agent-ecosystem/test/renderer/store/teamSlice.test.ts
2026-05-18 01:57:16 +03:00

6359 lines
204 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { create } from 'zustand';
import {
__getTeamScopedTransientStateForTests,
__resetTeamSliceModuleStateForTests,
createTeamSlice,
getActiveTeamPendingReplyWaits,
hasActiveTeamPendingReplyWait,
getCurrentProvisioningProgressForTeam,
selectMemberMessagesForTeamMember,
selectResolvedMemberForTeamName,
selectResolvedMembersForTeamName,
selectTeamDataForName,
} from '../../../src/renderer/store/slices/teamSlice';
import {
__resetTeamRefreshFanoutDiagnosticsForTests,
getTeamRefreshFanoutSnapshotForTests,
type TeamRefreshFanoutSnapshot,
} from '../../../src/renderer/store/teamRefreshFanoutDiagnostics';
const hoisted = vi.hoisted(() => ({
list: vi.fn(),
getData: vi.fn(),
getMessagesPage: vi.fn(),
getMemberActivityMeta: vi.fn(),
createTeam: vi.fn(),
launchTeam: vi.fn(),
getProvisioningStatus: vi.fn(),
getMemberSpawnStatuses: vi.fn(),
getTeamAgentRuntime: vi.fn(),
cancelProvisioning: vi.fn(),
deleteTeam: vi.fn(),
restoreTeam: vi.fn(),
permanentlyDeleteTeam: vi.fn(),
sendMessage: vi.fn(),
getOpenCodeRuntimeDeliveryStatus: vi.fn(),
retryFailedOpenCodeSecondaryLanes: vi.fn(),
restartMember: vi.fn(),
skipMemberForLaunch: vi.fn(),
requestReview: vi.fn(),
updateKanban: vi.fn(),
invalidateTaskChangeSummaries: vi.fn(),
onProvisioningProgress: vi.fn(() => () => undefined),
}));
const originalWindowAnimationFrame =
typeof window === 'undefined'
? null
: {
hasRequest: Object.prototype.hasOwnProperty.call(window, 'requestAnimationFrame'),
hasCancel: Object.prototype.hasOwnProperty.call(window, 'cancelAnimationFrame'),
requestAnimationFrame: window.requestAnimationFrame,
cancelAnimationFrame: window.cancelAnimationFrame,
};
vi.mock('@renderer/api', () => ({
api: {
teams: {
list: hoisted.list,
getData: hoisted.getData,
getMessagesPage: hoisted.getMessagesPage,
getMemberActivityMeta: hoisted.getMemberActivityMeta,
createTeam: hoisted.createTeam,
launchTeam: hoisted.launchTeam,
getProvisioningStatus: hoisted.getProvisioningStatus,
getMemberSpawnStatuses: hoisted.getMemberSpawnStatuses,
getTeamAgentRuntime: hoisted.getTeamAgentRuntime,
cancelProvisioning: hoisted.cancelProvisioning,
deleteTeam: hoisted.deleteTeam,
restoreTeam: hoisted.restoreTeam,
permanentlyDeleteTeam: hoisted.permanentlyDeleteTeam,
sendMessage: hoisted.sendMessage,
getOpenCodeRuntimeDeliveryStatus: hoisted.getOpenCodeRuntimeDeliveryStatus,
retryFailedOpenCodeSecondaryLanes: hoisted.retryFailedOpenCodeSecondaryLanes,
restartMember: hoisted.restartMember,
skipMemberForLaunch: hoisted.skipMemberForLaunch,
requestReview: hoisted.requestReview,
updateKanban: hoisted.updateKanban,
onProvisioningProgress: hoisted.onProvisioningProgress,
},
review: {
invalidateTaskChangeSummaries: hoisted.invalidateTaskChangeSummaries,
},
},
}));
vi.mock('../../../src/renderer/utils/unwrapIpc', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/renderer/utils/unwrapIpc')>();
return {
...actual,
unwrapIpc: async <T>(_operation: string, fn: () => Promise<T>): Promise<T> => {
try {
return await fn();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new actual.IpcError('mock-op', message, error);
}
},
};
});
function createSliceStore() {
return create<any>()((set, get, store) => ({
...createTeamSlice(set as never, get as never, store as never),
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(),
fetchTeams: vi.fn(async () => undefined),
fetchAllTasks: vi.fn(async () => undefined),
}));
}
function createTeamSnapshot(overrides: Record<string, unknown> = {}): {
teamName: string;
config: { name: string; members?: unknown[]; projectPath?: string };
tasks: unknown[];
members: unknown[];
kanbanState: { teamName: string; reviewers: unknown[]; tasks: Record<string, unknown> };
processes: unknown[];
isAlive?: boolean;
} {
return {
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
...overrides,
};
}
function createMemberSpawnStatus(overrides: Record<string, unknown> = {}) {
return {
status: 'online',
launchState: 'confirmed_alive',
error: undefined,
updatedAt: '2026-03-12T10:00:00.000Z',
runtimeAlive: true,
livenessSource: 'heartbeat',
bootstrapConfirmed: true,
hardFailure: false,
firstSpawnAcceptedAt: '2026-03-12T09:59:30.000Z',
lastHeartbeatAt: '2026-03-12T10:00:00.000Z',
...overrides,
};
}
function createMemberSpawnSnapshot(overrides: Record<string, unknown> = {}) {
const typedOverrides = overrides as {
statuses?: Record<string, ReturnType<typeof createMemberSpawnStatus>>;
};
return {
runId: 'runtime-run',
teamLaunchState: 'clean_success',
launchPhase: 'finished',
expectedMembers: ['alice'],
updatedAt: '2026-03-12T10:00:00.000Z',
summary: {
confirmedCount: 1,
pendingCount: 0,
failedCount: 0,
runtimeAlivePendingCount: 0,
},
source: 'merged',
statuses: typedOverrides.statuses ?? { alice: createMemberSpawnStatus() },
...overrides,
};
}
function createDeferredPromise<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function defineWindowAnimationFrame(
requestAnimationFrame: ((callback: FrameRequestCallback) => number) | undefined,
cancelAnimationFrame: ((handle: number) => void) | undefined
): void {
if (typeof window === 'undefined') {
return;
}
if (requestAnimationFrame === undefined) {
delete (window as Partial<Window>).requestAnimationFrame;
} else {
Object.defineProperty(window, 'requestAnimationFrame', {
configurable: true,
writable: true,
value: requestAnimationFrame,
});
}
if (cancelAnimationFrame === undefined) {
delete (window as Partial<Window>).cancelAnimationFrame;
} else {
Object.defineProperty(window, 'cancelAnimationFrame', {
configurable: true,
writable: true,
value: cancelAnimationFrame,
});
}
}
function restoreWindowAnimationFrame(): void {
if (typeof window === 'undefined' || originalWindowAnimationFrame === null) {
return;
}
defineWindowAnimationFrame(
originalWindowAnimationFrame.hasRequest
? originalWindowAnimationFrame.requestAnimationFrame
: undefined,
originalWindowAnimationFrame.hasCancel
? originalWindowAnimationFrame.cancelAnimationFrame
: undefined
);
}
function stubAnimationFrameWithTimer(): void {
defineWindowAnimationFrame(
(callback) => setTimeout(() => callback(Date.now()), 16) as unknown as number,
(handle) => clearTimeout(handle as unknown as ReturnType<typeof setTimeout>)
);
}
function stubAnimationFrameNeverFires(): void {
defineWindowAnimationFrame(
() => 1,
() => undefined
);
}
async function flushPostPaintTeamEnrichments(): Promise<void> {
await vi.advanceTimersByTimeAsync(16);
await vi.runOnlyPendingTimersAsync();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
}
async function flushMicrotasks(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
}
async function flushAsyncWork(): Promise<void> {
await flushMicrotasks();
await new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
await flushMicrotasks();
}
function createRuntimeSnapshot(overrides: Record<string, unknown> = {}) {
return {
teamName: 'my-team',
updatedAt: '2026-03-12T10:00:00.000Z',
runId: 'runtime-run',
members: {
alice: {
memberName: 'alice',
alive: true,
restartable: true,
backendType: 'tmux',
pid: 4242,
runtimeModel: 'gpt-5.4-mini',
rssBytes: 256 * 1024 * 1024,
updatedAt: '2026-03-12T10:00:00.000Z',
},
},
...overrides,
};
}
describe('teamSlice actions', () => {
beforeEach(() => {
vi.clearAllMocks();
__resetTeamSliceModuleStateForTests();
__resetTeamRefreshFanoutDiagnosticsForTests();
hoisted.list.mockResolvedValue([]);
hoisted.getData.mockResolvedValue(createTeamSnapshot());
hoisted.getMessagesPage.mockResolvedValue({
messages: [],
nextCursor: null,
hasMore: false,
feedRevision: 'rev-1',
});
hoisted.getMemberActivityMeta.mockResolvedValue({
teamName: 'my-team',
computedAt: '2026-03-12T10:00:00.000Z',
members: {},
feedRevision: 'rev-1',
});
hoisted.sendMessage.mockResolvedValue({ deliveredToInbox: true, messageId: 'm1' });
hoisted.getOpenCodeRuntimeDeliveryStatus.mockResolvedValue(null);
hoisted.requestReview.mockResolvedValue(undefined);
hoisted.updateKanban.mockResolvedValue(undefined);
hoisted.createTeam.mockResolvedValue({ runId: 'run-1' });
hoisted.launchTeam.mockResolvedValue({ runId: 'run-1' });
hoisted.invalidateTaskChangeSummaries.mockResolvedValue(undefined);
hoisted.getProvisioningStatus.mockResolvedValue({
runId: 'run-1',
teamName: 'my-team',
state: 'spawning',
message: 'Starting',
startedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
hoisted.getMemberSpawnStatuses.mockResolvedValue({ statuses: {}, runId: null });
hoisted.getTeamAgentRuntime.mockResolvedValue(
createRuntimeSnapshot({ runId: null, members: {} })
);
hoisted.cancelProvisioning.mockResolvedValue(undefined);
hoisted.deleteTeam.mockResolvedValue(undefined);
hoisted.restoreTeam.mockResolvedValue(undefined);
hoisted.permanentlyDeleteTeam.mockResolvedValue(undefined);
hoisted.retryFailedOpenCodeSecondaryLanes.mockResolvedValue({
attempted: [],
confirmed: [],
pending: [],
failed: [],
skipped: [],
});
hoisted.restartMember.mockResolvedValue(undefined);
hoisted.skipMemberForLaunch.mockResolvedValue(undefined);
});
afterEach(() => {
restoreWindowAnimationFrame();
vi.useRealTimers();
});
it('records terminal provisioning fanout diagnostics without changing visible graph hydrate behavior', () => {
const store = createSliceStore();
const fetchTeams = vi.fn(async () => undefined);
const refreshTeamData = vi.fn(async () => undefined);
store.setState({
fetchTeams,
refreshTeamData,
selectedTeamName: 'other-team',
selectedTeamData: createTeamSnapshot({
teamName: 'other-team',
config: { name: 'Other Team' },
}),
paneLayout: {
focusedPaneId: 'pane-default',
panes: [
{
id: 'pane-default',
widthFraction: 1,
tabs: [{ id: 'graph-my-team', type: 'graph', teamName: 'my-team', label: 'Graph' }],
activeTabId: 'graph-my-team',
},
],
},
});
store.getState().onProvisioningProgress({
runId: 'run-ready',
teamName: 'my-team',
state: 'ready',
message: 'Ready',
startedAt: '2026-03-12T10:00:00.000Z',
updatedAt: '2026-03-12T10:00:01.000Z',
} as never);
expect(fetchTeams).toHaveBeenCalledTimes(1);
expect(refreshTeamData).toHaveBeenCalledTimes(1);
expect(refreshTeamData).toHaveBeenCalledWith('my-team', { withDedup: true });
const snapshot = getTeamRefreshFanoutSnapshotForTests(
'my-team'
) as TeamRefreshFanoutSnapshot | null;
expect(
snapshot?.counts['provisioning-progress:provisioning:terminal-ready:fetchTeams:scheduled']
).toBe(1);
expect(
snapshot?.counts[
'provisioning-progress:provisioning:terminal-ready:refreshTeamData:scheduled'
]
).toBe(1);
});
it('maps inbox verify failure to user-friendly text', async () => {
const store = createSliceStore();
hoisted.sendMessage.mockRejectedValue(new Error('Failed to verify inbox write'));
await expect(
store.getState().sendTeamMessage('my-team', { member: 'alice', text: 'hello' })
).rejects.toThrow('Failed to verify inbox write');
expect(store.getState().sendMessageError).toBe(
'Message was written but not verified (race). Please try again.'
);
});
it('keeps send dialog result non-terminal when OpenCode runtime delivery fails after inbox persistence', async () => {
const store = createSliceStore();
hoisted.sendMessage.mockResolvedValue({
deliveredToInbox: true,
messageId: 'm-opencode-1',
runtimeDelivery: {
providerId: 'opencode',
attempted: true,
delivered: false,
reason: 'opencode_runtime_not_active',
},
});
const result = await store.getState().sendTeamMessage('my-team', {
member: 'bob',
text: 'hello',
});
expect(result.messageId).toBe('m-opencode-1');
expect(store.getState().lastSendMessageResult).toBeNull();
expect(store.getState().sendMessageError).toBeNull();
expect(store.getState().sendMessageWarning).toBe(
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete.'
);
expect(store.getState().sendMessageDebugDetails).toMatchObject({
messageId: 'm-opencode-1',
providerId: 'opencode',
delivered: false,
responsePending: null,
responseState: null,
ledgerStatus: null,
acceptanceUnknown: null,
reason: 'opencode_runtime_not_active',
diagnostics: [],
});
});
it('stores hidden OpenCode runtime diagnostics while live response is pending', async () => {
const store = createSliceStore();
hoisted.sendMessage.mockResolvedValue({
deliveredToInbox: true,
messageId: 'm-opencode-pending',
runtimeDelivery: {
providerId: 'opencode',
attempted: true,
delivered: true,
responsePending: true,
responseState: 'pending',
ledgerStatus: 'accepted',
acceptanceUnknown: false,
reason: 'assistant_response_pending',
diagnostics: ['assistant_response_pending'],
},
});
const result = await store.getState().sendTeamMessage('my-team', {
member: 'bob',
text: 'hello',
});
expect(store.getState().lastSendMessageResult).toBe(result);
expect(store.getState().sendMessageWarning).toBe(
'OpenCode delivery is still being checked. Message was saved and will be observed before retry if needed.'
);
expect(store.getState().sendMessageDebugDetails).toMatchObject({
messageId: 'm-opencode-pending',
providerId: 'opencode',
delivered: true,
responsePending: true,
responseState: 'pending',
ledgerStatus: 'accepted',
acceptanceUnknown: false,
reason: 'assistant_response_pending',
diagnostics: ['assistant_response_pending'],
});
});
it('updates pending OpenCode runtime diagnostics when delivery becomes terminal', async () => {
const store = createSliceStore();
hoisted.sendMessage.mockResolvedValue({
deliveredToInbox: true,
messageId: 'm-opencode-pending',
runtimeDelivery: {
providerId: 'opencode',
attempted: true,
delivered: true,
responsePending: true,
responseState: 'pending',
ledgerStatus: 'accepted',
acceptanceUnknown: false,
reason: 'assistant_response_pending',
diagnostics: ['assistant_response_pending'],
},
});
hoisted.getOpenCodeRuntimeDeliveryStatus.mockResolvedValue({
messageId: 'm-opencode-pending',
providerId: 'opencode',
attempted: true,
delivered: false,
responsePending: false,
responseState: 'empty_assistant_turn',
ledgerStatus: 'failed_terminal',
acceptanceUnknown: false,
reason: 'empty_assistant_turn',
diagnostics: ['empty_assistant_turn'],
});
await store.getState().sendTeamMessage('my-team', {
member: 'bob',
text: 'hello',
});
await store.getState().refreshSendMessageRuntimeDeliveryStatus('my-team', 'm-opencode-pending');
expect(store.getState().sendMessageWarning).toBe(
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete. Reason: OpenCode returned an empty assistant turn.'
);
expect(store.getState().sendMessageDebugDetails).toMatchObject({
messageId: 'm-opencode-pending',
delivered: false,
responsePending: false,
responseState: 'empty_assistant_turn',
ledgerStatus: 'failed_terminal',
reason: 'empty_assistant_turn',
diagnostics: ['empty_assistant_turn'],
});
});
it('checks the original message when queued blocker impact is no longer user-visible', async () => {
const store = createSliceStore();
hoisted.sendMessage.mockResolvedValue({
deliveredToInbox: true,
messageId: 'm-opencode-queued',
runtimeDelivery: {
providerId: 'opencode',
attempted: true,
delivered: true,
responsePending: true,
responseState: 'pending',
ledgerStatus: 'accepted',
queuedBehindMessageId: 'm-opencode-blocker',
reason: 'opencode_delivery_response_pending',
diagnostics: ['opencode_delivery_response_pending'],
userVisibleImpact: {
state: 'checking',
},
},
});
hoisted.getOpenCodeRuntimeDeliveryStatus
.mockResolvedValueOnce({
messageId: 'm-opencode-blocker',
providerId: 'opencode',
attempted: true,
delivered: true,
responsePending: true,
responseState: 'responded_non_visible_tool',
ledgerStatus: 'responded',
acceptanceUnknown: false,
reason: 'non_visible_tool_without_task_progress',
diagnostics: ['non_visible_tool_without_task_progress'],
userVisibleImpact: {
state: 'none',
},
})
.mockResolvedValueOnce({
messageId: 'm-opencode-queued',
providerId: 'opencode',
attempted: true,
delivered: false,
responsePending: false,
responseState: 'empty_assistant_turn',
ledgerStatus: 'failed_terminal',
acceptanceUnknown: false,
reason: 'empty_assistant_turn',
diagnostics: ['empty_assistant_turn'],
userVisibleImpact: {
state: 'error',
reasonCode: 'backend_error',
message: 'empty_assistant_turn',
},
});
await store.getState().sendTeamMessage('my-team', {
member: 'bob',
text: 'hello',
});
await store.getState().refreshSendMessageRuntimeDeliveryStatus('my-team', {
messageId: 'm-opencode-queued',
statusMessageId: 'm-opencode-blocker',
});
expect(hoisted.getOpenCodeRuntimeDeliveryStatus).toHaveBeenNthCalledWith(
1,
'my-team',
'm-opencode-blocker'
);
expect(hoisted.getOpenCodeRuntimeDeliveryStatus).toHaveBeenNthCalledWith(
2,
'my-team',
'm-opencode-queued'
);
expect(store.getState().sendMessageWarning).toBe(
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete. Reason: OpenCode returned an empty assistant turn.'
);
expect(store.getState().sendMessageDebugDetails).toMatchObject({
messageId: 'm-opencode-queued',
statusMessageId: 'm-opencode-queued',
userVisibleState: 'error',
});
});
it('clears OpenCode runtime diagnostics only for the matching message id', async () => {
const store = createSliceStore();
hoisted.sendMessage.mockResolvedValue({
deliveredToInbox: true,
messageId: 'm-opencode-pending',
runtimeDelivery: {
providerId: 'opencode',
attempted: true,
delivered: true,
responsePending: true,
responseState: 'pending',
ledgerStatus: 'accepted',
acceptanceUnknown: false,
reason: 'assistant_response_pending',
diagnostics: ['assistant_response_pending'],
},
});
await store.getState().sendTeamMessage('my-team', {
member: 'bob',
text: 'hello',
});
store.getState().clearSendMessageRuntimeDiagnostics('other-message');
expect(store.getState().sendMessageWarning).toBe(
'OpenCode delivery is still being checked. Message was saved and will be observed before retry if needed.'
);
expect(store.getState().sendMessageDebugDetails?.messageId).toBe('m-opencode-pending');
store.getState().clearSendMessageRuntimeDiagnostics('m-opencode-pending');
expect(store.getState().sendMessageWarning).toBeNull();
expect(store.getState().sendMessageDebugDetails).toBeNull();
});
it('clears OpenCode runtime diagnostics after normal success or send failure', async () => {
const store = createSliceStore();
hoisted.sendMessage
.mockResolvedValueOnce({
deliveredToInbox: true,
messageId: 'm-opencode-failed',
runtimeDelivery: {
providerId: 'opencode',
attempted: true,
delivered: false,
reason: 'runtime_unavailable',
},
})
.mockResolvedValueOnce({
deliveredToInbox: true,
messageId: 'm-ok',
})
.mockRejectedValueOnce(new Error('boom'));
await store.getState().sendTeamMessage('my-team', { member: 'bob', text: 'first' });
expect(store.getState().sendMessageDebugDetails?.messageId).toBe('m-opencode-failed');
await store.getState().sendTeamMessage('my-team', { member: 'alice', text: 'second' });
expect(store.getState().sendMessageWarning).toBeNull();
expect(store.getState().sendMessageDebugDetails).toBeNull();
expect(store.getState().lastSendMessageResult?.messageId).toBe('m-ok');
await expect(
store.getState().sendTeamMessage('my-team', { member: 'alice', text: 'third' })
).rejects.toThrow('boom');
expect(store.getState().sendMessageWarning).toBeNull();
expect(store.getState().sendMessageDebugDetails).toBeNull();
expect(store.getState().sendMessageError).toBe('boom');
});
it('maps task status verify failure in updateKanban and rethrows', async () => {
const store = createSliceStore();
hoisted.updateKanban.mockRejectedValue(new Error('Task status update verification failed: 12'));
await expect(
store.getState().updateKanban('my-team', '12', { op: 'request_changes' })
).rejects.toThrow('Task status update verification failed: 12');
expect(store.getState().reviewActionError).toBe(
'Failed to update task status (possible agent conflict).'
);
});
it('maps task status verify failure in requestReview and rethrows', async () => {
const store = createSliceStore();
hoisted.requestReview.mockRejectedValue(
new Error('Task status update verification failed: 22')
);
await expect(store.getState().requestReview('my-team', '22')).rejects.toThrow(
'Task status update verification failed: 22'
);
expect(store.getState().reviewActionError).toBe(
'Failed to update task status (possible agent conflict).'
);
});
it('does not warm task-change summaries on team open', async () => {
const store = createSliceStore();
hoisted.getData.mockResolvedValue({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [
{
id: 'completed-1',
owner: 'alice',
status: 'completed',
createdAt: '2026-03-20T08:00:00.000Z',
updatedAt: '2026-03-20T12:00:00.000Z',
},
],
members: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
});
await store.getState().selectTeam('my-team');
expect(store.getState().warmTaskChangeSummaries).not.toHaveBeenCalled();
});
it('commits owner slot drops in the current session while persistence is disabled', () => {
const store = createSliceStore();
store
.getState()
.commitTeamGraphOwnerSlotDrop(
'my-team',
'agent-alice',
{ ringIndex: 0, sectorIndex: 2 },
'agent-bob',
{ ringIndex: 0, sectorIndex: 1 }
);
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
'agent-alice': { ringIndex: 0, sectorIndex: 2 },
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
});
expect(store.getState().graphLayoutSessionByTeam['my-team']).toEqual({
mode: 'manual',
signature: null,
});
});
it('stores non-default graph layout mode without mutating radial slot assignments', () => {
const store = createSliceStore();
store
.getState()
.commitTeamGraphOwnerSlotDrop('my-team', 'agent-alice', { ringIndex: 0, sectorIndex: 2 });
store.getState().setTeamGraphLayoutMode('my-team', 'radial');
expect(store.getState().graphLayoutModeByTeam['my-team']).toBe('radial');
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
'agent-alice': { ringIndex: 0, sectorIndex: 2 },
});
store.getState().setTeamGraphLayoutMode('my-team', 'grid-under-lead');
expect(store.getState().graphLayoutModeByTeam['my-team']).toBe('grid-under-lead');
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
'agent-alice': { ringIndex: 0, sectorIndex: 2 },
});
});
it('swaps grid owners from canonical visible order without mutating radial slots', () => {
const store = createSliceStore();
store.setState({
teamDataCacheByName: {
'my-team': createTeamSnapshot({
config: {
name: 'My Team',
members: [
{ name: 'team-lead', agentId: 'lead-agent' },
{ name: 'alice', agentId: 'agent-alice' },
{ name: 'bob', agentId: 'agent-bob' },
{ name: 'tom', agentId: 'agent-tom' },
],
},
members: [
{ name: 'team-lead', agentId: 'lead-agent', agentType: 'team-lead' },
{ name: 'alice', agentId: 'agent-alice' },
{ name: 'bob', agentId: 'agent-bob' },
{ name: 'tom', agentId: 'agent-tom' },
],
}),
},
slotAssignmentsByTeam: {
'my-team': {
'agent-alice': { ringIndex: 0, sectorIndex: 2 },
},
},
});
store.getState().swapTeamGraphGridOwners('my-team', 'agent-alice', 'agent-tom');
expect(store.getState().gridOwnerOrderByTeam['my-team']).toEqual([
'agent-tom',
'agent-bob',
'agent-alice',
]);
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
'agent-alice': { ringIndex: 0, sectorIndex: 2 },
});
});
it('keeps grid owner order unchanged when radial slots are committed', () => {
const store = createSliceStore();
store.setState({
gridOwnerOrderByTeam: {
'my-team': ['agent-bob', 'agent-alice'],
},
});
store
.getState()
.commitTeamGraphOwnerSlotDrop('my-team', 'agent-alice', { ringIndex: 0, sectorIndex: 2 });
expect(store.getState().gridOwnerOrderByTeam['my-team']).toEqual(['agent-bob', 'agent-alice']);
});
it('replaces persisted slot assignments with defaults while persistence is disabled', () => {
const store = createSliceStore();
store.setState({
slotLayoutVersion: 'stable-slots-v1',
slotAssignmentsByTeam: {
'my-team': {
alice: { ringIndex: 0, sectorIndex: 3 },
},
},
});
store.getState().ensureTeamGraphSlotAssignments('my-team', [
{ name: 'alice', agentId: 'agent-alice' },
{ name: 'bob', agentId: 'agent-bob' },
]);
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
});
});
it('seeds first-open cardinal slot defaults for small visible teams with no saved placements', () => {
const store = createSliceStore();
store.getState().ensureTeamGraphSlotAssignments('my-team', [
{ name: 'alice', agentId: 'agent-alice' },
{ name: 'bob', agentId: 'agent-bob' },
{ name: 'tom', agentId: 'agent-tom' },
{ name: 'jack', agentId: 'agent-jack' },
]);
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
'agent-jack': { ringIndex: 0, sectorIndex: 2 },
'agent-tom': { ringIndex: 0, sectorIndex: 3 },
});
});
it('uses config member order instead of transient visible member array order for defaults', () => {
const store = createSliceStore();
store.getState().ensureTeamGraphSlotAssignments(
'my-team',
[
{ name: 'jack', agentId: 'agent-jack' },
{ name: 'tom', agentId: 'agent-tom' },
{ name: 'alice', agentId: 'agent-alice' },
{ name: 'bob', agentId: 'agent-bob' },
],
[
{ name: 'alice', agentId: 'agent-alice' },
{ name: 'bob', agentId: 'agent-bob' },
{ name: 'tom', agentId: 'agent-tom' },
{ name: 'jack', agentId: 'agent-jack' },
]
);
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
'agent-tom': { ringIndex: 0, sectorIndex: 2 },
'agent-jack': { ringIndex: 0, sectorIndex: 3 },
});
});
it('ignores the lead member when deriving small-team cardinal defaults', () => {
const store = createSliceStore();
store.getState().ensureTeamGraphSlotAssignments('my-team', [
{ name: 'team-lead', agentId: 'lead-id' },
{ name: 'alice', agentId: 'agent-alice' },
{ name: 'bob', agentId: 'agent-bob' },
{ name: 'tom', agentId: 'agent-tom' },
{ name: 'jack', agentId: 'agent-jack' },
]);
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
'agent-jack': { ringIndex: 0, sectorIndex: 2 },
'agent-tom': { ringIndex: 0, sectorIndex: 3 },
});
});
it('drops hidden persisted slot assignments and reseeds visible members while persistence is disabled', () => {
const store = createSliceStore();
store.setState({
slotLayoutVersion: 'stable-slots-v1',
slotAssignmentsByTeam: {
'my-team': {
'agent-hidden': { ringIndex: 2, sectorIndex: 4 },
},
},
});
store.getState().ensureTeamGraphSlotAssignments('my-team', [
{ name: 'hidden', agentId: 'agent-hidden', removedAt: '2026-04-16T08:00:00.000Z' },
{ name: 'alice', agentId: 'agent-alice' },
{ name: 'bob', agentId: 'agent-bob' },
]);
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
});
});
it('resets stale slot assignments when slot layout version mismatches', () => {
const store = createSliceStore();
store.setState({
slotLayoutVersion: 'legacy-layout-version',
slotAssignmentsByTeam: {
'other-team': {
'agent-old': { ringIndex: 9, sectorIndex: 9 },
},
'my-team': {
alice: { ringIndex: 0, sectorIndex: 1 },
},
},
});
store
.getState()
.ensureTeamGraphSlotAssignments('my-team', [{ name: 'alice', agentId: 'agent-alice' }]);
expect(store.getState().slotLayoutVersion).toBe('stable-slots-v1');
expect(store.getState().slotAssignmentsByTeam).toEqual({
'my-team': {
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
},
});
});
it('ignores hidden-member persisted slot assignments while persistence is disabled', () => {
const store = createSliceStore();
store.setState({
slotLayoutVersion: 'stable-slots-v1',
slotAssignmentsByTeam: {
'my-team': {
'agent-hidden': { ringIndex: 1, sectorIndex: 5 },
'agent-visible': { ringIndex: 0, sectorIndex: 2 },
},
},
});
store
.getState()
.ensureTeamGraphSlotAssignments('my-team', [{ name: 'visible', agentId: 'agent-visible' }]);
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
'agent-visible': { ringIndex: 0, sectorIndex: 0 },
});
});
it('reseeds defaults again while the team remains in default mode and visible owners change', () => {
const store = createSliceStore();
store.getState().ensureTeamGraphSlotAssignments('my-team', [
{ name: 'alice', agentId: 'agent-alice' },
{ name: 'bob', agentId: 'agent-bob' },
]);
store.getState().ensureTeamGraphSlotAssignments('my-team', [
{ name: 'alice', agentId: 'agent-alice' },
{ name: 'bob', agentId: 'agent-bob' },
{ name: 'tom', agentId: 'agent-tom' },
{ name: 'jack', agentId: 'agent-jack' },
]);
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
'agent-jack': { ringIndex: 0, sectorIndex: 2 },
'agent-tom': { ringIndex: 0, sectorIndex: 3 },
});
expect(store.getState().graphLayoutSessionByTeam['my-team']).toEqual({
mode: 'default',
signature: 'agent-alice|agent-bob|agent-jack|agent-tom',
});
});
it('does not reshuffle existing owners after the team enters manual mode', () => {
const store = createSliceStore();
store.getState().ensureTeamGraphSlotAssignments('my-team', [
{ name: 'alice', agentId: 'agent-alice' },
{ name: 'bob', agentId: 'agent-bob' },
]);
store.getState().setTeamGraphOwnerSlotAssignment('my-team', 'agent-alice', {
ringIndex: 1,
sectorIndex: 4,
});
store.getState().ensureTeamGraphSlotAssignments('my-team', [
{ name: 'alice', agentId: 'agent-alice' },
{ name: 'bob', agentId: 'agent-bob' },
{ name: 'tom', agentId: 'agent-tom' },
{ name: 'jack', agentId: 'agent-jack' },
]);
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
'agent-alice': { ringIndex: 1, sectorIndex: 4 },
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
});
expect(store.getState().graphLayoutSessionByTeam['my-team']).toEqual({
mode: 'manual',
signature: 'agent-alice|agent-bob',
});
});
it('normalizes legacy six-owner row-orbit slots before preserving manual layout', () => {
const store = createSliceStore();
const members = [
{ name: 'alice', agentId: 'agent-alice' },
{ name: 'bob', agentId: 'agent-bob' },
{ name: 'tom', agentId: 'agent-tom' },
{ name: 'jack', agentId: 'agent-jack' },
{ name: 'nova', agentId: 'agent-nova' },
{ name: 'atlas', agentId: 'agent-atlas' },
];
store.setState({
slotAssignmentsByTeam: {
'my-team': {
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
'agent-atlas': { ringIndex: 0, sectorIndex: 1 },
'agent-bob': { ringIndex: 0, sectorIndex: 2 },
'agent-jack': { ringIndex: 1, sectorIndex: 0 },
'agent-nova': { ringIndex: 1, sectorIndex: 1 },
'agent-tom': { ringIndex: 1, sectorIndex: 2 },
},
},
graphLayoutSessionByTeam: {
'my-team': {
mode: 'manual',
signature: null,
},
},
});
store.getState().ensureTeamGraphSlotAssignments('my-team', members);
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
'agent-atlas': { ringIndex: 0, sectorIndex: 1 },
'agent-bob': { ringIndex: 0, sectorIndex: 2 },
'agent-jack': { ringIndex: 2, sectorIndex: 0 },
'agent-nova': { ringIndex: 2, sectorIndex: 1 },
'agent-tom': { ringIndex: 2, sectorIndex: 2 },
});
expect(store.getState().graphLayoutSessionByTeam['my-team']).toEqual({
mode: 'manual',
signature: null,
});
});
it('resets graph slot assignments back to defaults when reopening the graph surface', () => {
const store = createSliceStore();
store.setState({
teamDataCacheByName: {
'my-team': {
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [
{ name: 'alice', agentId: 'agent-alice' },
{ name: 'bob', agentId: 'agent-bob' },
{ name: 'tom', agentId: 'agent-tom' },
{ name: 'jack', agentId: 'agent-jack' },
],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
},
},
});
store.getState().ensureTeamGraphSlotAssignments('my-team', [
{ name: 'alice', agentId: 'agent-alice' },
{ name: 'bob', agentId: 'agent-bob' },
{ name: 'tom', agentId: 'agent-tom' },
{ name: 'jack', agentId: 'agent-jack' },
]);
store
.getState()
.commitTeamGraphOwnerSlotDrop(
'my-team',
'agent-alice',
{ ringIndex: 0, sectorIndex: 2 },
'agent-jack',
{ ringIndex: 0, sectorIndex: 0 }
);
store.getState().resetTeamGraphSlotAssignmentsToDefaults('my-team');
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
'agent-jack': { ringIndex: 0, sectorIndex: 2 },
'agent-tom': { ringIndex: 0, sectorIndex: 3 },
});
expect(store.getState().graphLayoutSessionByTeam['my-team']).toEqual({
mode: 'default',
signature: 'agent-alice|agent-bob|agent-jack|agent-tom',
});
});
it('syncs both team and graph tab labels when the team display name changes', async () => {
const store = createSliceStore();
const getAllPaneTabs = vi.fn(() => [
{ id: 'team-tab', type: 'team', teamName: 'my-team', label: 'my-team' },
{ id: 'graph-tab', type: 'graph', teamName: 'my-team', label: 'my-team Graph' },
]);
const updateTabLabel = vi.fn();
store.setState({
getAllPaneTabs,
updateTabLabel,
});
hoisted.getData.mockResolvedValue({
teamName: 'my-team',
config: { name: 'Northstar', members: [], projectPath: '/repo' },
tasks: [],
members: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
await store.getState().selectTeam('my-team');
expect(updateTabLabel).toHaveBeenCalledWith('team-tab', 'Northstar');
expect(updateTabLabel).toHaveBeenCalledWith('graph-tab', 'Northstar Graph');
});
it('clears stale selectedTeamData immediately when selecting an uncached team', async () => {
const store = createSliceStore();
const nextTeamData = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
store.setState({
selectedTeamName: 'alpha-team',
selectedTeamData: createTeamSnapshot({
teamName: 'alpha-team',
config: { name: 'Alpha Team' },
}),
});
hoisted.getData.mockImplementationOnce(async () => nextTeamData.promise);
const selectPromise = store.getState().selectTeam('beta-team');
expect(store.getState().selectedTeamName).toBe('beta-team');
expect(store.getState().selectedTeamLoading).toBe(true);
expect(store.getState().selectedTeamData).toBeNull();
nextTeamData.resolve(
createTeamSnapshot({
teamName: 'beta-team',
config: { name: 'Beta Team' },
})
);
await selectPromise;
expect(store.getState().selectedTeamData?.teamName).toBe('beta-team');
});
it('repoints selectedTeamData to the cached snapshot immediately on team switch', async () => {
const store = createSliceStore();
const nextTeamData = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const cachedBeta = createTeamSnapshot({
teamName: 'beta-team',
config: { name: 'Beta Team' },
});
store.setState({
selectedTeamName: 'alpha-team',
selectedTeamData: createTeamSnapshot({
teamName: 'alpha-team',
config: { name: 'Alpha Team' },
}),
teamDataCacheByName: {
'beta-team': cachedBeta,
},
});
hoisted.getData.mockImplementationOnce(async () => nextTeamData.promise);
const selectPromise = store.getState().selectTeam('beta-team');
expect(store.getState().selectedTeamName).toBe('beta-team');
expect(store.getState().selectedTeamData).toBe(cachedBeta);
nextTeamData.resolve(cachedBeta);
await selectPromise;
expect(store.getState().selectedTeamData).toBe(cachedBeta);
});
it('commits selectTeam thin snapshot before post-paint messages and activity meta refreshes', async () => {
vi.useFakeTimers();
stubAnimationFrameWithTimer();
const store = createSliceStore();
const messagesRequest = createDeferredPromise<{
messages: Array<{
from: string;
text: string;
timestamp: string;
messageId: string;
source: 'inbox';
}>;
nextCursor: null;
hasMore: false;
feedRevision: string;
}>();
const metaRequest = createDeferredPromise<{
teamName: string;
computedAt: string;
feedRevision: string;
members: Record<string, never>;
}>();
const thinSnapshot = createTeamSnapshot({
config: { name: 'Thin Team' },
members: [{ name: 'alice', role: 'developer', currentTaskId: null }],
});
hoisted.getData.mockResolvedValueOnce(thinSnapshot);
hoisted.getMessagesPage.mockImplementationOnce(() => messagesRequest.promise);
hoisted.getMemberActivityMeta.mockImplementationOnce(() => metaRequest.promise);
await store.getState().selectTeam('my-team');
expect(hoisted.getData).toHaveBeenCalledWith('my-team', {
includeMemberBranches: false,
});
expect(store.getState().selectedTeamLoading).toBe(false);
expect(store.getState().selectedTeamData).toEqual(thinSnapshot);
expect(hoisted.getMessagesPage).not.toHaveBeenCalled();
expect(hoisted.getMemberActivityMeta).not.toHaveBeenCalled();
await flushPostPaintTeamEnrichments();
expect(hoisted.getMessagesPage).toHaveBeenCalledWith('my-team', { limit: 50 });
expect(hoisted.getMemberActivityMeta).not.toHaveBeenCalled();
messagesRequest.resolve({
messages: [],
nextCursor: null,
hasMore: false,
feedRevision: 'rev-thin',
});
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(hoisted.getMemberActivityMeta).toHaveBeenCalledWith('my-team');
metaRequest.resolve({
teamName: 'my-team',
computedAt: '2026-03-12T10:00:00.000Z',
feedRevision: 'rev-thin',
members: {},
});
await Promise.resolve();
await Promise.resolve();
expect(store.getState().selectedTeamData).toEqual(thinSnapshot);
expect(store.getState().selectedTeamError).toBeNull();
});
it('keeps selected team data visible when post-paint message refresh fails', async () => {
vi.useFakeTimers();
stubAnimationFrameWithTimer();
const store = createSliceStore();
const thinSnapshot = createTeamSnapshot({
config: { name: 'Thin Team' },
members: [{ name: 'alice', role: 'developer', currentTaskId: null }],
});
hoisted.getData.mockResolvedValueOnce(thinSnapshot);
hoisted.getMessagesPage.mockRejectedValueOnce(new Error('message feed unavailable'));
await store.getState().selectTeam('my-team');
await flushPostPaintTeamEnrichments();
await Promise.resolve();
await Promise.resolve();
expect(hoisted.getMessagesPage).toHaveBeenCalledTimes(1);
expect(store.getState().selectedTeamData).toEqual(thinSnapshot);
expect(store.getState().selectedTeamError).toBeNull();
expect(store.getState().teamMessagesByName['my-team']?.loadingHead).toBe(false);
});
it('queues a full team refresh behind an in-flight thin selectTeam snapshot', async () => {
vi.useFakeTimers();
stubAnimationFrameWithTimer();
const store = createSliceStore();
const thinRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const thinSnapshot = createTeamSnapshot({
config: { name: 'Thin Team' },
});
const fullSnapshot = createTeamSnapshot({
config: { name: 'Full Team' },
members: [{ name: 'alice', role: 'developer', currentTaskId: null, gitBranch: 'feature/a' }],
});
hoisted.getData
.mockImplementationOnce(() => thinRequest.promise)
.mockResolvedValueOnce(fullSnapshot);
const selectPromise = store.getState().selectTeam('my-team');
await Promise.resolve();
await store.getState().refreshTeamData('my-team', { withDedup: true });
expect(hoisted.getData).toHaveBeenCalledTimes(1);
expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({
hasQueuedFullTeamDataRefreshAfterThin: true,
});
thinRequest.resolve(thinSnapshot);
await selectPromise;
expect(store.getState().selectedTeamData).toEqual(thinSnapshot);
expect(hoisted.getData).toHaveBeenCalledTimes(1);
await flushPostPaintTeamEnrichments();
await Promise.resolve();
await Promise.resolve();
expect(hoisted.getData).toHaveBeenCalledTimes(2);
expect(hoisted.getData.mock.calls[1]).toEqual(['my-team']);
expect(store.getState().selectedTeamData).toEqual(fullSnapshot);
expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({
hasQueuedFullTeamDataRefreshAfterThin: false,
});
});
it('drains queued full team refresh through the post-paint fallback when rAF never fires', async () => {
vi.useFakeTimers();
stubAnimationFrameNeverFires();
const store = createSliceStore();
const thinRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
hoisted.getData
.mockImplementationOnce(() => thinRequest.promise)
.mockResolvedValueOnce(
createTeamSnapshot({
config: { name: 'Full Team After Fallback' },
})
);
const selectPromise = store.getState().selectTeam('my-team');
await Promise.resolve();
await store.getState().refreshTeamData('my-team', { withDedup: true });
thinRequest.resolve(createTeamSnapshot({ config: { name: 'Thin Team' } }));
await selectPromise;
await vi.advanceTimersByTimeAsync(499);
expect(hoisted.getData).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
await Promise.resolve();
await Promise.resolve();
expect(hoisted.getData).toHaveBeenCalledTimes(2);
expect(store.getState().selectedTeamData?.config.name).toBe('Full Team After Fallback');
expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({
hasQueuedFullTeamDataRefreshAfterThin: false,
hasPostPaintTeamEnrichmentTimer: false,
});
});
it('keeps selected team data visible when post-paint activity meta refresh fails', async () => {
vi.useFakeTimers();
stubAnimationFrameWithTimer();
const store = createSliceStore();
const thinSnapshot = createTeamSnapshot({
config: { name: 'Thin Team' },
members: [{ name: 'alice', role: 'developer', currentTaskId: null }],
});
hoisted.getData.mockResolvedValueOnce(thinSnapshot);
hoisted.getMessagesPage.mockResolvedValueOnce({
messages: [
{
from: 'alice',
text: 'Fresh message',
timestamp: '2026-03-12T10:00:00.000Z',
messageId: 'msg-fresh',
source: 'inbox',
},
],
nextCursor: null,
hasMore: false,
feedRevision: 'rev-meta-fail',
});
hoisted.getMemberActivityMeta.mockRejectedValueOnce(new Error('meta unavailable'));
await store.getState().selectTeam('my-team');
await flushPostPaintTeamEnrichments();
await flushMicrotasks();
expect(hoisted.getMemberActivityMeta).toHaveBeenCalledWith('my-team');
expect(store.getState().selectedTeamData).toEqual(thinSnapshot);
expect(store.getState().selectedTeamError).toBeNull();
});
it('does not share a forced full refresh request with an in-flight thin selectTeam request', async () => {
const store = createSliceStore();
const thinRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const fullRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const thinSnapshot = createTeamSnapshot({ config: { name: 'Thin Team' } });
const fullSnapshot = createTeamSnapshot({
config: { name: 'Full Team' },
members: [{ name: 'alice', role: 'developer', currentTaskId: null, gitBranch: 'feature/a' }],
});
hoisted.getData
.mockImplementationOnce(() => thinRequest.promise)
.mockImplementationOnce(() => fullRequest.promise);
const selectPromise = store.getState().selectTeam('my-team');
await flushMicrotasks();
const fullPromise = store.getState().refreshTeamData('my-team', { withDedup: false });
expect(hoisted.getData).toHaveBeenCalledTimes(2);
expect(hoisted.getData.mock.calls[0]).toEqual(['my-team', { includeMemberBranches: false }]);
expect(hoisted.getData.mock.calls[1]).toEqual(['my-team']);
thinRequest.resolve(thinSnapshot);
await selectPromise;
fullRequest.resolve(fullSnapshot);
await fullPromise;
expect(store.getState().selectedTeamData).toEqual(fullSnapshot);
});
it('does not let a late thin selectTeam snapshot clear members loaded by an earlier full refresh', async () => {
const store = createSliceStore();
const thinRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const fullRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const thinSnapshot = createTeamSnapshot({
config: { name: 'Thin Team' },
members: [],
});
const fullSnapshot = createTeamSnapshot({
config: { name: 'Full Team' },
members: [{ name: 'alice', role: 'developer', currentTaskId: null, gitBranch: 'feature/a' }],
});
store.setState({
teamByName: {
'my-team': {
teamName: 'my-team',
displayName: 'My Team',
description: '',
memberCount: 1,
members: [{ name: 'alice', role: 'developer' }],
taskCount: 0,
lastActivity: null,
},
},
});
hoisted.getData
.mockImplementationOnce(() => thinRequest.promise)
.mockImplementationOnce(() => fullRequest.promise);
const selectPromise = store.getState().selectTeam('my-team');
await flushMicrotasks();
const fullPromise = store.getState().refreshTeamData('my-team', { withDedup: false });
fullRequest.resolve(fullSnapshot);
await fullPromise;
expect(store.getState().selectedTeamData).toEqual(fullSnapshot);
thinRequest.resolve(thinSnapshot);
await selectPromise;
expect(store.getState().selectedTeamData).toEqual(fullSnapshot);
expect(store.getState().teamDataCacheByName['my-team']).toEqual(fullSnapshot);
expect(selectResolvedMembersForTeamName(store.getState(), 'my-team')).toHaveLength(1);
});
it('does not let a late failed selectTeam request clear members loaded by a full refresh', async () => {
const store = createSliceStore();
const thinRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const fullRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const fullSnapshot = createTeamSnapshot({
config: { name: 'Full Team' },
members: [{ name: 'alice', role: 'developer', currentTaskId: null, gitBranch: 'feature/a' }],
});
store.setState({
teamByName: {
'my-team': {
teamName: 'my-team',
displayName: 'My Team',
description: '',
memberCount: 1,
members: [{ name: 'alice', role: 'developer' }],
taskCount: 0,
lastActivity: null,
},
},
});
hoisted.getData
.mockImplementationOnce(() => thinRequest.promise)
.mockImplementationOnce(() => fullRequest.promise);
const selectPromise = store.getState().selectTeam('my-team');
await flushMicrotasks();
const fullPromise = store.getState().refreshTeamData('my-team', { withDedup: false });
fullRequest.resolve(fullSnapshot);
await fullPromise;
expect(store.getState().selectedTeamData).toEqual(fullSnapshot);
expect(store.getState().selectedTeamLoading).toBe(true);
thinRequest.reject(new Error('Timeout after 30000ms: team:getData(my-team,mode=thin)'));
await selectPromise;
expect(store.getState().selectedTeamData).toEqual(fullSnapshot);
expect(store.getState().teamDataCacheByName['my-team']).toEqual(fullSnapshot);
expect(store.getState().selectedTeamLoading).toBe(false);
expect(store.getState().selectedTeamError).toBeNull();
});
it('preserves an earlier full refresh even when the cached baseline had the same member names', async () => {
const store = createSliceStore();
const thinRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const fullRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const cachedSnapshot = createTeamSnapshot({
config: { name: 'Cached Team' },
members: [{ name: 'alice', role: 'developer', currentTaskId: null }],
});
const thinSnapshot = createTeamSnapshot({
config: { name: 'Thin Team' },
members: [],
});
const fullSnapshot = createTeamSnapshot({
config: { name: 'Full Team' },
members: [{ name: 'alice', role: 'developer', currentTaskId: null, gitBranch: 'feature/a' }],
});
store.setState({
teamByName: {
'my-team': {
teamName: 'my-team',
displayName: 'My Team',
description: '',
memberCount: 1,
members: [{ name: 'alice', role: 'developer' }],
taskCount: 0,
lastActivity: null,
},
},
teamDataCacheByName: {
'my-team': cachedSnapshot,
},
});
hoisted.getData
.mockImplementationOnce(() => thinRequest.promise)
.mockImplementationOnce(() => fullRequest.promise);
const selectPromise = store.getState().selectTeam('my-team');
await flushMicrotasks();
const fullPromise = store.getState().refreshTeamData('my-team', { withDedup: false });
fullRequest.resolve(fullSnapshot);
await fullPromise;
expect(store.getState().selectedTeamData).toEqual(fullSnapshot);
thinRequest.resolve(thinSnapshot);
await selectPromise;
expect(store.getState().selectedTeamData).toEqual(fullSnapshot);
expect(store.getState().teamDataCacheByName['my-team']).toEqual(fullSnapshot);
});
it('does not let an empty selectTeam snapshot clear an already cached member roster', async () => {
const store = createSliceStore();
const cachedSnapshot = createTeamSnapshot({
config: { name: 'Cached Team' },
members: [{ name: 'alice', role: 'developer', currentTaskId: null, gitBranch: 'feature/a' }],
});
const thinSnapshot = createTeamSnapshot({
config: { name: 'Thin Team' },
members: [],
});
store.setState({
teamByName: {
'my-team': {
teamName: 'my-team',
displayName: 'My Team',
description: '',
memberCount: 1,
members: [{ name: 'alice', role: 'developer' }],
taskCount: 0,
lastActivity: null,
},
},
teamDataCacheByName: {
'my-team': cachedSnapshot,
},
});
hoisted.getData.mockResolvedValueOnce(thinSnapshot);
await store.getState().selectTeam('my-team');
expect(store.getState().selectedTeamData).toEqual(cachedSnapshot);
expect(store.getState().teamDataCacheByName['my-team']).toEqual(cachedSnapshot);
expect(selectResolvedMembersForTeamName(store.getState(), 'my-team')).toHaveLength(1);
});
it('does not treat a lead-only selectTeam snapshot as a confirmed teammate roster', async () => {
const store = createSliceStore();
const cachedSnapshot = createTeamSnapshot({
config: { name: 'Cached Team' },
members: [
{ name: 'team-lead', agentType: 'team-lead', currentTaskId: null },
{ name: 'alice', role: 'developer', currentTaskId: null },
],
});
const leadOnlySnapshot = createTeamSnapshot({
config: { name: 'Lead Only Thin Team' },
members: [{ name: 'team-lead', agentType: 'team-lead', currentTaskId: null }],
});
store.setState({
teamByName: {
'my-team': {
teamName: 'my-team',
displayName: 'My Team',
description: '',
memberCount: 1,
members: [{ name: 'alice', role: 'developer' }],
taskCount: 0,
lastActivity: null,
},
},
teamDataCacheByName: {
'my-team': cachedSnapshot,
},
});
hoisted.getData.mockResolvedValueOnce(leadOnlySnapshot);
await store.getState().selectTeam('my-team');
expect(store.getState().selectedTeamData).toEqual(cachedSnapshot);
expect(
selectResolvedMembersForTeamName(store.getState(), 'my-team').map((m) => m.name)
).toEqual(['team-lead', 'alice']);
});
it('uses summary fallback instead of a stale cached roster when names no longer match', async () => {
const store = createSliceStore();
const cachedSnapshot = createTeamSnapshot({
config: { name: 'Cached Team' },
members: [{ name: 'alice', role: 'developer', currentTaskId: null }],
});
const emptySnapshot = createTeamSnapshot({
config: { name: 'Thin Team' },
members: [],
});
store.setState({
teamByName: {
'my-team': {
teamName: 'my-team',
displayName: 'My Team',
description: '',
memberCount: 1,
members: [{ name: 'bob', role: 'reviewer' }],
taskCount: 0,
lastActivity: null,
},
},
teamDataCacheByName: {
'my-team': cachedSnapshot,
},
});
hoisted.getData.mockResolvedValueOnce(emptySnapshot);
await store.getState().selectTeam('my-team');
expect(store.getState().selectedTeamData).toEqual(emptySnapshot);
expect(selectResolvedMembersForTeamName(store.getState(), 'my-team')).toMatchObject([
{ name: 'bob', role: 'reviewer' },
]);
});
it('commits an empty selectTeam snapshot when the team summary is already solo', async () => {
const store = createSliceStore();
const cachedSnapshot = createTeamSnapshot({
config: { name: 'Cached Team' },
members: [{ name: 'alice', role: 'developer', currentTaskId: null }],
});
const soloSnapshot = createTeamSnapshot({
config: { name: 'Solo Team' },
members: [],
});
store.setState({
teamByName: {
'my-team': {
teamName: 'my-team',
displayName: 'Solo Team',
description: '',
memberCount: 0,
taskCount: 0,
lastActivity: null,
},
},
teamDataCacheByName: {
'my-team': cachedSnapshot,
},
});
hoisted.getData.mockResolvedValueOnce(soloSnapshot);
await store.getState().selectTeam('my-team');
expect(store.getState().selectedTeamData).toEqual(soloSnapshot);
expect(store.getState().teamDataCacheByName['my-team']).toEqual(soloSnapshot);
expect(selectResolvedMembersForTeamName(store.getState(), 'my-team')).toHaveLength(0);
});
it('commits an empty cached-team selectTeam snapshot when no summary confirms teammates', async () => {
const store = createSliceStore();
const cachedSnapshot = createTeamSnapshot({
config: { name: 'Cached Team' },
members: [{ name: 'alice', role: 'developer', currentTaskId: null }],
});
const emptySnapshot = createTeamSnapshot({
config: { name: 'Empty Team' },
members: [],
});
store.setState({
teamDataCacheByName: {
'my-team': cachedSnapshot,
},
teamByName: {},
});
hoisted.getData.mockResolvedValueOnce(emptySnapshot);
await store.getState().selectTeam('my-team');
expect(store.getState().selectedTeamData).toEqual(emptySnapshot);
expect(store.getState().teamDataCacheByName['my-team']).toEqual(emptySnapshot);
});
it('does not preserve a cached roster from a summary count without member names', async () => {
const store = createSliceStore();
const cachedSnapshot = createTeamSnapshot({
config: { name: 'Cached Team' },
members: [{ name: 'alice', role: 'developer', currentTaskId: null }],
});
const emptySnapshot = createTeamSnapshot({
config: { name: 'Empty Team' },
members: [],
});
store.setState({
teamDataCacheByName: {
'my-team': cachedSnapshot,
},
teamByName: {
'my-team': {
teamName: 'my-team',
displayName: 'My Team',
description: '',
memberCount: 1,
taskCount: 0,
lastActivity: null,
},
},
});
hoisted.getData.mockResolvedValueOnce(emptySnapshot);
await store.getState().selectTeam('my-team');
expect(store.getState().selectedTeamData).toEqual(emptySnapshot);
expect(selectResolvedMembersForTeamName(store.getState(), 'my-team')).toHaveLength(0);
});
it('preserves a cached roster when full launch failure metadata confirms the member names', async () => {
const store = createSliceStore();
const cachedSnapshot = createTeamSnapshot({
config: { name: 'Cached Team' },
members: [
{ name: 'alice', role: 'developer', currentTaskId: null },
{ name: 'bob', role: 'reviewer', currentTaskId: null },
],
});
const leadOnlySnapshot = createTeamSnapshot({
config: { name: 'Lead Only Team' },
members: [{ name: 'team-lead', agentType: 'team-lead', currentTaskId: null }],
});
store.setState({
teamDataCacheByName: {
'my-team': cachedSnapshot,
},
teamByName: {
'my-team': {
teamName: 'my-team',
displayName: 'My Team',
description: '',
memberCount: 0,
expectedMemberCount: 2,
partialLaunchFailure: true,
missingMembers: ['alice', 'bob'],
taskCount: 0,
lastActivity: null,
},
},
});
hoisted.getData.mockResolvedValueOnce(leadOnlySnapshot);
await store.getState().selectTeam('my-team');
expect(store.getState().selectedTeamData).toEqual(cachedSnapshot);
expect(
selectResolvedMembersForTeamName(store.getState(), 'my-team').map((member) => member.name)
).toEqual(['alice', 'bob']);
});
it('does not preserve a cached roster when launch failure metadata only names part of the team', async () => {
const store = createSliceStore();
const cachedSnapshot = createTeamSnapshot({
config: { name: 'Cached Team' },
members: [
{ name: 'alice', role: 'developer', currentTaskId: null },
{ name: 'bob', role: 'reviewer', currentTaskId: null },
],
});
const leadOnlySnapshot = createTeamSnapshot({
config: { name: 'Lead Only Team' },
members: [{ name: 'team-lead', agentType: 'team-lead', currentTaskId: null }],
});
store.setState({
teamDataCacheByName: {
'my-team': cachedSnapshot,
},
teamByName: {
'my-team': {
teamName: 'my-team',
displayName: 'My Team',
description: '',
memberCount: 0,
expectedMemberCount: 2,
partialLaunchFailure: true,
missingMembers: ['bob'],
taskCount: 0,
lastActivity: null,
},
},
});
hoisted.getData.mockResolvedValueOnce(leadOnlySnapshot);
await store.getState().selectTeam('my-team');
expect(store.getState().selectedTeamData).toEqual(leadOnlySnapshot);
expect(
selectResolvedMembersForTeamName(store.getState(), 'my-team').map((member) => member.name)
).toEqual(['team-lead']);
});
it('commits a late selectTeam snapshot that explicitly marks members as removed', async () => {
const store = createSliceStore();
const selectRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const activeSnapshot = createTeamSnapshot({
members: [{ name: 'alice', role: 'developer', currentTaskId: null }],
});
const removedSnapshot = createTeamSnapshot({
members: [
{ name: 'alice', role: 'developer', currentTaskId: null, removedAt: 1710000000000 },
],
});
hoisted.getData.mockImplementationOnce(() => selectRequest.promise);
const selectPromise = store.getState().selectTeam('my-team');
await flushMicrotasks();
store.setState({
selectedTeamName: 'my-team',
selectedTeamData: activeSnapshot,
teamDataCacheByName: {
'my-team': activeSnapshot,
},
});
selectRequest.resolve(removedSnapshot);
await selectPromise;
expect(store.getState().selectedTeamData).toEqual(removedSnapshot);
expect(store.getState().teamDataCacheByName['my-team']).toEqual(removedSnapshot);
});
it('still commits a late selectTeam snapshot when concurrent local state only changed tasks', async () => {
const store = createSliceStore();
const selectRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const previousSnapshot = createTeamSnapshot({
members: [{ name: 'alice', role: 'developer', currentTaskId: null }],
tasks: [{ id: 'task-1', subject: 'Old task', status: 'pending', owner: 'alice' }],
});
const locallyPatchedSnapshot = createTeamSnapshot({
members: [{ name: 'alice', role: 'developer', currentTaskId: null }],
tasks: [{ id: 'task-1', subject: 'Old task', status: 'pending', owner: 'alice' }],
});
const incomingSnapshot = createTeamSnapshot({
config: { name: 'Server Team' },
members: [
{ name: 'alice', role: 'developer', currentTaskId: null },
{ name: 'bob', role: 'reviewer', currentTaskId: null },
],
tasks: [{ id: 'task-2', subject: 'Server task', status: 'pending', owner: 'bob' }],
});
store.setState({
selectedTeamName: 'my-team',
selectedTeamData: previousSnapshot,
teamDataCacheByName: {
'my-team': previousSnapshot,
},
});
hoisted.getData.mockImplementationOnce(() => selectRequest.promise);
const selectPromise = store.getState().selectTeam('my-team');
await flushMicrotasks();
store.setState({
selectedTeamData: locallyPatchedSnapshot,
teamDataCacheByName: {
'my-team': locallyPatchedSnapshot,
},
});
selectRequest.resolve(incomingSnapshot);
await selectPromise;
expect(store.getState().selectedTeamData).toMatchObject({
config: { name: 'Server Team' },
members: [{ name: 'alice' }, { name: 'bob' }],
});
});
it('does not preserve a stale roster when concurrent local state only changed tasks', async () => {
const store = createSliceStore();
const selectRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const previousSnapshot = createTeamSnapshot({
members: [{ name: 'alice', role: 'developer', currentTaskId: null }],
tasks: [{ id: 'task-1', subject: 'Old task', status: 'pending', owner: 'alice' }],
});
const locallyPatchedSnapshot = createTeamSnapshot({
members: [{ name: 'alice', role: 'developer', currentTaskId: null }],
tasks: [{ id: 'task-1', subject: 'Locally changed task', status: 'pending', owner: 'alice' }],
});
const leadOnlySnapshot = createTeamSnapshot({
config: { name: 'Solo Team' },
members: [{ name: 'team-lead', agentType: 'team-lead', currentTaskId: null }],
tasks: [],
});
store.setState({
selectedTeamName: 'my-team',
selectedTeamData: previousSnapshot,
teamDataCacheByName: {
'my-team': previousSnapshot,
},
teamByName: {
'my-team': {
teamName: 'my-team',
displayName: 'Solo Team',
description: '',
memberCount: 0,
taskCount: 0,
lastActivity: null,
},
},
});
hoisted.getData.mockImplementationOnce(() => selectRequest.promise);
const selectPromise = store.getState().selectTeam('my-team');
await flushMicrotasks();
store.setState({
selectedTeamData: locallyPatchedSnapshot,
teamDataCacheByName: {
'my-team': locallyPatchedSnapshot,
},
});
selectRequest.resolve(leadOnlySnapshot);
await selectPromise;
expect(store.getState().selectedTeamData).toEqual(leadOnlySnapshot);
expect(
selectResolvedMembersForTeamName(store.getState(), 'my-team').map((m) => m.name)
).toEqual(['team-lead']);
});
it('keeps one queued full refresh for repeated fanout while thin selectTeam is pending', async () => {
vi.useFakeTimers();
stubAnimationFrameWithTimer();
const store = createSliceStore();
const thinRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const fullSnapshot = createTeamSnapshot({
config: { name: 'Full Team Once' },
});
hoisted.getData
.mockImplementationOnce(() => thinRequest.promise)
.mockResolvedValueOnce(fullSnapshot);
const selectPromise = store.getState().selectTeam('my-team');
await flushMicrotasks();
await Promise.all([
store.getState().refreshTeamData('my-team', { withDedup: true }),
store.getState().refreshTeamData('my-team', { withDedup: true }),
]);
expect(hoisted.getData).toHaveBeenCalledTimes(1);
expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({
hasQueuedFullTeamDataRefreshAfterThin: true,
});
thinRequest.resolve(createTeamSnapshot({ config: { name: 'Thin Team' } }));
await selectPromise;
await flushPostPaintTeamEnrichments();
await flushMicrotasks();
expect(hoisted.getData).toHaveBeenCalledTimes(2);
expect(store.getState().selectedTeamData).toEqual(fullSnapshot);
expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({
hasQueuedFullTeamDataRefreshAfterThin: false,
});
});
it('drains queued full refresh when thin selectTeam becomes stale after switching teams', async () => {
const store = createSliceStore();
const alphaThin = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const alphaFull = createTeamSnapshot({
teamName: 'alpha-team',
config: { name: 'Alpha Full' },
});
hoisted.getData
.mockImplementationOnce(() => alphaThin.promise)
.mockResolvedValueOnce(
createTeamSnapshot({ teamName: 'beta-team', config: { name: 'Beta' } })
)
.mockResolvedValueOnce(alphaFull);
const alphaSelect = store.getState().selectTeam('alpha-team');
await flushMicrotasks();
await store.getState().refreshTeamData('alpha-team', { withDedup: true });
expect(__getTeamScopedTransientStateForTests('alpha-team')).toMatchObject({
hasQueuedFullTeamDataRefreshAfterThin: true,
});
await store.getState().selectTeam('beta-team');
alphaThin.resolve(
createTeamSnapshot({ teamName: 'alpha-team', config: { name: 'Alpha Thin' } })
);
await alphaSelect;
await flushAsyncWork();
expect(hoisted.getData).toHaveBeenCalledTimes(3);
expect(hoisted.getData.mock.calls[2]).toEqual(['alpha-team']);
expect(store.getState().selectedTeamName).toBe('beta-team');
expect(store.getState().teamDataCacheByName['alpha-team']).toEqual(alphaFull);
expect(__getTeamScopedTransientStateForTests('alpha-team')).toMatchObject({
hasQueuedFullTeamDataRefreshAfterThin: false,
});
});
it('clears queued full refresh when thin selectTeam fails structurally', async () => {
const store = createSliceStore();
const thinRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
hoisted.getData.mockImplementationOnce(() => thinRequest.promise);
const selectPromise = store.getState().selectTeam('my-team');
await flushMicrotasks();
await store.getState().refreshTeamData('my-team', { withDedup: true });
expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({
hasQueuedFullTeamDataRefreshAfterThin: true,
});
thinRequest.reject(new Error('TEAM_DRAFT'));
await selectPromise;
expect(store.getState().selectedTeamError).toBe('TEAM_DRAFT');
expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({
hasQueuedFullTeamDataRefreshAfterThin: false,
});
});
it('lets the newer same-team selectTeam drain queued full refresh after its own paint', async () => {
vi.useFakeTimers();
stubAnimationFrameWithTimer();
const store = createSliceStore();
const thinRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const fullSnapshot = createTeamSnapshot({
config: { name: 'Full After Newer Paint' },
});
hoisted.getData
.mockImplementationOnce(() => thinRequest.promise)
.mockResolvedValueOnce(fullSnapshot);
const firstSelect = store.getState().selectTeam('my-team');
await flushMicrotasks();
await store.getState().refreshTeamData('my-team', { withDedup: true });
const secondSelect = store
.getState()
.selectTeam('my-team', { allowReloadWhileProvisioning: true });
thinRequest.resolve(createTeamSnapshot({ config: { name: 'Thin Team' } }));
await Promise.all([firstSelect, secondSelect]);
expect(hoisted.getData).toHaveBeenCalledTimes(1);
expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({
hasQueuedFullTeamDataRefreshAfterThin: true,
hasPostPaintTeamEnrichmentTimer: true,
});
await flushPostPaintTeamEnrichments();
await flushMicrotasks();
expect(hoisted.getData).toHaveBeenCalledTimes(2);
expect(hoisted.getData.mock.calls[1]).toEqual(['my-team']);
expect(store.getState().selectedTeamData).toEqual(fullSnapshot);
expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({
hasQueuedFullTeamDataRefreshAfterThin: false,
});
});
it('does not run stale post-paint messages for a team after switching away', async () => {
vi.useFakeTimers();
stubAnimationFrameWithTimer();
const store = createSliceStore();
hoisted.getData
.mockResolvedValueOnce(
createTeamSnapshot({ teamName: 'alpha-team', config: { name: 'Alpha' } })
)
.mockResolvedValueOnce(
createTeamSnapshot({ teamName: 'beta-team', config: { name: 'Beta' } })
);
await store.getState().selectTeam('alpha-team');
expect(__getTeamScopedTransientStateForTests('alpha-team')).toMatchObject({
hasPostPaintTeamEnrichmentTimer: true,
});
await store.getState().selectTeam('beta-team');
await flushPostPaintTeamEnrichments();
await flushMicrotasks();
expect(hoisted.getMessagesPage).toHaveBeenCalledTimes(1);
expect(hoisted.getMessagesPage).toHaveBeenCalledWith('beta-team', { limit: 50 });
expect(hoisted.getMessagesPage).not.toHaveBeenCalledWith('alpha-team', { limit: 50 });
});
it('clears queued full refresh and post-paint timer when deleting a loaded team', async () => {
vi.useFakeTimers();
stubAnimationFrameWithTimer();
const store = createSliceStore();
const thinRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
hoisted.getData.mockImplementationOnce(() => thinRequest.promise);
const selectPromise = store.getState().selectTeam('my-team');
await flushMicrotasks();
await store.getState().refreshTeamData('my-team', { withDedup: true });
thinRequest.resolve(createTeamSnapshot({ config: { name: 'Thin Team' } }));
await selectPromise;
expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({
hasQueuedFullTeamDataRefreshAfterThin: true,
hasPostPaintTeamEnrichmentTimer: true,
});
await store.getState().deleteTeam('my-team');
expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({
hasQueuedFullTeamDataRefreshAfterThin: false,
hasPostPaintTeamEnrichmentTimer: false,
});
await flushPostPaintTeamEnrichments();
expect(hoisted.getMessagesPage).not.toHaveBeenCalled();
expect(hoisted.getData).toHaveBeenCalledTimes(1);
});
it('keeps selected team data visible when post-structural sync work throws', async () => {
const store = createSliceStore();
const thinSnapshot = createTeamSnapshot({
config: { name: 'Renamed Team' },
});
const updateTabLabel = vi.fn(() => {
throw new Error('tab label failed');
});
store.setState({
getAllPaneTabs: vi.fn(() => [
{ id: 'tab-1', type: 'team', teamName: 'my-team', label: 'Old Team' },
]),
updateTabLabel,
});
hoisted.getData.mockResolvedValueOnce(thinSnapshot);
await store.getState().selectTeam('my-team');
expect(updateTabLabel).toHaveBeenCalledWith('tab-1', 'Renamed Team');
expect(store.getState().selectedTeamData).toEqual(thinSnapshot);
expect(store.getState().selectedTeamError).toBeNull();
});
it('distinguishes historical feed changes from visible head changes in refreshTeamMessagesHead', async () => {
const store = createSliceStore();
const existingMessages = [
{
from: 'team-lead',
text: 'Stable head',
timestamp: '2026-03-20T08:00:00.000Z',
read: true,
source: 'lead_session',
messageId: 'msg-1',
},
];
store.setState({
teamMessagesByName: {
'my-team': {
canonicalMessages: existingMessages,
optimisticMessages: [],
feedRevision: 'rev-1',
nextCursor: 'cursor-1',
hasMore: true,
lastFetchedAt: 123,
loadingHead: false,
loadingOlder: false,
headHydrated: true,
},
},
});
hoisted.getMessagesPage.mockResolvedValueOnce({
messages: existingMessages.map((message) => ({ ...message })),
nextCursor: 'cursor-1',
hasMore: true,
feedRevision: 'rev-2',
});
const result = await store.getState().refreshTeamMessagesHead('my-team');
const nextEntry = store.getState().teamMessagesByName['my-team'];
expect(result).toEqual({
feedChanged: true,
headChanged: false,
feedRevision: 'rev-2',
});
expect(nextEntry?.canonicalMessages).toBe(existingMessages);
expect(nextEntry?.feedRevision).toBe('rev-2');
expect(nextEntry?.nextCursor).toBe('cursor-1');
expect(nextEntry?.hasMore).toBe(true);
});
it('keeps loaded older tail when head refresh updates only the visible top slice', async () => {
const store = createSliceStore();
const existingMessages = [
{
from: 'team-lead',
text: 'Head 2',
timestamp: '2026-03-20T08:00:03.000Z',
read: true,
source: 'lead_session',
messageId: 'msg-4',
},
{
from: 'alice',
text: 'Head 1',
timestamp: '2026-03-20T08:00:02.000Z',
read: true,
source: 'inbox',
messageId: 'msg-3',
},
{
from: 'bob',
text: 'Older 1',
timestamp: '2026-03-20T08:00:01.000Z',
read: true,
source: 'inbox',
messageId: 'msg-2',
},
{
from: 'carol',
text: 'Older 2',
timestamp: '2026-03-20T08:00:00.000Z',
read: true,
source: 'inbox',
messageId: 'msg-1',
},
];
store.setState({
teamMessagesByName: {
'my-team': {
canonicalMessages: existingMessages,
optimisticMessages: [],
feedRevision: 'rev-1',
nextCursor: 'cursor-tail',
hasMore: true,
lastFetchedAt: 123,
loadingHead: false,
loadingOlder: false,
headHydrated: true,
},
},
});
hoisted.getMessagesPage.mockResolvedValueOnce({
messages: [
{
from: 'team-lead',
text: 'Fresh head',
timestamp: '2026-03-20T08:00:04.000Z',
read: true,
source: 'lead_session',
messageId: 'msg-5',
},
existingMessages[0],
existingMessages[1],
],
nextCursor: 'cursor-head',
hasMore: true,
feedRevision: 'rev-2',
});
const result = await store.getState().refreshTeamMessagesHead('my-team');
const nextEntry = store.getState().teamMessagesByName['my-team'];
expect(result).toEqual({
feedChanged: true,
headChanged: true,
feedRevision: 'rev-2',
});
expect(
nextEntry?.canonicalMessages.map((message: { messageId?: string }) => message.messageId)
).toEqual(['msg-5', 'msg-4', 'msg-3', 'msg-2', 'msg-1']);
expect(nextEntry?.nextCursor).toBe('cursor-tail');
expect(nextEntry?.hasMore).toBe(true);
});
it('single-flights concurrent head refreshes and runs one fresh follow-up pass', async () => {
const store = createSliceStore();
const firstRequest = createDeferredPromise<{
messages: Array<{
from: string;
text: string;
timestamp: string;
read: boolean;
source: string;
messageId: string;
}>;
nextCursor: string | null;
hasMore: boolean;
feedRevision: string;
}>();
store.setState({
teamMessagesByName: {
'my-team': {
canonicalMessages: [],
optimisticMessages: [],
feedRevision: null,
nextCursor: null,
hasMore: false,
lastFetchedAt: 0,
loadingHead: false,
loadingOlder: false,
headHydrated: false,
},
},
});
hoisted.getMessagesPage
.mockImplementationOnce(() => firstRequest.promise)
.mockResolvedValueOnce({
messages: [
{
from: 'team-lead',
text: 'Newest head',
timestamp: '2026-03-20T08:00:01.000Z',
read: true,
source: 'lead_session',
messageId: 'msg-2',
},
],
nextCursor: 'cursor-2',
hasMore: true,
feedRevision: 'rev-2',
});
const p1 = store.getState().refreshTeamMessagesHead('my-team');
const p2 = store.getState().refreshTeamMessagesHead('my-team');
expect(hoisted.getMessagesPage).toHaveBeenCalledTimes(1);
firstRequest.resolve({
messages: [
{
from: 'team-lead',
text: 'Old head',
timestamp: '2026-03-20T08:00:00.000Z',
read: true,
source: 'lead_session',
messageId: 'msg-1',
},
],
nextCursor: 'cursor-1',
hasMore: true,
feedRevision: 'rev-1',
});
await p1;
await p2;
await Promise.resolve();
await Promise.resolve();
expect(hoisted.getMessagesPage).toHaveBeenCalledTimes(2);
expect(store.getState().teamMessagesByName['my-team']).toMatchObject({
feedRevision: 'rev-2',
nextCursor: 'cursor-2',
hasMore: true,
loadingHead: false,
headHydrated: true,
});
expect(store.getState().teamMessagesByName['my-team']?.canonicalMessages[0]?.messageId).toBe(
'msg-2'
);
});
it('serializes head refresh behind an in-flight older-page load', async () => {
const store = createSliceStore();
const olderRequest = createDeferredPromise<{
messages: Array<{
from: string;
text: string;
timestamp: string;
read: boolean;
source: string;
messageId: string;
}>;
nextCursor: string | null;
hasMore: boolean;
feedRevision: string;
}>();
store.setState({
teamMessagesByName: {
'my-team': {
canonicalMessages: [
{
from: 'team-lead',
text: 'Head 1',
timestamp: '2026-03-20T08:00:02.000Z',
read: true,
source: 'lead_session',
messageId: 'msg-3',
},
{
from: 'alice',
text: 'Head 0',
timestamp: '2026-03-20T08:00:01.000Z',
read: true,
source: 'inbox',
messageId: 'msg-2',
},
],
optimisticMessages: [],
feedRevision: 'rev-1',
nextCursor: 'cursor-older',
hasMore: true,
lastFetchedAt: 123,
loadingHead: false,
loadingOlder: false,
headHydrated: true,
},
},
});
hoisted.getMessagesPage
.mockImplementationOnce(() => olderRequest.promise)
.mockResolvedValueOnce({
messages: [
{
from: 'team-lead',
text: 'Fresh head',
timestamp: '2026-03-20T08:00:03.000Z',
read: true,
source: 'lead_session',
messageId: 'msg-4',
},
{
from: 'team-lead',
text: 'Head 1',
timestamp: '2026-03-20T08:00:02.000Z',
read: true,
source: 'lead_session',
messageId: 'msg-3',
},
],
nextCursor: 'cursor-head',
hasMore: true,
feedRevision: 'rev-2',
});
const olderPromise = store.getState().loadOlderTeamMessages('my-team');
const headPromise = store.getState().refreshTeamMessagesHead('my-team');
expect(hoisted.getMessagesPage).toHaveBeenCalledTimes(1);
expect(hoisted.getMessagesPage.mock.calls[0]).toEqual([
'my-team',
{ cursor: 'cursor-older', limit: 50 },
]);
olderRequest.resolve({
messages: [
{
from: 'bob',
text: 'Older tail',
timestamp: '2026-03-20T08:00:00.000Z',
read: true,
source: 'inbox',
messageId: 'msg-1',
},
],
nextCursor: null,
hasMore: false,
feedRevision: 'rev-1',
});
await olderPromise;
await headPromise;
expect(hoisted.getMessagesPage).toHaveBeenCalledTimes(2);
expect(hoisted.getMessagesPage.mock.calls[1]).toEqual(['my-team', { limit: 50 }]);
expect(
store
.getState()
.teamMessagesByName[
'my-team'
]?.canonicalMessages.map((message: { messageId?: string }) => message.messageId)
).toEqual(['msg-4', 'msg-3', 'msg-2', 'msg-1']);
});
it('drops a queued head refresh behind an older-page load when launch invalidates the team epoch', async () => {
const store = createSliceStore();
const olderRequest = createDeferredPromise<{
messages: Array<{
from: string;
text: string;
timestamp: string;
read: boolean;
source: string;
messageId: string;
}>;
nextCursor: string | null;
hasMore: boolean;
feedRevision: string;
}>();
store.setState({
teamMessagesByName: {
'my-team': {
canonicalMessages: [
{
from: 'team-lead',
text: 'Head 1',
timestamp: '2026-03-20T08:00:02.000Z',
read: true,
source: 'lead_session',
messageId: 'msg-3',
},
{
from: 'alice',
text: 'Head 0',
timestamp: '2026-03-20T08:00:01.000Z',
read: true,
source: 'inbox',
messageId: 'msg-2',
},
],
optimisticMessages: [],
feedRevision: 'rev-1',
nextCursor: 'cursor-older',
hasMore: true,
lastFetchedAt: 123,
loadingHead: false,
loadingOlder: false,
headHydrated: true,
},
},
});
hoisted.getMessagesPage.mockImplementationOnce(() => olderRequest.promise);
const olderPromise = store.getState().loadOlderTeamMessages('my-team');
const queuedHeadPromise = store.getState().refreshTeamMessagesHead('my-team');
await Promise.resolve();
await store.getState().launchTeam({
teamName: 'my-team',
cwd: '/tmp/project',
});
olderRequest.resolve({
messages: [
{
from: 'bob',
text: 'Older tail',
timestamp: '2026-03-20T08:00:00.000Z',
read: true,
source: 'inbox',
messageId: 'msg-1',
},
],
nextCursor: null,
hasMore: false,
feedRevision: 'rev-1',
});
await olderPromise;
await expect(queuedHeadPromise).resolves.toEqual({
feedChanged: false,
headChanged: false,
feedRevision: null,
});
expect(hoisted.getMessagesPage).toHaveBeenCalledTimes(1);
expect(store.getState().teamMessagesByName['my-team']?.feedRevision).toBe('rev-1');
});
it('does not continue an older-page fetch with a stale cursor after launch invalidates while waiting for head refresh', async () => {
const store = createSliceStore();
const headRequest = createDeferredPromise<{
messages: Array<{
from: string;
text: string;
timestamp: string;
read: boolean;
source: string;
messageId: string;
}>;
nextCursor: string | null;
hasMore: boolean;
feedRevision: string;
}>();
store.setState({
teamMessagesByName: {
'my-team': {
canonicalMessages: [
{
from: 'team-lead',
text: 'Head 1',
timestamp: '2026-03-20T08:00:02.000Z',
read: true,
source: 'lead_session',
messageId: 'msg-3',
},
],
optimisticMessages: [],
feedRevision: 'rev-1',
nextCursor: 'cursor-older',
hasMore: true,
lastFetchedAt: 123,
loadingHead: false,
loadingOlder: false,
headHydrated: true,
},
},
});
hoisted.getMessagesPage.mockImplementationOnce(() => headRequest.promise);
const headPromise = store.getState().refreshTeamMessagesHead('my-team');
const olderPromise = store.getState().loadOlderTeamMessages('my-team');
await Promise.resolve();
await store.getState().launchTeam({
teamName: 'my-team',
cwd: '/tmp/project',
});
headRequest.resolve({
messages: [
{
from: 'team-lead',
text: 'Fresh head',
timestamp: '2026-03-20T08:00:03.000Z',
read: true,
source: 'lead_session',
messageId: 'msg-4',
},
],
nextCursor: 'cursor-head',
hasMore: true,
feedRevision: 'rev-2',
});
await headPromise;
await olderPromise;
expect(hoisted.getMessagesPage).toHaveBeenCalledTimes(1);
expect(store.getState().teamMessagesByName['my-team']?.feedRevision).toBe('rev-1');
expect(store.getState().teamMessagesByName['my-team']?.loadingOlder).toBe(false);
});
it('schedules pending-reply refresh through store-owned timers', async () => {
vi.useFakeTimers();
try {
const store = createSliceStore();
const refreshTeamMessagesHeadSpy = vi
.spyOn(store.getState(), 'refreshTeamMessagesHead')
.mockResolvedValue({
feedChanged: true,
headChanged: true,
feedRevision: 'rev-2',
});
const refreshMemberActivityMetaSpy = vi
.spyOn(store.getState(), 'refreshMemberActivityMeta')
.mockResolvedValue(undefined);
store.getState().syncTeamPendingReplyRefresh('my-team', 'tab-a', true, 1_000);
await vi.advanceTimersByTimeAsync(999);
expect(refreshTeamMessagesHeadSpy).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledTimes(1);
expect(refreshMemberActivityMetaSpy).toHaveBeenCalledTimes(1);
store.getState().syncTeamPendingReplyRefresh('my-team', 'tab-a', true, 1_000);
store.getState().syncTeamPendingReplyRefresh('my-team', 'tab-a', false);
await vi.advanceTimersByTimeAsync(1_000);
expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
it('keeps pending-reply refresh ownership active while another source still waits for the same team', () => {
const store = createSliceStore();
store.getState().syncTeamPendingReplyRefresh('my-team', 'tab-a', true, 1_000);
store.getState().syncTeamPendingReplyRefresh('my-team', 'tab-b', true, 1_000);
store.getState().syncTeamPendingReplyRefresh('my-team', 'tab-b', false);
expect(hasActiveTeamPendingReplyWait('my-team')).toBe(true);
expect(getActiveTeamPendingReplyWaits()).toEqual(new Set(['my-team']));
store.getState().syncTeamPendingReplyRefresh('my-team', 'tab-a', false);
expect(hasActiveTeamPendingReplyWait('my-team')).toBe(false);
expect(getActiveTeamPendingReplyWaits().size).toBe(0);
});
it('single-flights concurrent member activity refreshes and re-fetches after feed revision changes', async () => {
const store = createSliceStore();
const firstRequest = createDeferredPromise<{
teamName: string;
computedAt: string;
members: Record<string, unknown>;
feedRevision: string;
}>();
store.setState({
teamMessagesByName: {
'my-team': {
canonicalMessages: [],
optimisticMessages: [],
feedRevision: 'rev-1',
nextCursor: null,
hasMore: false,
lastFetchedAt: 0,
loadingHead: false,
loadingOlder: false,
headHydrated: true,
},
},
memberActivityMetaByTeam: {},
});
hoisted.getMemberActivityMeta
.mockImplementationOnce(() => firstRequest.promise)
.mockResolvedValueOnce({
teamName: 'my-team',
computedAt: '2026-03-12T10:00:01.000Z',
members: {
alice: {
memberName: 'alice',
lastAuthoredMessageAt: '2026-03-12T10:00:01.000Z',
messageCountExact: 3,
latestAuthoredMessageSignalsTermination: false,
},
},
feedRevision: 'rev-2',
});
const p1 = store.getState().refreshMemberActivityMeta('my-team');
store.setState((state: any) => ({
teamMessagesByName: {
...state.teamMessagesByName,
'my-team': {
...state.teamMessagesByName['my-team'],
feedRevision: 'rev-2',
},
},
}));
const p2 = store.getState().refreshMemberActivityMeta('my-team');
expect(hoisted.getMemberActivityMeta).toHaveBeenCalledTimes(1);
firstRequest.resolve({
teamName: 'my-team',
computedAt: '2026-03-12T10:00:00.000Z',
members: {
alice: {
memberName: 'alice',
lastAuthoredMessageAt: '2026-03-12T10:00:00.000Z',
messageCountExact: 2,
latestAuthoredMessageSignalsTermination: false,
},
},
feedRevision: 'rev-1',
});
await p1;
await p2;
await Promise.resolve();
await Promise.resolve();
expect(hoisted.getMemberActivityMeta).toHaveBeenCalledTimes(2);
expect(store.getState().memberActivityMetaByTeam['my-team']).toMatchObject({
feedRevision: 'rev-2',
members: {
alice: {
messageCountExact: 3,
},
},
});
});
it('reuses member activity facts and resolved member refs when only meta wrapper fields change', async () => {
const store = createSliceStore();
const initialMetaMembers = {
alice: {
memberName: 'alice',
lastAuthoredMessageAt: '2026-03-12T10:00:00.000Z',
messageCountExact: 2,
latestAuthoredMessageSignalsTermination: false,
},
};
store.setState({
selectedTeamName: 'my-team',
selectedTeamData: createTeamSnapshot({
members: [
{
name: 'alice',
currentTaskId: null,
taskCount: 0,
},
],
}),
teamDataCacheByName: {
'my-team': createTeamSnapshot({
members: [
{
name: 'alice',
currentTaskId: null,
taskCount: 0,
},
],
}),
},
teamMessagesByName: {
'my-team': {
canonicalMessages: [],
optimisticMessages: [],
feedRevision: 'rev-2',
nextCursor: null,
hasMore: false,
lastFetchedAt: 0,
loadingHead: false,
loadingOlder: false,
headHydrated: true,
},
},
memberActivityMetaByTeam: {
'my-team': {
teamName: 'my-team',
computedAt: '2026-03-12T10:00:00.000Z',
members: initialMetaMembers,
feedRevision: 'rev-1',
},
},
leadActivityByTeam: {
'my-team': 'active',
},
leadContextByTeam: {
'my-team': {
currentTokens: 12,
contextWindow: 100,
percent: 12,
updatedAt: '2026-03-12T10:00:00.000Z',
},
},
memberSpawnStatusesByTeam: {
'my-team': {
alice: createMemberSpawnStatus(),
},
},
memberSpawnSnapshotsByTeam: {
'my-team': createMemberSpawnSnapshot(),
},
});
const initialResolvedMembers = selectResolvedMembersForTeamName(store.getState(), 'my-team');
hoisted.getMemberActivityMeta.mockResolvedValueOnce({
teamName: 'my-team',
computedAt: '2026-03-12T10:00:05.000Z',
members: {
alice: {
memberName: 'alice',
lastAuthoredMessageAt: '2026-03-12T10:00:00.000Z',
messageCountExact: 2,
latestAuthoredMessageSignalsTermination: false,
},
},
feedRevision: 'rev-2',
});
await store.getState().refreshMemberActivityMeta('my-team');
const nextMeta = store.getState().memberActivityMetaByTeam['my-team'];
const nextResolvedMembers = selectResolvedMembersForTeamName(store.getState(), 'my-team');
expect(nextMeta?.feedRevision).toBe('rev-2');
expect(nextMeta?.members).toBe(initialMetaMembers);
expect(nextResolvedMembers).toBe(initialResolvedMembers);
});
it('prefers selected team data over stale cached data for the active team', () => {
const store = createSliceStore();
const staleCachedData = createTeamSnapshot({
members: [],
});
const freshSelectedData = createTeamSnapshot({
members: [
{
name: 'alice',
currentTaskId: null,
taskCount: 0,
color: 'blue',
},
],
});
store.setState({
selectedTeamName: 'my-team',
selectedTeamData: freshSelectedData,
teamDataCacheByName: {
'my-team': staleCachedData,
},
memberActivityMetaByTeam: {},
});
expect(selectTeamDataForName(store.getState(), 'my-team')).toBe(freshSelectedData);
expect(selectResolvedMembersForTeamName(store.getState(), 'my-team')).toHaveLength(1);
});
it('falls back to config roster when snapshot members are temporarily empty', () => {
const store = createSliceStore();
const partialSnapshot = createTeamSnapshot({
config: {
name: 'My Team',
members: [
{ name: 'team-lead', agentType: 'team-lead', providerId: 'codex' },
{ name: 'alice', role: 'reviewer', providerId: 'anthropic', color: 'blue' },
{ name: 'bob', role: 'developer', providerId: 'opencode' },
],
},
members: [],
tasks: [
{
id: 'task-1',
subject: 'Review current diff',
status: 'in_progress',
owner: 'alice',
},
],
});
store.setState({
selectedTeamName: 'my-team',
selectedTeamData: partialSnapshot,
teamDataCacheByName: {
'my-team': partialSnapshot,
},
memberActivityMetaByTeam: {},
});
const members = selectResolvedMembersForTeamName(store.getState(), 'my-team');
expect(members.map((member) => member.name)).toEqual(['team-lead', 'alice', 'bob']);
expect(members.find((member) => member.name === 'alice')).toMatchObject({
role: 'reviewer',
currentTaskId: 'task-1',
taskCount: 1,
});
expect(selectResolvedMemberForTeamName(store.getState(), 'my-team', 'bob')).toMatchObject({
name: 'bob',
role: 'developer',
});
});
it('falls back to team summary roster when detail snapshot temporarily has no members', () => {
const store = createSliceStore();
const partialSnapshot = createTeamSnapshot({
config: {
name: 'My Team',
projectPath: '/repo',
},
members: [],
tasks: [
{
id: 'task-1',
subject: 'Build',
status: 'in_progress',
owner: 'alice',
},
],
});
store.setState({
selectedTeamName: 'my-team',
selectedTeamData: partialSnapshot,
teamDataCacheByName: {
'my-team': partialSnapshot,
},
teamByName: {
'my-team': {
teamName: 'my-team',
displayName: 'My Team',
description: '',
memberCount: 2,
taskCount: 1,
lastActivity: null,
leadName: 'team-lead',
leadColor: 'purple',
members: [
{ name: 'alice', role: 'developer', color: 'blue' },
{ name: 'bob', role: 'reviewer', color: 'green' },
],
},
},
memberActivityMetaByTeam: {},
});
const members = selectResolvedMembersForTeamName(store.getState(), 'my-team');
expect(members.map((member) => member.name)).toEqual(['team-lead', 'alice', 'bob']);
expect(members.find((member) => member.name === 'alice')).toMatchObject({
role: 'developer',
currentTaskId: 'task-1',
taskCount: 1,
});
expect(selectResolvedMemberForTeamName(store.getState(), 'my-team', 'bob')).toMatchObject({
name: 'bob',
role: 'reviewer',
});
});
it('falls back to team summary roster when detail snapshot only has the synthetic lead', () => {
const store = createSliceStore();
const leadOnlySnapshot = createTeamSnapshot({
config: {
name: 'My Team',
projectPath: '/repo',
},
members: [
{
name: 'team-lead',
agentType: 'team-lead',
currentTaskId: null,
role: 'Lead from detail',
color: 'purple',
},
],
tasks: [],
});
store.setState({
selectedTeamName: 'my-team',
selectedTeamData: leadOnlySnapshot,
teamDataCacheByName: {
'my-team': leadOnlySnapshot,
},
teamByName: {
'my-team': {
teamName: 'my-team',
displayName: 'My Team',
description: '',
memberCount: 1,
taskCount: 0,
lastActivity: null,
members: [{ name: 'alice', role: 'developer', color: 'blue' }],
},
},
memberActivityMetaByTeam: {},
});
const members = selectResolvedMembersForTeamName(store.getState(), 'my-team');
expect(members.map((m) => m.name)).toEqual(['team-lead', 'alice']);
expect(members[0]).toMatchObject({
name: 'team-lead',
role: 'Lead from detail',
color: 'purple',
});
});
it('does not synthesize member cards from launch failure names when summary roster is missing', () => {
const store = createSliceStore();
const leadOnlySnapshot = createTeamSnapshot({
config: {
name: 'My Team',
projectPath: '/repo',
},
members: [
{
name: 'team-lead',
agentType: 'team-lead',
currentTaskId: null,
role: 'Lead from detail',
color: 'purple',
},
],
tasks: [
{
id: 'task-1',
subject: 'Build',
status: 'in_progress',
owner: 'Alice',
},
],
});
store.setState({
selectedTeamName: 'my-team',
selectedTeamData: leadOnlySnapshot,
teamDataCacheByName: {
'my-team': leadOnlySnapshot,
},
teamByName: {
'my-team': {
teamName: 'my-team',
displayName: 'My Team',
description: '',
memberCount: 0,
expectedMemberCount: 2,
leadName: 'Lead',
partialLaunchFailure: true,
missingMembers: ['Lead', 'Alice', 'bob'],
taskCount: 1,
lastActivity: null,
},
},
memberActivityMetaByTeam: {},
});
const members = selectResolvedMembersForTeamName(store.getState(), 'my-team');
expect(members.map((m) => m.name)).toEqual(['team-lead']);
expect(selectResolvedMemberForTeamName(store.getState(), 'my-team', 'Alice')).toBeNull();
});
it('memoizes team-scoped member messages selectors over the merged message feed', () => {
const store = createSliceStore();
store.setState({
teamMessagesByName: {
'my-team': {
canonicalMessages: [
{
from: 'team-lead',
to: 'alice',
text: 'Ping Alice',
summary: 'Ping Alice',
timestamp: '2026-03-12T10:00:00.000Z',
read: false,
messageId: 'msg-1',
},
{
from: 'team-lead',
to: 'bob',
text: 'Ping Bob',
summary: 'Ping Bob',
timestamp: '2026-03-12T10:00:01.000Z',
read: false,
messageId: 'msg-2',
},
],
optimisticMessages: [],
feedRevision: 'rev-1',
nextCursor: null,
hasMore: false,
lastFetchedAt: 0,
loadingHead: false,
loadingOlder: false,
headHydrated: true,
},
},
});
const first = selectMemberMessagesForTeamMember(store.getState(), 'my-team', 'alice');
const second = selectMemberMessagesForTeamMember(store.getState(), 'my-team', 'alice');
expect(first).toBe(second);
expect(first.map((message) => message.messageId)).toEqual(['msg-1']);
store.setState({
teamMessagesByName: {
'my-team': {
canonicalMessages: [
{
from: 'team-lead',
to: 'alice',
text: 'Ping Alice',
summary: 'Ping Alice',
timestamp: '2026-03-12T10:00:00.000Z',
read: false,
messageId: 'msg-1',
},
{
from: 'alice',
to: 'team-lead',
text: 'Reply from Alice',
summary: 'Reply from Alice',
timestamp: '2026-03-12T10:00:02.000Z',
read: false,
messageId: 'msg-3',
},
],
optimisticMessages: [],
feedRevision: 'rev-2',
nextCursor: null,
hasMore: false,
lastFetchedAt: 1,
loadingHead: false,
loadingOlder: false,
headHydrated: true,
},
},
});
const third = selectMemberMessagesForTeamMember(store.getState(), 'my-team', 'alice');
expect(third).not.toBe(first);
expect(third.map((message) => message.messageId)).toEqual(['msg-3', 'msg-1']);
});
it('removes non-selected team cache entries on permanent delete', async () => {
const store = createSliceStore();
store.setState({
selectedTeamName: 'other-team',
selectedTeamData: {
teamName: 'other-team',
config: { name: 'Other Team' },
tasks: [],
members: [],
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
processes: [],
},
teamDataCacheByName: {
'my-team': {
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
},
'other-team': {
teamName: 'other-team',
config: { name: 'Other Team' },
tasks: [],
members: [],
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
processes: [],
},
},
});
await store.getState().permanentlyDeleteTeam('my-team');
expect(hoisted.permanentlyDeleteTeam).toHaveBeenCalledWith('my-team');
expect(store.getState().teamDataCacheByName['my-team']).toBeUndefined();
expect(store.getState().teamDataCacheByName['other-team']).toBeDefined();
});
it('clears selected team state and cache on soft delete', async () => {
const store = createSliceStore();
store.setState({
selectedTeamName: 'my-team',
selectedTeamData: {
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
},
teamDataCacheByName: {
'my-team': {
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
},
},
});
await store.getState().deleteTeam('my-team');
expect(hoisted.deleteTeam).toHaveBeenCalledWith('my-team');
expect(store.getState().selectedTeamName).toBeNull();
expect(store.getState().selectedTeamData).toBeNull();
expect(store.getState().teamDataCacheByName['my-team']).toBeUndefined();
});
it('drops stale cache on restore so the next open refetches fresh data', async () => {
const store = createSliceStore();
store.setState({
teamDataCacheByName: {
'my-team': {
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
},
},
});
await store.getState().restoreTeam('my-team');
expect(hoisted.restoreTeam).toHaveBeenCalledWith('my-team');
expect(store.getState().teamDataCacheByName['my-team']).toBeUndefined();
});
it('clears team-scoped selector and transient caches on delete and restore flows', async () => {
const store = createSliceStore();
const message = {
from: 'alice',
to: 'team-lead',
text: 'hello',
timestamp: '2026-03-12T10:00:00.000Z',
messageId: 'm-1',
source: 'inbox' as const,
};
store.setState({
selectedTeamName: 'my-team',
selectedTeamData: createTeamSnapshot({
members: [
{
name: 'alice',
role: 'developer',
currentTaskId: null,
},
],
}),
teamDataCacheByName: {
'my-team': createTeamSnapshot({
members: [
{
name: 'alice',
role: 'developer',
currentTaskId: null,
},
],
}),
},
teamMessagesByName: {
'my-team': {
canonicalMessages: [message],
optimisticMessages: [],
nextCursor: null,
hasMore: false,
feedRevision: 'rev-1',
lastFetchedAt: Date.now(),
loadingHead: false,
loadingOlder: false,
headHydrated: true,
},
},
memberActivityMetaByTeam: {
'my-team': {
teamName: 'my-team',
computedAt: '2026-03-12T10:00:00.000Z',
feedRevision: 'rev-1',
members: {
alice: {
memberName: 'alice',
lastAuthoredMessageAt: '2026-03-12T10:00:00.000Z',
messageCountExact: 1,
latestAuthoredMessageSignalsTermination: false,
},
},
},
},
});
selectResolvedMembersForTeamName(store.getState(), 'my-team');
selectResolvedMemberForTeamName(store.getState(), 'my-team', 'alice');
selectMemberMessagesForTeamMember(store.getState(), 'my-team', 'alice');
await store.getState().refreshTeamData('my-team', { withDedup: false });
store.getState().syncTeamPendingReplyRefresh('my-team', 'test-source', true);
expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({
hasResolvedMembersSelector: true,
resolvedMemberSelectorCount: 1,
hasMergedMessagesSelector: true,
memberMessagesSelectorCount: 1,
hasLastResolvedTeamDataRefresh: true,
});
await store.getState().deleteTeam('my-team');
expect(__getTeamScopedTransientStateForTests('my-team')).toEqual({
hasResolvedMembersSelector: false,
resolvedMemberSelectorCount: 0,
hasMergedMessagesSelector: false,
memberMessagesSelectorCount: 0,
hasPendingFreshTeamDataRefresh: false,
hasQueuedFullTeamDataRefreshAfterThin: false,
hasPostPaintTeamEnrichmentTimer: false,
hasQueuedHeadRefreshAfterOlder: false,
hasPendingFreshMessagesHeadRefresh: false,
hasPendingFreshMemberActivityMetaRefresh: false,
hasLastResolvedTeamDataRefresh: false,
hasCurrentLocalStateEpoch: true,
hasMemberSpawnStatusesIpcBackoff: false,
hasTeamRefreshBurstDiagnostics: false,
hasMemberSpawnUiEqualLastWarn: false,
});
expect(store.getState().leadActivityByTeam['my-team']).toBeUndefined();
expect(store.getState().leadContextByTeam['my-team']).toBeUndefined();
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBeUndefined();
expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toBeUndefined();
store.setState({
teamDataCacheByName: {
'my-team': createTeamSnapshot({
members: [
{
name: 'alice',
role: 'developer',
currentTaskId: null,
},
],
}),
},
teamMessagesByName: {
'my-team': {
canonicalMessages: [message],
optimisticMessages: [],
nextCursor: null,
hasMore: false,
feedRevision: 'rev-1',
lastFetchedAt: Date.now(),
loadingHead: false,
loadingOlder: false,
headHydrated: true,
},
},
memberActivityMetaByTeam: {
'my-team': {
teamName: 'my-team',
computedAt: '2026-03-12T10:00:00.000Z',
feedRevision: 'rev-1',
members: {
alice: {
memberName: 'alice',
lastAuthoredMessageAt: '2026-03-12T10:00:00.000Z',
messageCountExact: 1,
latestAuthoredMessageSignalsTermination: false,
},
},
},
},
leadActivityByTeam: {
'my-team': 'active',
},
leadContextByTeam: {
'my-team': {
currentTokens: 12,
contextWindow: 100,
percent: 12,
updatedAt: '2026-03-12T10:00:00.000Z',
},
},
memberSpawnStatusesByTeam: {
'my-team': {
alice: createMemberSpawnStatus(),
},
},
memberSpawnSnapshotsByTeam: {
'my-team': createMemberSpawnSnapshot(),
},
});
selectResolvedMembersForTeamName(store.getState(), 'my-team');
selectResolvedMemberForTeamName(store.getState(), 'my-team', 'alice');
selectMemberMessagesForTeamMember(store.getState(), 'my-team', 'alice');
expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({
hasResolvedMembersSelector: true,
resolvedMemberSelectorCount: 1,
hasMergedMessagesSelector: true,
memberMessagesSelectorCount: 1,
});
await store.getState().restoreTeam('my-team');
expect(__getTeamScopedTransientStateForTests('my-team')).toEqual({
hasResolvedMembersSelector: false,
resolvedMemberSelectorCount: 0,
hasMergedMessagesSelector: false,
memberMessagesSelectorCount: 0,
hasPendingFreshTeamDataRefresh: false,
hasQueuedFullTeamDataRefreshAfterThin: false,
hasPostPaintTeamEnrichmentTimer: false,
hasQueuedHeadRefreshAfterOlder: false,
hasPendingFreshMessagesHeadRefresh: false,
hasPendingFreshMemberActivityMetaRefresh: false,
hasLastResolvedTeamDataRefresh: false,
hasCurrentLocalStateEpoch: true,
hasMemberSpawnStatusesIpcBackoff: false,
hasTeamRefreshBurstDiagnostics: false,
hasMemberSpawnUiEqualLastWarn: false,
});
expect(store.getState().leadActivityByTeam['my-team']).toBeUndefined();
expect(store.getState().leadContextByTeam['my-team']).toBeUndefined();
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBeUndefined();
expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toBeUndefined();
});
it('ignores stale async team snapshot and message refreshes after delete invalidates the team', async () => {
const store = createSliceStore();
const deferredData = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const deferredMessages = createDeferredPromise<{
messages: Array<{
from: string;
text: string;
timestamp: string;
messageId: string;
source: 'inbox';
}>;
nextCursor: null;
hasMore: false;
feedRevision: string;
}>();
const deferredMeta = createDeferredPromise<{
teamName: string;
computedAt: string;
feedRevision: string;
members: Record<
string,
{
memberName: string;
lastAuthoredMessageAt: string | null;
messageCountExact: number;
latestAuthoredMessageSignalsTermination: boolean;
}
>;
}>();
hoisted.getData.mockImplementation(() => deferredData.promise);
hoisted.getMessagesPage.mockImplementation(() => deferredMessages.promise);
hoisted.getMemberActivityMeta.mockImplementation(() => deferredMeta.promise);
store.setState({
teamMessagesByName: {
'my-team': {
canonicalMessages: [],
optimisticMessages: [],
nextCursor: null,
hasMore: false,
feedRevision: 'rev-0',
lastFetchedAt: Date.now(),
loadingHead: false,
loadingOlder: false,
headHydrated: true,
},
},
});
const refreshDataPromise = store.getState().refreshTeamData('my-team', { withDedup: false });
const refreshMessagesPromise = store.getState().refreshTeamMessagesHead('my-team');
const refreshMetaPromise = store.getState().refreshMemberActivityMeta('my-team');
await Promise.resolve();
await store.getState().deleteTeam('my-team');
deferredData.resolve(
createTeamSnapshot({
members: [{ name: 'alice', role: 'developer', currentTaskId: null }],
})
);
deferredMessages.resolve({
messages: [
{
from: 'alice',
text: 'late-message',
timestamp: '2026-03-12T10:00:00.000Z',
messageId: 'late-1',
source: 'inbox',
},
],
nextCursor: null,
hasMore: false,
feedRevision: 'rev-late',
});
deferredMeta.resolve({
teamName: 'my-team',
computedAt: '2026-03-12T10:00:00.000Z',
feedRevision: 'rev-late',
members: {
alice: {
memberName: 'alice',
lastAuthoredMessageAt: '2026-03-12T10:00:00.000Z',
messageCountExact: 1,
latestAuthoredMessageSignalsTermination: false,
},
},
});
await Promise.all([refreshDataPromise, refreshMessagesPromise, refreshMetaPromise]);
expect(store.getState().teamDataCacheByName['my-team']).toBeUndefined();
expect(store.getState().teamMessagesByName['my-team']).toBeUndefined();
expect(store.getState().memberActivityMetaByTeam['my-team']).toBeUndefined();
});
it('ignores stale async team refreshes after launch starts a new local epoch for the same team', async () => {
const store = createSliceStore();
const existingData = createTeamSnapshot({
config: { name: 'My Team Before Launch' },
members: [{ name: 'lead', role: 'lead', currentTaskId: null }],
});
const existingMeta: {
teamName: string;
computedAt: string;
feedRevision: string;
members: Record<
string,
{
memberName: string;
lastAuthoredMessageAt: string | null;
messageCountExact: number;
latestAuthoredMessageSignalsTermination: boolean;
}
>;
} = {
teamName: 'my-team',
computedAt: '2026-03-12T09:59:00.000Z',
feedRevision: 'rev-0',
members: {
lead: {
memberName: 'lead',
lastAuthoredMessageAt: '2026-03-12T09:59:00.000Z',
messageCountExact: 1,
latestAuthoredMessageSignalsTermination: false,
},
},
};
const deferredData = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const deferredMessages = createDeferredPromise<{
messages: Array<{
from: string;
text: string;
timestamp: string;
messageId: string;
source: 'inbox';
}>;
nextCursor: null;
hasMore: false;
feedRevision: string;
}>();
const deferredMeta = createDeferredPromise<typeof existingMeta>();
hoisted.getData.mockImplementation(() => deferredData.promise);
hoisted.getMessagesPage.mockImplementation(() => deferredMessages.promise);
hoisted.getMemberActivityMeta.mockImplementation(() => deferredMeta.promise);
store.setState({
selectedTeamName: 'my-team',
selectedTeamData: existingData,
teamDataCacheByName: {
'my-team': existingData,
},
teamMessagesByName: {
'my-team': {
canonicalMessages: [],
optimisticMessages: [],
nextCursor: null,
hasMore: false,
feedRevision: 'rev-0',
lastFetchedAt: Date.now(),
loadingHead: false,
loadingOlder: false,
headHydrated: true,
},
},
memberActivityMetaByTeam: {
'my-team': existingMeta,
},
});
const refreshDataPromise = store.getState().refreshTeamData('my-team', { withDedup: false });
const refreshMessagesPromise = store.getState().refreshTeamMessagesHead('my-team');
const refreshMetaPromise = store.getState().refreshMemberActivityMeta('my-team');
await Promise.resolve();
await store.getState().launchTeam({
teamName: 'my-team',
cwd: '/tmp/project',
});
expect(store.getState().teamMessagesByName['my-team']?.loadingHead).toBe(false);
deferredData.resolve(
createTeamSnapshot({
config: { name: 'My Team Stale After Launch' },
members: [{ name: 'alice', role: 'reviewer', currentTaskId: null }],
})
);
deferredMessages.resolve({
messages: [
{
from: 'alice',
text: 'stale-after-launch',
timestamp: '2026-03-12T10:00:00.000Z',
messageId: 'stale-after-launch-1',
source: 'inbox',
},
],
nextCursor: null,
hasMore: false,
feedRevision: 'rev-stale-after-launch',
});
deferredMeta.resolve({
teamName: 'my-team',
computedAt: '2026-03-12T10:00:00.000Z',
feedRevision: 'rev-stale-after-launch',
members: {
alice: {
memberName: 'alice',
lastAuthoredMessageAt: '2026-03-12T10:00:00.000Z',
messageCountExact: 3,
latestAuthoredMessageSignalsTermination: false,
},
},
});
await Promise.all([refreshDataPromise, refreshMessagesPromise, refreshMetaPromise]);
expect(store.getState().selectedTeamData).toBe(existingData);
expect(store.getState().teamDataCacheByName['my-team']).toBe(existingData);
expect(store.getState().teamMessagesByName['my-team']?.feedRevision).toBe('rev-0');
expect(store.getState().memberActivityMetaByTeam['my-team']).toEqual(existingMeta);
});
it('clears stale selectedTeamLoading when launch invalidates an in-flight selectTeam request', async () => {
const store = createSliceStore();
const existingData = createTeamSnapshot({
config: { name: 'My Team Cached' },
members: [{ name: 'lead', role: 'lead', currentTaskId: null }],
});
const deferredData = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
hoisted.getData.mockImplementationOnce(() => deferredData.promise);
store.setState({
teamDataCacheByName: {
'my-team': existingData,
},
});
const selectPromise = store.getState().selectTeam('my-team');
await Promise.resolve();
expect(store.getState().selectedTeamLoading).toBe(true);
expect(store.getState().selectedTeamData).toEqual(existingData);
await store.getState().launchTeam({
teamName: 'my-team',
cwd: '/tmp/project',
});
expect(store.getState().selectedTeamLoading).toBe(false);
expect(store.getState().selectedTeamError).toBeNull();
expect(store.getState().selectedTeamData).toEqual(existingData);
deferredData.resolve(
createTeamSnapshot({
config: { name: 'My Team Stale Select' },
members: [{ name: 'alice', role: 'reviewer', currentTaskId: null }],
})
);
await selectPromise;
expect(store.getState().selectedTeamLoading).toBe(false);
expect(store.getState().selectedTeamData).toEqual(existingData);
});
it('clears stale loadingOlder when launch invalidates an in-flight older messages request', async () => {
const store = createSliceStore();
const olderRequest = createDeferredPromise<{
messages: Array<{
from: string;
text: string;
timestamp: string;
read: boolean;
source: string;
messageId: string;
}>;
nextCursor: string | null;
hasMore: boolean;
feedRevision: string;
}>();
store.setState({
teamMessagesByName: {
'my-team': {
canonicalMessages: [],
optimisticMessages: [],
feedRevision: 'rev-1',
nextCursor: 'cursor-older',
hasMore: true,
lastFetchedAt: 123,
loadingHead: false,
loadingOlder: false,
headHydrated: true,
},
},
});
hoisted.getMessagesPage.mockImplementationOnce(() => olderRequest.promise);
const olderPromise = store.getState().loadOlderTeamMessages('my-team');
await Promise.resolve();
expect(store.getState().teamMessagesByName['my-team']?.loadingOlder).toBe(true);
await store.getState().launchTeam({
teamName: 'my-team',
cwd: '/tmp/project',
});
expect(store.getState().teamMessagesByName['my-team']?.loadingOlder).toBe(false);
olderRequest.resolve({
messages: [
{
from: 'bob',
text: 'Older tail',
timestamp: '2026-03-20T08:00:00.000Z',
read: true,
source: 'inbox',
messageId: 'msg-1',
},
],
nextCursor: null,
hasMore: false,
feedRevision: 'rev-1',
});
await olderPromise;
expect(store.getState().teamMessagesByName['my-team']?.loadingOlder).toBe(false);
});
it('ignores stale refreshTeamData failures after launch starts a new local epoch', async () => {
const store = createSliceStore();
const existingData = createTeamSnapshot({
config: { name: 'My Team Stable' },
members: [{ name: 'lead', role: 'lead', currentTaskId: null }],
});
const deferredData = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
hoisted.getData.mockImplementation(() => deferredData.promise);
store.setState({
selectedTeamName: 'my-team',
selectedTeamData: existingData,
teamDataCacheByName: {
'my-team': existingData,
},
selectedTeamError: null,
});
const refreshPromise = store.getState().refreshTeamData('my-team', { withDedup: false });
await Promise.resolve();
await store.getState().launchTeam({
teamName: 'my-team',
cwd: '/tmp/project',
});
deferredData.reject(new Error('TEAM_DRAFT'));
await refreshPromise;
expect(store.getState().selectedTeamData).toBe(existingData);
expect(store.getState().teamDataCacheByName['my-team']).toBe(existingData);
expect(store.getState().selectedTeamError).toBeNull();
});
it('keeps the newer messages-head request pinned when a stale pre-launch request settles', async () => {
const store = createSliceStore();
const deferredOld = createDeferredPromise<{
messages: Array<{
from: string;
text: string;
timestamp: string;
messageId: string;
source: 'inbox';
}>;
nextCursor: null;
hasMore: false;
feedRevision: string;
}>();
const deferredNew = createDeferredPromise<{
messages: Array<{
from: string;
text: string;
timestamp: string;
messageId: string;
source: 'inbox';
}>;
nextCursor: null;
hasMore: false;
feedRevision: string;
}>();
hoisted.getMessagesPage
.mockImplementationOnce(() => deferredOld.promise)
.mockImplementationOnce(() => deferredNew.promise);
const firstPromise = store.getState().refreshTeamMessagesHead('my-team');
await Promise.resolve();
await store.getState().launchTeam({
teamName: 'my-team',
cwd: '/tmp/project',
});
const secondPromise = store.getState().refreshTeamMessagesHead('my-team');
await Promise.resolve();
deferredOld.reject(new Error('stale head failed'));
await expect(firstPromise).resolves.toEqual({
feedChanged: false,
headChanged: false,
feedRevision: null,
});
expect(hoisted.getMessagesPage).toHaveBeenCalledTimes(2);
deferredNew.resolve({
messages: [
{
from: 'bob',
text: 'fresh-after-launch',
timestamp: '2026-03-12T10:00:01.000Z',
messageId: 'fresh-after-launch-1',
source: 'inbox',
},
],
nextCursor: null,
hasMore: false,
feedRevision: 'rev-fresh-after-launch',
});
await secondPromise;
expect(store.getState().teamMessagesByName['my-team']?.feedRevision).toBe(
'rev-fresh-after-launch'
);
});
it('does not reuse a pre-delete in-flight team snapshot request after the same team is reselected', async () => {
const store = createSliceStore();
const deferredOld = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const freshSnapshot = createTeamSnapshot({
config: { name: 'My Team Reloaded' },
members: [{ name: 'bob', role: 'developer', currentTaskId: null }],
});
hoisted.getData
.mockImplementationOnce(() => deferredOld.promise)
.mockResolvedValueOnce(freshSnapshot);
const firstSelectPromise = store.getState().selectTeam('my-team');
await Promise.resolve();
await store.getState().deleteTeam('my-team');
const secondSelectPromise = store.getState().selectTeam('my-team');
await secondSelectPromise;
expect(hoisted.getData).toHaveBeenCalledTimes(2);
expect(store.getState().selectedTeamData).toEqual(freshSnapshot);
deferredOld.resolve(
createTeamSnapshot({
config: { name: 'My Team Stale' },
members: [{ name: 'alice', role: 'reviewer', currentTaskId: null }],
})
);
await firstSelectPromise;
expect(store.getState().selectedTeamData).toEqual(freshSnapshot);
});
it('does not reuse a pre-delete in-flight messages head request after the same team is reselected', async () => {
const store = createSliceStore();
const deferredOld = createDeferredPromise<{
messages: Array<{
from: string;
text: string;
timestamp: string;
messageId: string;
source: 'inbox';
}>;
nextCursor: null;
hasMore: false;
feedRevision: string;
}>();
hoisted.getMessagesPage
.mockImplementationOnce(() => deferredOld.promise)
.mockResolvedValueOnce({
messages: [
{
from: 'bob',
text: 'fresh-message',
timestamp: '2026-03-12T10:00:01.000Z',
messageId: 'fresh-1',
source: 'inbox',
},
],
nextCursor: null,
hasMore: false,
feedRevision: 'rev-fresh',
});
const firstHeadPromise = store.getState().refreshTeamMessagesHead('my-team');
await Promise.resolve();
await store.getState().deleteTeam('my-team');
const secondHeadPromise = store.getState().refreshTeamMessagesHead('my-team');
await secondHeadPromise;
expect(hoisted.getMessagesPage).toHaveBeenCalledTimes(2);
expect(store.getState().teamMessagesByName['my-team']?.feedRevision).toBe('rev-fresh');
expect(store.getState().teamMessagesByName['my-team']?.canonicalMessages).toEqual([
{
from: 'bob',
text: 'fresh-message',
timestamp: '2026-03-12T10:00:01.000Z',
messageId: 'fresh-1',
source: 'inbox',
},
]);
deferredOld.resolve({
messages: [
{
from: 'alice',
text: 'stale-message',
timestamp: '2026-03-12T10:00:00.000Z',
messageId: 'stale-1',
source: 'inbox',
},
],
nextCursor: null,
hasMore: false,
feedRevision: 'rev-stale',
});
await firstHeadPromise;
expect(store.getState().teamMessagesByName['my-team']?.feedRevision).toBe('rev-fresh');
});
it('tombstones current progress runs when delete clears a team so late progress cannot resurrect it', async () => {
const store = createSliceStore();
store.setState({
provisioningRuns: {
'run-live': {
runId: 'run-live',
teamName: 'my-team',
state: 'assembling',
message: 'Live run',
startedAt: '2026-03-12T10:00:00.000Z',
updatedAt: '2026-03-12T10:00:00.000Z',
},
},
currentProvisioningRunIdByTeam: {
'my-team': 'run-live',
},
currentRuntimeRunIdByTeam: {
'my-team': 'run-live',
},
provisioningStartedAtFloorByTeam: {
'my-team': '2026-03-12T10:00:00.000Z',
},
});
await store.getState().deleteTeam('my-team');
expect(store.getState().ignoredProvisioningRunIds['run-live']).toBe('my-team');
expect(store.getState().ignoredRuntimeRunIds['run-live']).toBe('my-team');
expect(store.getState().provisioningStartedAtFloorByTeam['my-team']).toBeTruthy();
store.getState().onProvisioningProgress({
runId: 'run-live',
teamName: 'my-team',
state: 'ready',
message: 'Late zombie progress',
startedAt: '2026-03-12T10:00:00.000Z',
updatedAt: '2026-03-12T10:00:05.000Z',
});
expect(store.getState().provisioningRuns['run-live']).toBeUndefined();
expect(store.getState().currentProvisioningRunIdByTeam['my-team']).toBeUndefined();
expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBeUndefined();
});
it('stores runtime snapshots and suppresses semantic no-op refreshes', async () => {
const store = createSliceStore();
const snapshot = createRuntimeSnapshot();
hoisted.getTeamAgentRuntime.mockResolvedValue(snapshot);
await store.getState().fetchTeamAgentRuntime('my-team');
const firstSnapshot = store.getState().teamAgentRuntimeByTeam['my-team'];
expect(firstSnapshot).toEqual(snapshot);
hoisted.getTeamAgentRuntime.mockResolvedValue({
...snapshot,
updatedAt: '2026-03-12T10:00:05.000Z',
members: {
alice: {
...snapshot.members.alice,
updatedAt: '2026-03-12T10:00:05.000Z',
},
},
});
await store.getState().fetchTeamAgentRuntime('my-team');
expect(store.getState().teamAgentRuntimeByTeam['my-team']).toBe(firstSnapshot);
});
it('updates runtime snapshots when liveness diagnostics change', async () => {
const store = createSliceStore();
const snapshot = createRuntimeSnapshot();
hoisted.getTeamAgentRuntime.mockResolvedValue(snapshot);
await store.getState().fetchTeamAgentRuntime('my-team');
const firstSnapshot = store.getState().teamAgentRuntimeByTeam['my-team'];
const nextSnapshot = createRuntimeSnapshot({
members: {
alice: {
...snapshot.members.alice,
alive: false,
livenessKind: 'shell_only',
pidSource: 'tmux_pane',
runtimeDiagnostic: 'tmux pane foreground command is zsh',
runtimeDiagnosticSeverity: 'warning',
},
},
});
hoisted.getTeamAgentRuntime.mockResolvedValue(nextSnapshot);
await store.getState().fetchTeamAgentRuntime('my-team');
expect(store.getState().teamAgentRuntimeByTeam['my-team']).not.toBe(firstSnapshot);
expect(store.getState().teamAgentRuntimeByTeam['my-team']).toEqual(nextSnapshot);
});
it('updates runtime snapshots when resource history changes', async () => {
const store = createSliceStore();
const firstResourceHistory = [
{
timestamp: '2026-03-12T10:00:00.000Z',
rssBytes: 256 * 1024 * 1024,
cpuPercent: 4,
pid: 4242,
},
];
const snapshot = createRuntimeSnapshot({
members: {
alice: {
...createRuntimeSnapshot().members.alice,
cpuPercent: 4,
resourceHistory: firstResourceHistory,
},
},
});
hoisted.getTeamAgentRuntime.mockResolvedValue(snapshot);
await store.getState().fetchTeamAgentRuntime('my-team');
const firstSnapshot = store.getState().teamAgentRuntimeByTeam['my-team'];
const nextSnapshot = createRuntimeSnapshot({
members: {
alice: {
...snapshot.members.alice,
cpuPercent: 14,
resourceHistory: [
...firstResourceHistory,
{
timestamp: '2026-03-12T10:00:05.000Z',
rssBytes: 270 * 1024 * 1024,
cpuPercent: 14,
pid: 4242,
},
],
},
},
});
hoisted.getTeamAgentRuntime.mockResolvedValue(nextSnapshot);
await store.getState().fetchTeamAgentRuntime('my-team');
expect(store.getState().teamAgentRuntimeByTeam['my-team']).not.toBe(firstSnapshot);
expect(store.getState().teamAgentRuntimeByTeam['my-team']).toEqual(nextSnapshot);
});
it('does not crash when runtime resource history contains malformed samples', async () => {
const store = createSliceStore();
const validSample = {
timestamp: '2026-03-12T10:00:00.000Z',
rssBytes: 256 * 1024 * 1024,
cpuPercent: 4,
pid: 4242,
};
const snapshot = createRuntimeSnapshot({
members: {
alice: {
...createRuntimeSnapshot().members.alice,
cpuPercent: 4,
resourceHistory: [null, validSample] as any,
},
},
});
hoisted.getTeamAgentRuntime.mockResolvedValue(snapshot);
await store.getState().fetchTeamAgentRuntime('my-team');
const firstSnapshot = store.getState().teamAgentRuntimeByTeam['my-team'];
const semanticallySameSnapshot = createRuntimeSnapshot({
members: {
alice: {
...snapshot.members.alice,
resourceHistory: [null, { ...validSample }] as any,
},
},
});
hoisted.getTeamAgentRuntime.mockResolvedValue(semanticallySameSnapshot);
await store.getState().fetchTeamAgentRuntime('my-team');
expect(store.getState().teamAgentRuntimeByTeam['my-team']).toBe(firstSnapshot);
});
it('updates runtime snapshots when aggregate runtime load breakdown changes', async () => {
const store = createSliceStore();
const firstBreakdownHistorySample = {
timestamp: '2026-03-12T10:00:00.000Z',
rssBytes: 300 * 1024 * 1024,
cpuPercent: 12,
primaryCpuPercent: 12,
primaryRssBytes: 300 * 1024 * 1024,
processCount: 1,
runtimeLoadScope: 'single-process',
pid: 4242,
};
const snapshot = createRuntimeSnapshot({
members: {
alice: {
...createRuntimeSnapshot().members.alice,
cpuPercent: 12,
rssBytes: 300 * 1024 * 1024,
primaryCpuPercent: 12,
primaryRssBytes: 300 * 1024 * 1024,
processCount: 1,
runtimeLoadScope: 'single-process',
resourceHistory: [firstBreakdownHistorySample],
},
},
});
hoisted.getTeamAgentRuntime.mockResolvedValue(snapshot);
await store.getState().fetchTeamAgentRuntime('my-team');
const firstSnapshot = store.getState().teamAgentRuntimeByTeam['my-team'];
const nextSnapshot = createRuntimeSnapshot({
members: {
alice: {
...snapshot.members.alice,
childCpuPercent: 8,
childRssBytes: 80 * 1024 * 1024,
processCount: 3,
runtimeLoadScope: 'process-tree',
resourceHistory: [
{
...firstBreakdownHistorySample,
childCpuPercent: 8,
childRssBytes: 80 * 1024 * 1024,
processCount: 3,
runtimeLoadScope: 'process-tree',
},
],
},
},
});
hoisted.getTeamAgentRuntime.mockResolvedValue(nextSnapshot);
await store.getState().fetchTeamAgentRuntime('my-team');
expect(store.getState().teamAgentRuntimeByTeam['my-team']).not.toBe(firstSnapshot);
expect(store.getState().teamAgentRuntimeByTeam['my-team']).toEqual(nextSnapshot);
});
it('updates runtime snapshots when copy-diagnostics details change', async () => {
const store = createSliceStore();
const snapshot = createRuntimeSnapshot({
members: {
alice: {
memberName: 'alice',
alive: false,
restartable: true,
backendType: 'tmux',
pid: 42,
livenessKind: 'shell_only',
pidSource: 'tmux_pane',
paneId: '%42',
panePid: 42,
paneCurrentCommand: 'zsh',
runtimeDiagnostic: 'tmux pane foreground command is zsh',
diagnostics: ['tmux pane foreground command is zsh'],
updatedAt: '2026-03-12T10:00:00.000Z',
},
},
});
hoisted.getTeamAgentRuntime.mockResolvedValue(snapshot);
await store.getState().fetchTeamAgentRuntime('my-team');
const firstSnapshot = store.getState().teamAgentRuntimeByTeam['my-team'];
const nextSnapshot = createRuntimeSnapshot({
members: {
alice: {
...snapshot.members.alice,
processCommand: 'node runtime --token [redacted]',
runtimeSessionId: 'session-alice',
diagnostics: [
'tmux pane foreground command is zsh',
'no verified runtime descendant process was found',
],
},
},
});
hoisted.getTeamAgentRuntime.mockResolvedValue(nextSnapshot);
await store.getState().fetchTeamAgentRuntime('my-team');
expect(store.getState().teamAgentRuntimeByTeam['my-team']).not.toBe(firstSnapshot);
expect(store.getState().teamAgentRuntimeByTeam['my-team']).toEqual(nextSnapshot);
});
it('updates runtime snapshots when historical bootstrap state changes', async () => {
const store = createSliceStore();
const snapshot = createRuntimeSnapshot();
hoisted.getTeamAgentRuntime.mockResolvedValue(snapshot);
await store.getState().fetchTeamAgentRuntime('my-team');
const firstSnapshot = store.getState().teamAgentRuntimeByTeam['my-team'];
const nextSnapshot = createRuntimeSnapshot({
members: {
alice: {
...snapshot.members.alice,
alive: false,
historicalBootstrapConfirmed: true,
},
},
});
hoisted.getTeamAgentRuntime.mockResolvedValue(nextSnapshot);
await store.getState().fetchTeamAgentRuntime('my-team');
expect(store.getState().teamAgentRuntimeByTeam['my-team']).not.toBe(firstSnapshot);
expect(store.getState().teamAgentRuntimeByTeam['my-team']).toEqual(nextSnapshot);
});
it('restartMember refreshes spawn statuses and runtime snapshot', async () => {
const store = createSliceStore();
hoisted.getMemberSpawnStatuses.mockResolvedValue({
statuses: {
alice: createMemberSpawnStatus({ status: 'spawning', launchState: 'starting' }),
},
runId: 'runtime-run',
});
hoisted.getTeamAgentRuntime.mockResolvedValue(createRuntimeSnapshot());
await store.getState().restartMember('my-team', 'alice');
expect(hoisted.restartMember).toHaveBeenCalledWith('my-team', 'alice');
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toEqual({
alice: expect.objectContaining({ status: 'spawning', launchState: 'starting' }),
});
expect(store.getState().teamAgentRuntimeByTeam['my-team']).toEqual(createRuntimeSnapshot());
});
it('retryFailedOpenCodeSecondaryLanes refreshes only spawn statuses and runtime snapshot', async () => {
const store = createSliceStore();
const refreshSpawnStatuses = vi.fn(async (_teamName: string) => undefined);
const refreshRuntimeSnapshot = vi.fn(async (_teamName: string) => undefined);
const refreshTeamData = vi.fn(async (_teamName: string) => undefined);
const fetchTeams = vi.fn(async () => undefined);
store.setState({
fetchMemberSpawnStatuses: refreshSpawnStatuses,
fetchTeamAgentRuntime: refreshRuntimeSnapshot,
refreshTeamData,
fetchTeams,
});
hoisted.retryFailedOpenCodeSecondaryLanes.mockResolvedValueOnce({
attempted: ['alice'],
confirmed: [],
pending: [],
failed: [{ memberName: 'alice', error: 'OpenRouter credits exhausted' }],
skipped: [],
});
const result = await store.getState().retryFailedOpenCodeSecondaryLanes('my-team');
expect(result.failed).toEqual([{ memberName: 'alice', error: 'OpenRouter credits exhausted' }]);
expect(hoisted.retryFailedOpenCodeSecondaryLanes).toHaveBeenCalledWith('my-team');
expect(refreshSpawnStatuses).toHaveBeenCalledWith('my-team');
expect(refreshRuntimeSnapshot).toHaveBeenCalledWith('my-team');
expect(refreshTeamData).not.toHaveBeenCalled();
expect(fetchTeams).not.toHaveBeenCalled();
});
it('restartMember refreshes spawn statuses and runtime snapshot even when restart fails', async () => {
const store = createSliceStore();
const refreshSpawnStatuses = vi.fn(async (_teamName: string) => undefined);
const refreshRuntimeSnapshot = vi.fn(async (_teamName: string) => undefined);
store.setState({
fetchMemberSpawnStatuses: refreshSpawnStatuses,
fetchTeamAgentRuntime: refreshRuntimeSnapshot,
});
hoisted.restartMember.mockRejectedValueOnce(new Error('restart failed'));
await expect(store.getState().restartMember('my-team', 'alice')).rejects.toThrow(
'restart failed'
);
expect(refreshSpawnStatuses).toHaveBeenCalledWith('my-team');
expect(refreshRuntimeSnapshot).toHaveBeenCalledWith('my-team');
});
it('skipMemberForLaunch refreshes spawn statuses, runtime snapshot, and team list', async () => {
const store = createSliceStore();
const refreshTeams = vi.fn(async () => undefined);
store.setState({ fetchTeams: refreshTeams });
hoisted.getMemberSpawnStatuses.mockResolvedValue({
statuses: {
alice: createMemberSpawnStatus({
status: 'skipped',
launchState: 'skipped_for_launch',
skippedForLaunch: true,
}),
},
runId: 'runtime-run',
});
hoisted.getTeamAgentRuntime.mockResolvedValue(createRuntimeSnapshot());
await store.getState().skipMemberForLaunch('my-team', 'alice');
expect(hoisted.skipMemberForLaunch).toHaveBeenCalledWith('my-team', 'alice');
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toEqual({
alice: expect.objectContaining({
status: 'skipped',
launchState: 'skipped_for_launch',
skippedForLaunch: true,
}),
});
expect(store.getState().teamAgentRuntimeByTeam['my-team']).toEqual(createRuntimeSnapshot());
expect(refreshTeams).toHaveBeenCalled();
});
it('skipMemberForLaunch refreshes launch data even when skip fails', async () => {
const store = createSliceStore();
const refreshSpawnStatuses = vi.fn(async (_teamName: string) => undefined);
const refreshRuntimeSnapshot = vi.fn(async (_teamName: string) => undefined);
const refreshTeams = vi.fn(async () => undefined);
store.setState({
fetchMemberSpawnStatuses: refreshSpawnStatuses,
fetchTeamAgentRuntime: refreshRuntimeSnapshot,
fetchTeams: refreshTeams,
});
hoisted.skipMemberForLaunch.mockRejectedValueOnce(new Error('skip failed'));
await expect(store.getState().skipMemberForLaunch('my-team', 'alice')).rejects.toThrow(
'skip failed'
);
expect(refreshSpawnStatuses).toHaveBeenCalledWith('my-team');
expect(refreshRuntimeSnapshot).toHaveBeenCalledWith('my-team');
expect(refreshTeams).toHaveBeenCalled();
});
it('clears stale runtime snapshots on delete', async () => {
const store = createSliceStore();
store.setState({
teamAgentRuntimeByTeam: {
'my-team': createRuntimeSnapshot(),
},
});
await store.getState().deleteTeam('my-team');
expect(store.getState().teamAgentRuntimeByTeam['my-team']).toBeUndefined();
});
describe('refreshTeamData provisioning safety', () => {
it('does not set fatal error on TEAM_PROVISIONING', async () => {
const store = createSliceStore();
// First, select a team so selectedTeamName is set
store.setState({
selectedTeamName: 'my-team',
selectedTeamData: {
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
},
selectedTeamError: null,
});
hoisted.getData.mockRejectedValue(new Error('TEAM_PROVISIONING'));
await store.getState().refreshTeamData('my-team');
// Should NOT set error — team is still provisioning
expect(store.getState().selectedTeamError).toBeNull();
// Should preserve existing data
expect(store.getState().selectedTeamData).not.toBeNull();
expect(store.getState().selectedTeamData?.teamName).toBe('my-team');
});
it('preserves existing data on transient refresh error', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
const store = createSliceStore();
const existingData = {
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
};
store.setState({
selectedTeamName: 'my-team',
selectedTeamData: existingData,
selectedTeamError: null,
});
hoisted.getData.mockRejectedValue(new Error('Network timeout'));
await store.getState().refreshTeamData('my-team');
// Should NOT replace data with error — preserve existing data
expect(store.getState().selectedTeamError).toBeNull();
expect(store.getState().selectedTeamData).toEqual(existingData);
});
it('reuses the existing selectedTeamData ref on a semantic no-op refresh', async () => {
const store = createSliceStore();
const existingData = createTeamSnapshot({
tasks: [
{
id: 'task-1',
subject: 'Stable task',
status: 'pending',
createdAt: '2026-03-20T08:00:00.000Z',
updatedAt: '2026-03-20T08:00:00.000Z',
},
],
members: [
{
name: 'alice',
currentTaskId: 'task-1',
taskCount: 1,
},
],
});
store.setState({
selectedTeamName: 'my-team',
selectedTeamData: existingData,
teamDataCacheByName: {
'my-team': existingData,
},
selectedTeamError: 'stale error',
});
hoisted.getData.mockResolvedValue({
...existingData,
tasks: existingData.tasks.map((task: any) => ({ ...task })),
members: existingData.members.map((member: any) => ({ ...member })),
kanbanState: {
...existingData.kanbanState,
reviewers: [...existingData.kanbanState.reviewers],
tasks: { ...existingData.kanbanState.tasks },
},
processes: [...existingData.processes],
});
await store.getState().refreshTeamData('my-team');
expect(store.getState().selectedTeamData).toBe(existingData);
expect(store.getState().teamDataCacheByName['my-team']).toBe(existingData);
expect(store.getState().selectedTeamError).toBeNull();
});
it('memoizes focused resolved member selection against unrelated member activity churn', () => {
const aliceSnapshot = {
name: 'alice',
currentTaskId: null,
taskCount: 0,
role: 'Reviewer',
};
const bobSnapshot = {
name: 'bob',
currentTaskId: null,
taskCount: 0,
role: 'Builder',
};
const baseState = {
selectedTeamName: 'my-team',
selectedTeamData: null,
teamDataCacheByName: {
'my-team': createTeamSnapshot({
members: [aliceSnapshot, bobSnapshot],
}),
},
memberActivityMetaByTeam: {
'my-team': {
teamName: 'my-team',
computedAt: '2026-03-12T10:00:00.000Z',
feedRevision: 'rev-1',
members: {
alice: {
memberName: 'alice',
lastAuthoredMessageAt: '2026-03-12T10:00:00.000Z',
messageCountExact: 3,
latestAuthoredMessageSignalsTermination: false,
},
bob: {
memberName: 'bob',
lastAuthoredMessageAt: '2026-03-12T10:01:00.000Z',
messageCountExact: 1,
latestAuthoredMessageSignalsTermination: false,
},
},
},
},
};
const firstAlice = selectResolvedMemberForTeamName(baseState as never, 'my-team', 'alice');
const nextState = {
...baseState,
memberActivityMetaByTeam: {
'my-team': {
...baseState.memberActivityMetaByTeam['my-team'],
computedAt: '2026-03-12T10:02:00.000Z',
feedRevision: 'rev-2',
members: {
...baseState.memberActivityMetaByTeam['my-team'].members,
bob: {
...baseState.memberActivityMetaByTeam['my-team'].members.bob,
messageCountExact: 2,
},
},
},
},
};
const secondAlice = selectResolvedMemberForTeamName(nextState as never, 'my-team', 'alice');
expect(firstAlice).not.toBeNull();
expect(secondAlice).toBe(firstAlice);
});
it('re-canonicalizes selectedTeamData into the cache on a no-op refresh', async () => {
const store = createSliceStore();
const existingData = createTeamSnapshot({
tasks: [
{
id: 'task-1',
subject: 'Stable task',
status: 'pending',
createdAt: '2026-03-20T08:00:00.000Z',
updatedAt: '2026-03-20T08:00:00.000Z',
},
],
});
store.setState({
selectedTeamName: 'my-team',
selectedTeamData: existingData,
teamDataCacheByName: {},
});
hoisted.getData.mockResolvedValue({
...existingData,
tasks: existingData.tasks.map((task: any) => ({ ...task })),
members: existingData.members.map((member: any) => ({ ...member })),
kanbanState: {
...existingData.kanbanState,
reviewers: [...existingData.kanbanState.reviewers],
tasks: { ...existingData.kanbanState.tasks },
},
processes: [...existingData.processes],
});
await store.getState().refreshTeamData('my-team');
expect(store.getState().teamDataCacheByName['my-team']).toBe(existingData);
expect(store.getState().selectedTeamData).toBe(existingData);
});
it('clears non-selected cache on TEAM_DRAFT refresh failure', async () => {
const store = createSliceStore();
store.setState({
selectedTeamName: 'other-team',
selectedTeamData: {
teamName: 'other-team',
config: { name: 'Other Team' },
tasks: [],
members: [],
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
processes: [],
},
teamDataCacheByName: {
'my-team': {
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
},
},
});
hoisted.getData.mockRejectedValue(new Error('TEAM_DRAFT'));
await store.getState().refreshTeamData('my-team');
expect(store.getState().teamDataCacheByName['my-team']).toBeUndefined();
expect(store.getState().selectedTeamData?.teamName).toBe('other-team');
});
it('clears non-selected cache when the team no longer exists', async () => {
const store = createSliceStore();
store.setState({
selectedTeamName: 'other-team',
selectedTeamData: {
teamName: 'other-team',
config: { name: 'Other Team' },
tasks: [],
members: [],
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
processes: [],
},
teamDataCacheByName: {
'my-team': {
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
},
},
});
hoisted.getData.mockRejectedValue(new Error('Team not found: my-team'));
await store.getState().refreshTeamData('my-team');
expect(store.getState().teamDataCacheByName['my-team']).toBeUndefined();
expect(store.getState().selectedTeamData?.teamName).toBe('other-team');
});
it('clears stale selectedTeamError when TEAM_PROVISIONING with existing data', async () => {
const store = createSliceStore();
store.setState({
selectedTeamName: 'my-team',
selectedTeamData: {
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
},
selectedTeamError: 'Previous failure',
});
hoisted.getData.mockRejectedValue(new Error('TEAM_PROVISIONING'));
await store.getState().refreshTeamData('my-team');
// Stale error should be cleared even though provisioning prevents new data
expect(store.getState().selectedTeamError).toBeNull();
expect(store.getState().selectedTeamData).not.toBeNull();
});
it('clears stale selectedTeamError on transient error when data exists', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
const store = createSliceStore();
const existingData = {
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
};
store.setState({
selectedTeamName: 'my-team',
selectedTeamData: existingData,
selectedTeamError: 'Old stale error',
});
hoisted.getData.mockRejectedValue(new Error('Network timeout'));
await store.getState().refreshTeamData('my-team');
// Stale error should be cleared because we still have usable data
expect(store.getState().selectedTeamError).toBeNull();
expect(store.getState().selectedTeamData).toEqual(existingData);
});
it('sets error when no previous data exists', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
const store = createSliceStore();
store.setState({
selectedTeamName: 'my-team',
selectedTeamData: null,
selectedTeamError: null,
});
hoisted.getData.mockRejectedValue(new Error('Team not found'));
await store.getState().refreshTeamData('my-team');
// No previous data — error should be shown
expect(store.getState().selectedTeamError).toBe('Team not found');
});
it('invalidates changed task summaries without warming task availability on refresh', async () => {
const store = createSliceStore();
const invalidateTaskChangePresence = vi.fn();
const warmTaskChangeSummaries = vi.fn(async () => undefined);
store.setState({
selectedTeamName: 'my-team',
invalidateTaskChangePresence,
warmTaskChangeSummaries,
selectedTeamData: {
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [
{
id: 'task-1',
subject: 'Old completed',
status: 'completed',
owner: 'alice',
createdAt: '2026-03-01T10:00:00.000Z',
updatedAt: '2026-03-01T10:00:00.000Z',
workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }],
historyEvents: [],
comments: [],
attachments: [],
},
{
id: 'task-2',
subject: 'Still approved',
status: 'completed',
owner: 'bob',
createdAt: '2026-03-01T10:00:00.000Z',
updatedAt: '2026-03-01T10:00:00.000Z',
workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }],
historyEvents: [
{
id: 'evt-approved',
type: 'review_approved',
to: 'approved',
timestamp: '2026-03-01T10:10:00.000Z',
},
],
comments: [],
attachments: [],
},
],
members: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
},
});
hoisted.getData.mockResolvedValue({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [
{
id: 'task-1',
subject: 'Moved to review',
status: 'completed',
owner: 'alice',
createdAt: '2026-03-01T10:00:00.000Z',
updatedAt: '2026-03-01T11:00:00.000Z',
workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }],
historyEvents: [
{
id: 'evt-review',
type: 'review_requested',
to: 'review',
timestamp: '2026-03-01T11:00:00.000Z',
},
],
comments: [],
attachments: [],
},
{
id: 'task-2',
subject: 'Still approved',
status: 'completed',
owner: 'bob',
createdAt: '2026-03-01T10:00:00.000Z',
updatedAt: '2026-03-01T10:00:00.000Z',
workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }],
historyEvents: [
{
id: 'evt-approved',
type: 'review_approved',
to: 'approved',
timestamp: '2026-03-01T10:10:00.000Z',
},
],
comments: [],
attachments: [],
},
],
members: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
await store.getState().refreshTeamData('my-team');
expect(hoisted.invalidateTaskChangeSummaries).toHaveBeenCalledWith('my-team', ['task-1']);
expect(invalidateTaskChangePresence).toHaveBeenCalledTimes(1);
expect(warmTaskChangeSummaries).not.toHaveBeenCalled();
});
it('preserves known task changePresence across refresh when task change signature is unchanged', async () => {
const store = createSliceStore();
store.setState({
selectedTeamName: 'my-team',
selectedTeamData: {
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [
{
id: 'task-1',
subject: 'Known changes',
status: 'in_progress',
owner: 'alice',
createdAt: '2026-03-01T10:00:00.000Z',
updatedAt: '2026-03-01T10:00:00.000Z',
workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }],
historyEvents: [],
comments: [],
attachments: [],
changePresence: 'has_changes',
},
],
members: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
},
});
hoisted.getData.mockResolvedValue({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [
{
id: 'task-1',
subject: 'Known changes',
status: 'in_progress',
owner: 'alice',
createdAt: '2026-03-01T10:00:00.000Z',
updatedAt: '2026-03-01T10:00:00.000Z',
workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }],
historyEvents: [],
comments: [],
attachments: [],
changePresence: 'unknown',
},
],
members: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
await store.getState().refreshTeamData('my-team');
expect(store.getState().selectedTeamData?.tasks[0]?.changePresence).toBe('has_changes');
});
});
describe('provisioning run scoping', () => {
it('persists providerBackendId into createTeam launch params', async () => {
const store = createSliceStore();
await store.getState().createTeam({
teamName: 'my-team',
cwd: '/tmp/project',
members: [],
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
effort: 'medium',
});
expect(store.getState().launchParamsByTeam['my-team']).toEqual({
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
effort: 'medium',
limitContext: false,
});
});
it('persists providerBackendId into launchTeam launch params', async () => {
const store = createSliceStore();
await store.getState().launchTeam({
teamName: 'my-team',
cwd: '/tmp/project',
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
effort: 'medium',
});
expect(store.getState().launchParamsByTeam['my-team']).toEqual({
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
effort: 'medium',
limitContext: false,
});
});
it('stages changed launchTeam params before the launch IPC resolves', async () => {
const store = createSliceStore();
const launchRequest = createDeferredPromise<{ runId: string }>();
hoisted.launchTeam.mockImplementationOnce(() => launchRequest.promise);
store.setState({
launchParamsByTeam: {
'my-team': {
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.5',
effort: 'medium',
limitContext: false,
},
},
});
const launchPromise = store.getState().launchTeam({
teamName: 'my-team',
cwd: '/tmp/project',
providerId: 'anthropic',
model: 'sonnet',
effort: 'low',
});
await Promise.resolve();
expect(store.getState().launchParamsByTeam['my-team']).toEqual({
providerId: 'anthropic',
providerBackendId: undefined,
model: 'sonnet',
effort: 'low',
limitContext: false,
});
launchRequest.resolve({ runId: 'run-2' });
await launchPromise;
expect(store.getState().launchParamsByTeam['my-team']).toEqual({
providerId: 'anthropic',
providerBackendId: undefined,
model: 'sonnet',
effort: 'low',
limitContext: false,
});
});
it('sanitizes stale providerBackendId before staging launchTeam params', async () => {
const store = createSliceStore();
const launchRequest = createDeferredPromise<{ runId: string }>();
hoisted.launchTeam.mockImplementationOnce(() => launchRequest.promise);
const launchPromise = store.getState().launchTeam({
teamName: 'my-team',
cwd: '/tmp/project',
providerId: 'anthropic',
providerBackendId: 'codex-native',
model: 'haiku',
effort: 'low',
});
await Promise.resolve();
expect(store.getState().launchParamsByTeam['my-team']).toEqual({
providerId: 'anthropic',
providerBackendId: undefined,
model: 'haiku',
effort: 'low',
limitContext: false,
});
launchRequest.resolve({ runId: 'run-2' });
await launchPromise;
expect(store.getState().launchParamsByTeam['my-team']).toEqual({
providerId: 'anthropic',
providerBackendId: undefined,
model: 'haiku',
effort: 'low',
limitContext: false,
});
});
it('does not stage a previous model when launchTeam changes provider without a model', async () => {
const store = createSliceStore();
const launchRequest = createDeferredPromise<{ runId: string }>();
hoisted.launchTeam.mockImplementationOnce(() => launchRequest.promise);
store.setState({
launchParamsByTeam: {
'my-team': {
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.5',
effort: 'medium',
limitContext: true,
},
},
});
const launchPromise = store.getState().launchTeam({
teamName: 'my-team',
cwd: '/tmp/project',
providerId: 'anthropic',
});
await Promise.resolve();
expect(store.getState().launchParamsByTeam['my-team']).toEqual({
providerId: 'anthropic',
providerBackendId: undefined,
model: 'default',
effort: undefined,
limitContext: false,
});
launchRequest.resolve({ runId: 'run-2' });
await launchPromise;
});
it('stages Default when launchTeam keeps the provider but explicitly clears the model', async () => {
const store = createSliceStore();
const launchRequest = createDeferredPromise<{ runId: string }>();
hoisted.launchTeam.mockImplementationOnce(() => launchRequest.promise);
store.setState({
launchParamsByTeam: {
'my-team': {
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.5',
effort: 'medium',
limitContext: false,
},
},
});
const launchPromise = store.getState().launchTeam({
teamName: 'my-team',
cwd: '/tmp/project',
providerId: 'codex',
providerBackendId: 'codex-native',
model: undefined,
effort: 'low',
});
await Promise.resolve();
expect(store.getState().launchParamsByTeam['my-team']).toEqual({
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'default',
effort: 'low',
limitContext: false,
});
launchRequest.resolve({ runId: 'run-2' });
await launchPromise;
});
it('keeps previous launch params while a metadata-only relaunch request is pending', async () => {
const store = createSliceStore();
const previousParams = {
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.5',
effort: 'medium',
limitContext: false,
};
store.setState({
launchParamsByTeam: {
'my-team': previousParams,
},
});
const launchRequest = createDeferredPromise<{ runId: string }>();
hoisted.launchTeam.mockImplementationOnce(() => launchRequest.promise);
const launchPromise = store.getState().launchTeam({
teamName: 'my-team',
cwd: '/tmp/project',
});
await Promise.resolve();
expect(store.getState().launchParamsByTeam['my-team']).toEqual(previousParams);
launchRequest.resolve({ runId: 'run-2' });
await launchPromise;
expect(store.getState().launchParamsByTeam['my-team']).toEqual(previousParams);
});
it('rolls back staged launch params when launchTeam fails before provisioning starts', async () => {
const store = createSliceStore();
const previousParams = {
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.5',
effort: 'medium',
limitContext: false,
};
store.setState({
launchParamsByTeam: {
'my-team': previousParams,
},
});
hoisted.launchTeam.mockRejectedValueOnce(new Error('launch failed'));
await expect(
store.getState().launchTeam({
teamName: 'my-team',
cwd: '/tmp/project',
providerId: 'anthropic',
model: 'sonnet',
effort: 'low',
})
).rejects.toThrow('launch failed');
expect(store.getState().launchParamsByTeam['my-team']).toEqual(previousParams);
});
it('rolls back optimistic pending run on early createTeam failure', async () => {
const store = createSliceStore();
const previousParams = {
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.5',
effort: 'medium',
limitContext: false,
};
store.setState({
launchParamsByTeam: {
'my-team': previousParams,
},
});
hoisted.createTeam.mockRejectedValue(new Error('create failed'));
await expect(
store.getState().createTeam({
teamName: 'my-team',
cwd: '/tmp/project',
members: [],
providerId: 'anthropic',
model: 'sonnet',
effort: 'low',
})
).rejects.toThrow('create failed');
expect(store.getState().currentProvisioningRunIdByTeam['my-team']).toBeUndefined();
expect(Object.values(store.getState().provisioningRuns)).toHaveLength(0);
expect(store.getState().provisioningErrorByTeam['my-team']).toBe('create failed');
expect(store.getState().launchParamsByTeam['my-team']).toEqual(previousParams);
});
it('hydrates visible non-selected graph tabs when config becomes ready', () => {
const store = createSliceStore();
store.setState({
selectedTeamName: 'other-team',
selectedTeamData: {
teamName: 'other-team',
config: { name: 'Other Team' },
tasks: [],
members: [],
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
processes: [],
},
paneLayout: {
focusedPaneId: 'pane-default',
panes: [
{
id: 'pane-default',
widthFraction: 1,
tabs: [{ id: 'graph-1', type: 'graph', teamName: 'my-team', label: 'My Team' }],
activeTabId: 'graph-1',
},
],
},
currentProvisioningRunIdByTeam: {
'my-team': 'run-current',
},
});
const refreshTeamDataSpy = vi.spyOn(store.getState(), 'refreshTeamData');
const selectTeamSpy = vi.spyOn(store.getState(), 'selectTeam');
store.getState().onProvisioningProgress({
runId: 'run-current',
teamName: 'my-team',
state: 'assembling',
configReady: true,
message: 'Config written',
startedAt: '2026-03-12T10:00:00.000Z',
updatedAt: '2026-03-12T10:00:01.000Z',
});
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
expect(selectTeamSpy).not.toHaveBeenCalled();
});
it('refreshes visible non-selected graph tabs when the canonical run reaches ready', () => {
const store = createSliceStore();
store.setState({
selectedTeamName: 'other-team',
selectedTeamData: {
teamName: 'other-team',
config: { name: 'Other Team' },
tasks: [],
members: [],
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
processes: [],
},
paneLayout: {
focusedPaneId: 'pane-default',
panes: [
{
id: 'pane-default',
widthFraction: 1,
tabs: [{ id: 'graph-1', type: 'graph', teamName: 'my-team', label: 'My Team' }],
activeTabId: 'graph-1',
},
],
},
currentProvisioningRunIdByTeam: {
'my-team': 'run-current',
},
});
const refreshTeamDataSpy = vi.spyOn(store.getState(), 'refreshTeamData');
const selectTeamSpy = vi.spyOn(store.getState(), 'selectTeam');
store.getState().onProvisioningProgress({
runId: 'run-current',
teamName: 'my-team',
state: 'ready',
message: 'Ready',
startedAt: '2026-03-12T10:00:00.000Z',
updatedAt: '2026-03-12T10:00:02.000Z',
});
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
expect(selectTeamSpy).not.toHaveBeenCalled();
});
it('keeps the current run pinned when stale progress from another run arrives', () => {
const store = createSliceStore();
const startedAt = '2026-03-12T10:00:00.000Z';
store.getState().onProvisioningProgress({
runId: 'run-current',
teamName: 'my-team',
state: 'spawning',
message: 'Current run',
startedAt,
updatedAt: startedAt,
});
store.getState().onProvisioningProgress({
runId: 'run-stale',
teamName: 'my-team',
state: 'failed',
message: 'Stale failure',
error: 'stale',
startedAt: '2026-03-12T10:00:01.000Z',
updatedAt: '2026-03-12T10:00:01.000Z',
});
expect(store.getState().currentProvisioningRunIdByTeam['my-team']).toBe('run-current');
expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBe('run-current');
expect(store.getState().provisioningErrorByTeam['my-team']).toBeUndefined();
expect(store.getState().provisioningRuns['run-stale']).toBeUndefined();
});
it('promotes a pending run to a real run without throwing', () => {
const store = createSliceStore();
store.setState({
provisioningRuns: {
'pending:my-team:1': {
runId: 'pending:my-team:1',
teamName: 'my-team',
state: 'spawning',
message: 'Launching',
startedAt: '2026-03-12T10:00:00.000Z',
updatedAt: '2026-03-12T10:00:00.000Z',
},
},
currentProvisioningRunIdByTeam: {
'my-team': 'pending:my-team:1',
},
});
expect(() =>
store.getState().onProvisioningProgress({
runId: 'run-real',
teamName: 'my-team',
state: 'assembling',
message: 'Real run',
startedAt: '2026-03-12T10:00:01.000Z',
updatedAt: '2026-03-12T10:00:01.000Z',
})
).not.toThrow();
expect(store.getState().currentProvisioningRunIdByTeam['my-team']).toBe('run-real');
expect(store.getState().provisioningRuns['pending:my-team:1']).toBeUndefined();
expect(store.getState().provisioningRuns['run-real']).toEqual(
expect.objectContaining({
runId: 'run-real',
state: 'assembling',
})
);
});
it('clears orphaned runs when polling reports Unknown runId', () => {
const store = createSliceStore();
store.setState({
provisioningRuns: {
'pending:my-team:1': {
runId: 'pending:my-team:1',
teamName: 'my-team',
state: 'spawning',
message: 'Launching',
startedAt: '2026-03-12T10:00:00.000Z',
updatedAt: '2026-03-12T10:00:00.000Z',
},
},
currentProvisioningRunIdByTeam: {
'my-team': 'pending:my-team:1',
},
currentRuntimeRunIdByTeam: {
'my-team': 'pending:my-team:1',
},
memberSpawnStatusesByTeam: {
'my-team': {
alice: { status: 'spawning', updatedAt: '2026-03-12T10:00:00.000Z' },
},
},
});
store.getState().clearMissingProvisioningRun('pending:my-team:1');
expect(store.getState().provisioningRuns['pending:my-team:1']).toBeUndefined();
expect(store.getState().currentProvisioningRunIdByTeam['my-team']).toBeUndefined();
expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBeUndefined();
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBeUndefined();
expect(store.getState().ignoredProvisioningRunIds['pending:my-team:1']).toBe('my-team');
expect(store.getState().ignoredRuntimeRunIds['pending:my-team:1']).toBe('my-team');
});
it('does not resurrect a cleared missing run when late progress arrives', () => {
const store = createSliceStore();
store.setState({
provisioningRuns: {
'pending:my-team:1': {
runId: 'pending:my-team:1',
teamName: 'my-team',
state: 'spawning',
message: 'Launching',
startedAt: '2026-03-12T10:00:00.000Z',
updatedAt: '2026-03-12T10:00:00.000Z',
},
},
currentProvisioningRunIdByTeam: {
'my-team': 'pending:my-team:1',
},
});
store.getState().clearMissingProvisioningRun('pending:my-team:1');
store.getState().onProvisioningProgress({
runId: 'pending:my-team:1',
teamName: 'my-team',
state: 'assembling',
message: 'Late zombie progress',
startedAt: '2026-03-12T10:00:00.000Z',
updatedAt: '2026-03-12T10:00:02.000Z',
});
expect(store.getState().provisioningRuns['pending:my-team:1']).toBeUndefined();
expect(store.getState().currentProvisioningRunIdByTeam['my-team']).toBeUndefined();
});
it('keeps runtime run id separate from provisioning run id when fetching spawn statuses', async () => {
const store = createSliceStore();
store.setState({
currentProvisioningRunIdByTeam: {
'my-team': 'provisioning-run',
},
});
hoisted.getMemberSpawnStatuses.mockResolvedValue({
runId: 'runtime-run',
statuses: {
alice: { status: 'spawning', updatedAt: '2026-03-12T10:00:00.000Z' },
},
});
await store.getState().fetchMemberSpawnStatuses('my-team');
expect(store.getState().currentProvisioningRunIdByTeam['my-team']).toBe('provisioning-run');
expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBe('runtime-run');
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toEqual({
alice: { status: 'spawning', updatedAt: '2026-03-12T10:00:00.000Z' },
});
});
it('suppresses renderer rewrites when only lastHeartbeatAt changes', async () => {
const store = createSliceStore();
const previousSnapshot = createMemberSpawnSnapshot();
const previousStatuses = previousSnapshot.statuses;
store.setState({
currentRuntimeRunIdByTeam: {
'my-team': 'runtime-run',
},
memberSpawnStatusesByTeam: {
'my-team': previousStatuses,
},
memberSpawnSnapshotsByTeam: {
'my-team': previousSnapshot,
},
});
hoisted.getMemberSpawnStatuses.mockResolvedValue(
createMemberSpawnSnapshot({
statuses: {
alice: createMemberSpawnStatus({
lastHeartbeatAt: '2026-03-12T10:00:09.000Z',
}),
},
})
);
await store.getState().fetchMemberSpawnStatuses('my-team');
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBe(previousStatuses);
expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toBe(previousSnapshot);
});
it('suppresses renderer rewrites when only firstSpawnAcceptedAt changes', async () => {
const store = createSliceStore();
const previousSnapshot = createMemberSpawnSnapshot();
const previousStatuses = previousSnapshot.statuses;
store.setState({
currentRuntimeRunIdByTeam: {
'my-team': 'runtime-run',
},
memberSpawnStatusesByTeam: {
'my-team': previousStatuses,
},
memberSpawnSnapshotsByTeam: {
'my-team': previousSnapshot,
},
});
hoisted.getMemberSpawnStatuses.mockResolvedValue(
createMemberSpawnSnapshot({
statuses: {
alice: createMemberSpawnStatus({
firstSpawnAcceptedAt: '2026-03-12T09:59:35.000Z',
}),
},
})
);
await store.getState().fetchMemberSpawnStatuses('my-team');
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBe(previousStatuses);
expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toBe(previousSnapshot);
});
it('suppresses renderer rewrites when only updatedAt changes', async () => {
const store = createSliceStore();
const previousSnapshot = createMemberSpawnSnapshot();
const previousStatuses = previousSnapshot.statuses;
store.setState({
currentRuntimeRunIdByTeam: {
'my-team': 'runtime-run',
},
memberSpawnStatusesByTeam: {
'my-team': previousStatuses,
},
memberSpawnSnapshotsByTeam: {
'my-team': previousSnapshot,
},
});
hoisted.getMemberSpawnStatuses.mockResolvedValue(
createMemberSpawnSnapshot({
updatedAt: '2026-03-12T10:00:11.000Z',
statuses: {
alice: createMemberSpawnStatus({
updatedAt: '2026-03-12T10:00:11.000Z',
}),
},
})
);
await store.getState().fetchMemberSpawnStatuses('my-team');
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBe(previousStatuses);
expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toBe(previousSnapshot);
});
it('rewrites renderer state when runtimeAlive changes', async () => {
const store = createSliceStore();
const previousSnapshot = createMemberSpawnSnapshot({
statuses: {
alice: createMemberSpawnStatus({
launchState: 'runtime_pending_bootstrap',
livenessSource: 'process',
bootstrapConfirmed: false,
}),
},
teamLaunchState: 'partial_pending',
summary: {
confirmedCount: 0,
pendingCount: 1,
failedCount: 0,
runtimeAlivePendingCount: 1,
},
});
const previousStatuses = previousSnapshot.statuses;
store.setState({
currentRuntimeRunIdByTeam: {
'my-team': 'runtime-run',
},
memberSpawnStatusesByTeam: {
'my-team': previousStatuses,
},
memberSpawnSnapshotsByTeam: {
'my-team': previousSnapshot,
},
});
const nextSnapshot = createMemberSpawnSnapshot();
hoisted.getMemberSpawnStatuses.mockResolvedValue(nextSnapshot);
await store.getState().fetchMemberSpawnStatuses('my-team');
expect(store.getState().memberSpawnStatusesByTeam['my-team']).not.toBe(previousStatuses);
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toEqual(nextSnapshot.statuses);
expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toEqual(nextSnapshot);
});
it('rewrites renderer state when error semantics change', async () => {
const store = createSliceStore();
const previousSnapshot = createMemberSpawnSnapshot({
statuses: {
alice: createMemberSpawnStatus({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: false,
livenessSource: undefined,
bootstrapConfirmed: false,
}),
},
teamLaunchState: 'partial_pending',
summary: {
confirmedCount: 0,
pendingCount: 1,
failedCount: 0,
runtimeAlivePendingCount: 0,
},
});
const previousStatuses = previousSnapshot.statuses;
store.setState({
currentRuntimeRunIdByTeam: {
'my-team': 'runtime-run',
},
memberSpawnStatusesByTeam: {
'my-team': previousStatuses,
},
memberSpawnSnapshotsByTeam: {
'my-team': previousSnapshot,
},
});
const nextSnapshot = createMemberSpawnSnapshot({
teamLaunchState: 'partial_failure',
summary: {
confirmedCount: 0,
pendingCount: 0,
failedCount: 1,
runtimeAlivePendingCount: 0,
},
statuses: {
alice: createMemberSpawnStatus({
status: 'error',
launchState: 'failed_to_start',
error: 'bootstrap failed',
runtimeAlive: false,
livenessSource: undefined,
bootstrapConfirmed: false,
hardFailure: true,
}),
},
});
hoisted.getMemberSpawnStatuses.mockResolvedValue(nextSnapshot);
await store.getState().fetchMemberSpawnStatuses('my-team');
expect(store.getState().memberSpawnStatusesByTeam['my-team']).not.toBe(previousStatuses);
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toEqual(nextSnapshot.statuses);
expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toEqual(nextSnapshot);
});
it('rewrites renderer state when only hard failure reason changes', async () => {
const store = createSliceStore();
const previousSnapshot = createMemberSpawnSnapshot({
teamLaunchState: 'partial_failure',
summary: {
confirmedCount: 0,
pendingCount: 0,
failedCount: 1,
runtimeAlivePendingCount: 0,
},
statuses: {
alice: createMemberSpawnStatus({
status: 'error',
launchState: 'failed_to_start',
runtimeAlive: false,
livenessSource: undefined,
bootstrapConfirmed: false,
hardFailure: true,
hardFailureReason: 'initial failure',
}),
},
});
const previousStatuses = previousSnapshot.statuses;
store.setState({
currentRuntimeRunIdByTeam: {
'my-team': 'runtime-run',
},
memberSpawnStatusesByTeam: {
'my-team': previousStatuses,
},
memberSpawnSnapshotsByTeam: {
'my-team': previousSnapshot,
},
});
const nextSnapshot = createMemberSpawnSnapshot({
teamLaunchState: 'partial_failure',
summary: {
confirmedCount: 0,
pendingCount: 0,
failedCount: 1,
runtimeAlivePendingCount: 0,
},
statuses: {
alice: createMemberSpawnStatus({
status: 'error',
launchState: 'failed_to_start',
runtimeAlive: false,
livenessSource: undefined,
bootstrapConfirmed: false,
hardFailure: true,
hardFailureReason: 'resolved runtime reported missing auth',
}),
},
});
hoisted.getMemberSpawnStatuses.mockResolvedValue(nextSnapshot);
await store.getState().fetchMemberSpawnStatuses('my-team');
expect(store.getState().memberSpawnStatusesByTeam['my-team']).not.toBe(previousStatuses);
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toEqual(nextSnapshot.statuses);
expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toEqual(nextSnapshot);
});
it('rewrites renderer state when top-level launch summary changes', async () => {
const store = createSliceStore();
const previousSnapshot = createMemberSpawnSnapshot({
teamLaunchState: 'partial_pending',
summary: {
confirmedCount: 0,
pendingCount: 1,
failedCount: 0,
runtimeAlivePendingCount: 1,
},
statuses: {
alice: createMemberSpawnStatus({
launchState: 'runtime_pending_bootstrap',
livenessSource: 'process',
bootstrapConfirmed: false,
}),
},
});
const previousStatuses = previousSnapshot.statuses;
store.setState({
currentRuntimeRunIdByTeam: {
'my-team': 'runtime-run',
},
memberSpawnStatusesByTeam: {
'my-team': previousStatuses,
},
memberSpawnSnapshotsByTeam: {
'my-team': previousSnapshot,
},
});
const nextSnapshot = createMemberSpawnSnapshot({
teamLaunchState: 'clean_success',
summary: {
confirmedCount: 1,
pendingCount: 0,
failedCount: 0,
runtimeAlivePendingCount: 0,
},
});
hoisted.getMemberSpawnStatuses.mockResolvedValue(nextSnapshot);
await store.getState().fetchMemberSpawnStatuses('my-team');
expect(store.getState().memberSpawnStatusesByTeam['my-team']).not.toBe(previousStatuses);
expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toEqual(nextSnapshot);
});
it('preserves spawn snapshot references while still updating bookkeeping on suppressed snapshots', async () => {
const store = createSliceStore();
const previousSnapshot = createMemberSpawnSnapshot();
const previousStatuses = previousSnapshot.statuses;
store.setState({
ignoredRuntimeRunIds: {
'runtime-old': 'my-team',
},
memberSpawnStatusesByTeam: {
'my-team': previousStatuses,
},
memberSpawnSnapshotsByTeam: {
'my-team': previousSnapshot,
},
});
hoisted.getMemberSpawnStatuses.mockResolvedValue(
createMemberSpawnSnapshot({
statuses: {
alice: createMemberSpawnStatus({
lastHeartbeatAt: '2026-03-12T10:00:09.000Z',
}),
},
})
);
await store.getState().fetchMemberSpawnStatuses('my-team');
expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBe('runtime-run');
expect(store.getState().ignoredRuntimeRunIds['runtime-old']).toBe('my-team');
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBe(previousStatuses);
expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toBe(previousSnapshot);
});
it('does not suppress spawn snapshots when pending permission request ids change', async () => {
const store = createSliceStore();
const previousSnapshot = createMemberSpawnSnapshot({
teamLaunchState: 'partial_pending',
launchPhase: 'active',
summary: {
confirmedCount: 0,
pendingCount: 1,
failedCount: 0,
runtimeAlivePendingCount: 0,
},
statuses: {
alice: createMemberSpawnStatus({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: false,
livenessSource: undefined,
bootstrapConfirmed: false,
firstSpawnAcceptedAt: '2026-03-12T09:59:30.000Z',
lastHeartbeatAt: undefined,
}),
},
});
store.setState({
memberSpawnStatusesByTeam: {
'my-team': previousSnapshot.statuses,
},
memberSpawnSnapshotsByTeam: {
'my-team': previousSnapshot,
},
});
const nextSnapshot = createMemberSpawnSnapshot({
teamLaunchState: 'partial_pending',
launchPhase: 'active',
summary: {
confirmedCount: 0,
pendingCount: 1,
failedCount: 0,
runtimeAlivePendingCount: 0,
},
statuses: {
alice: createMemberSpawnStatus({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: false,
livenessSource: undefined,
bootstrapConfirmed: false,
firstSpawnAcceptedAt: '2026-03-12T09:59:30.000Z',
lastHeartbeatAt: undefined,
pendingPermissionRequestIds: ['perm-1'],
}),
},
});
hoisted.getMemberSpawnStatuses.mockResolvedValue(nextSnapshot);
await store.getState().fetchMemberSpawnStatuses('my-team');
expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).not.toBe(previousSnapshot);
expect(store.getState().memberSpawnStatusesByTeam['my-team']).not.toBe(
previousSnapshot.statuses
);
expect(
store.getState().memberSpawnStatusesByTeam['my-team']?.alice?.pendingPermissionRequestIds
).toEqual(['perm-1']);
});
it('ignores stale spawn-status fetches after runtime already went offline', async () => {
const store = createSliceStore();
store.setState({
currentProvisioningRunIdByTeam: {
'my-team': 'provisioning-run',
},
leadActivityByTeam: {
'my-team': 'offline',
},
});
hoisted.getMemberSpawnStatuses.mockResolvedValue({
runId: 'old-runtime-run',
statuses: {
alice: { status: 'spawning', updatedAt: '2026-03-12T10:00:00.000Z' },
},
});
await store.getState().fetchMemberSpawnStatuses('my-team');
expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBeUndefined();
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBeUndefined();
});
it('tombstones the previous runtime run and clears tool layers before creating a new run', async () => {
const store = createSliceStore();
store.setState({
currentRuntimeRunIdByTeam: {
'my-team': 'runtime-old',
},
activeToolsByTeam: {
'my-team': {
'team-lead': {
'tool-a': {
memberName: 'team-lead',
toolUseId: 'tool-a',
toolName: 'Read',
startedAt: '2026-03-12T10:00:00.000Z',
state: 'running',
source: 'runtime',
},
},
},
},
finishedVisibleByTeam: {
'my-team': {
'team-lead': {
'tool-b': {
memberName: 'team-lead',
toolUseId: 'tool-b',
toolName: 'Bash',
startedAt: '2026-03-12T10:00:01.000Z',
finishedAt: '2026-03-12T10:00:02.000Z',
state: 'complete',
source: 'runtime',
},
},
},
},
toolHistoryByTeam: {
'my-team': {
'team-lead': [
{
memberName: 'team-lead',
toolUseId: 'tool-b',
toolName: 'Bash',
startedAt: '2026-03-12T10:00:01.000Z',
finishedAt: '2026-03-12T10:00:02.000Z',
state: 'complete',
source: 'runtime',
},
],
},
},
});
await store.getState().createTeam({
teamName: 'my-team',
cwd: '/tmp/project',
members: [],
});
expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBe('run-1');
expect(store.getState().ignoredRuntimeRunIds['runtime-old']).toBe('my-team');
expect(store.getState().activeToolsByTeam['my-team']).toBeUndefined();
expect(store.getState().finishedVisibleByTeam['my-team']).toBeUndefined();
expect(store.getState().toolHistoryByTeam['my-team']).toBeUndefined();
});
it('keeps tombstoned runtime ids ignored during createTeam startup before the new run is pinned', async () => {
const store = createSliceStore();
const createDeferred = createDeferredPromise<{ runId: string }>();
hoisted.createTeam.mockImplementation(() => createDeferred.promise);
store.setState({
currentRuntimeRunIdByTeam: {
'my-team': 'runtime-live',
},
ignoredRuntimeRunIds: {
'runtime-old': 'my-team',
},
});
const createPromise = store.getState().createTeam({
teamName: 'my-team',
cwd: '/tmp/project',
members: [],
});
await Promise.resolve();
expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBeUndefined();
expect(store.getState().ignoredRuntimeRunIds['runtime-old']).toBe('my-team');
expect(store.getState().ignoredRuntimeRunIds['runtime-live']).toBe('my-team');
hoisted.getMemberSpawnStatuses.mockResolvedValue(
createMemberSpawnSnapshot({
runId: 'runtime-old',
})
);
await store.getState().fetchMemberSpawnStatuses('my-team');
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBeUndefined();
expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toBeUndefined();
createDeferred.resolve({ runId: 'run-1' });
await createPromise;
});
it('keeps older tombstoned runtime ids after canonical provisioning progress arrives', () => {
const store = createSliceStore();
store.setState({
ignoredRuntimeRunIds: {
'runtime-old': 'my-team',
},
});
store.getState().onProvisioningProgress({
runId: 'run-current',
teamName: 'my-team',
state: 'assembling',
message: 'Current run',
startedAt: '2026-03-12T10:00:00.000Z',
updatedAt: '2026-03-12T10:00:01.000Z',
});
expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBe('run-current');
expect(store.getState().ignoredRuntimeRunIds['runtime-old']).toBe('my-team');
});
it('ignores tombstoned runtime spawn-status snapshots', async () => {
const store = createSliceStore();
store.setState({
ignoredRuntimeRunIds: {
'runtime-old': 'my-team',
},
});
hoisted.getMemberSpawnStatuses.mockResolvedValue({
runId: 'runtime-old',
statuses: {
alice: { status: 'spawning', updatedAt: '2026-03-12T10:00:00.000Z' },
},
});
await store.getState().fetchMemberSpawnStatuses('my-team');
expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBeUndefined();
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBeUndefined();
});
it('preserves current spawn statuses when clearing a non-canonical missing run', () => {
const store = createSliceStore();
store.setState({
provisioningRuns: {
'run-current': {
runId: 'run-current',
teamName: 'my-team',
state: 'assembling',
message: 'Current run',
startedAt: '2026-03-12T10:00:00.000Z',
updatedAt: '2026-03-12T10:00:00.000Z',
},
'run-stale': {
runId: 'run-stale',
teamName: 'my-team',
state: 'failed',
message: 'Stale run',
startedAt: '2026-03-12T10:00:01.000Z',
updatedAt: '2026-03-12T10:00:01.000Z',
},
},
currentProvisioningRunIdByTeam: {
'my-team': 'run-current',
},
memberSpawnStatusesByTeam: {
'my-team': {
alice: { status: 'spawning', updatedAt: '2026-03-12T10:00:00.000Z' },
},
},
});
store.getState().clearMissingProvisioningRun('run-stale');
expect(store.getState().currentProvisioningRunIdByTeam['my-team']).toBe('run-current');
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toEqual({
alice: { status: 'spawning', updatedAt: '2026-03-12T10:00:00.000Z' },
});
});
it('keeps the terminal canonical run pinned and does not fall back to other team runs', () => {
const store = createSliceStore();
const startedAt = '2026-03-12T10:00:00.000Z';
store.getState().onProvisioningProgress({
runId: 'run-current',
teamName: 'my-team',
state: 'assembling',
message: 'Current run',
startedAt,
updatedAt: startedAt,
});
store.getState().onProvisioningProgress({
runId: 'run-current',
teamName: 'my-team',
state: 'disconnected',
message: 'Disconnected',
startedAt,
updatedAt: '2026-03-12T10:00:01.000Z',
});
store.setState((state: ReturnType<typeof store.getState>) => ({
provisioningRuns: {
...state.provisioningRuns,
'run-stale': {
runId: 'run-stale',
teamName: 'my-team',
state: 'failed',
message: 'Stale run',
startedAt: '2026-03-12T10:00:02.000Z',
updatedAt: '2026-03-12T10:00:02.000Z',
},
},
}));
expect(store.getState().currentProvisioningRunIdByTeam['my-team']).toBe('run-current');
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBeUndefined();
expect(getCurrentProvisioningProgressForTeam(store.getState(), 'my-team')).toEqual(
expect.objectContaining({
runId: 'run-current',
state: 'disconnected',
})
);
});
it('does not fall back to a team-wide latest run when no current run is pinned', () => {
expect(
getCurrentProvisioningProgressForTeam(
{
currentProvisioningRunIdByTeam: {},
provisioningRuns: {
'run-stale': {
runId: 'run-stale',
teamName: 'my-team',
state: 'failed',
message: 'Stale run',
startedAt: '2026-03-12T10:00:00.000Z',
updatedAt: '2026-03-12T10:00:00.000Z',
},
},
},
'my-team'
)
).toBeNull();
});
});
});