chore: merge team ipc clean architecture refactor

This commit is contained in:
777genius 2026-05-22 17:16:51 +03:00
commit f4ff278ac4
46 changed files with 5134 additions and 2066 deletions

View file

@ -97,7 +97,6 @@ import {
import { wrapAgentBlock } from '@shared/constants/agentBlocks';
import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban';
import { MAX_TEXT_LENGTH } from '@shared/constants/teamLimits';
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
import {
extractFlagsFromHelp,
extractUserFlags,
@ -111,7 +110,6 @@ import { getErrorMessage } from '@shared/utils/errorHandling';
import { isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
import { isTeamProviderBackendId, migrateProviderBackendId } from '@shared/utils/providerBackend';
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
import {
buildStandaloneSlashCommandMeta,
parseStandaloneSlashCommand,
@ -133,7 +131,6 @@ import {
import {
getAutoResumeService,
initializeAutoResumeService,
planRateLimitAutoResume,
} from '../services/team/AutoResumeService';
import {
cloneLaunchIoGovernorPayload,
@ -156,6 +153,7 @@ import {
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
import { TeamWorktreeGitService } from '../services/team/TeamWorktreeGitService';
import { teamMessageNotificationScanner } from './teams/teamMessageNotificationScanner';
import {
validateFromField,
validateMemberName,
@ -301,14 +299,6 @@ function validateTeamGetDataOptions(
};
}
/**
* In-memory set of rate-limit message keys already processed.
* Independent of NotificationManager storage survives notification deletion/pruning.
* Without this, deleted rate-limit notifications would re-appear on next getData() scan.
*/
const seenRateLimitKeys = new Set<string>();
const SEEN_RATE_LIMIT_KEYS_MAX = 500;
async function withTimeoutValue<T>(
promise: Promise<T>,
timeoutMs: number,
@ -442,178 +432,6 @@ function buildLeadDirectDelegateAckBlock(actionMode?: AgentActionMode): string |
);
}
/**
* In-memory set of API error message keys already processed.
* Independent of NotificationManager storage survives notification deletion/pruning.
*/
const seenApiErrorKeys = new Set<string>();
const SEEN_API_ERROR_KEYS_MAX = 500;
function formatNotificationClockTime(date: Date): string {
return new Intl.DateTimeFormat(undefined, {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date);
}
function buildRateLimitNotificationBody(plan: ReturnType<typeof planRateLimitAutoResume>): string {
if (plan.kind === 'scheduled') {
return `Auto-resume scheduled at ${formatNotificationClockTime(new Date(plan.fireAtMs))}`;
}
return 'Manual restart needed';
}
/**
* Check messages for rate limit indicators and fire notifications for new ones.
* Uses both in-memory seenRateLimitKeys (to prevent resurrection after deletion)
* and NotificationManager dedupeKey (to prevent storage duplicates).
*/
function checkRateLimitMessages(
messages: readonly {
messageId?: string;
from: string;
text: string;
timestamp: string;
to?: string;
source?: string;
leadSessionId?: string;
}[],
teamName: string,
teamDisplayName: string,
projectPath?: string,
teamIsAlive = true,
currentLeadSessionId: string | null = null
): void {
const observedAt = new Date();
const autoResumeEnabled =
ConfigManager.getInstance().getConfig().notifications.autoResumeOnRateLimit;
for (const msg of messages) {
if (msg.from === 'user') continue;
if (!isRateLimitMessage(msg.text)) continue;
const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
const dedupeKey = `rate-limit:${teamName}:${rawKey}`;
const isLeadAutoResumeCandidate =
!msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session');
const autoResumeSessionMatches =
msg.source !== 'lead_session' ||
(Boolean(currentLeadSessionId) && msg.leadSessionId === currentLeadSessionId);
const autoResumePlan = planRateLimitAutoResume({
enabled: autoResumeEnabled,
canAutoResume: teamIsAlive && isLeadAutoResumeCandidate && autoResumeSessionMatches,
messageText: msg.text,
observedAt,
messageTimestamp: new Date(msg.timestamp),
});
// In-memory guard: prevents resurrection after user deletes the notification.
if (!seenRateLimitKeys.has(dedupeKey)) {
seenRateLimitKeys.add(dedupeKey);
// Evict oldest entries to prevent unbounded growth
if (seenRateLimitKeys.size > SEEN_RATE_LIMIT_KEYS_MAX) {
const first = seenRateLimitKeys.values().next().value;
if (first) seenRateLimitKeys.delete(first);
}
void NotificationManager.getInstance()
.addTeamNotification({
teamEventType: 'rate_limit',
teamName,
teamDisplayName,
from: msg.from,
summary: 'Rate limit',
body: buildRateLimitNotificationBody(autoResumePlan),
dedupeKey,
target: { kind: 'member', teamName, memberName: msg.from, focus: 'logs' },
projectPath,
})
.catch(() => undefined);
}
// Only schedule auto-resume while a live team run currently exists.
// Persisted history for an offline/stopped team may still contain the old
// rate-limit message, but arming a new timer from that stale history would
// resurrect the nudge into a later manual restart.
if (autoResumePlan.kind === 'scheduled') {
// Only let persisted lead_session history rebuild auto-resume when it
// clearly belongs to the currently running lead session. Otherwise an old
// rate-limit from a previous manual run can resurrect into a newer restart.
// Pass the original message timestamp so relative reset windows survive restarts
// and old history does not rebuild a fresh auto-resume timer from "now".
getAutoResumeService().handleRateLimitMessage(
teamName,
msg.text,
observedAt,
new Date(msg.timestamp)
);
}
}
}
/**
* Check messages for API errors (e.g. "API Error: 429 ...") and fire OS notifications.
* Mirrors the rate-limit approach: in-memory dedup + NotificationManager dedupeKey.
* Skips rate-limit messages (they have their own notification path).
*/
function checkApiErrorMessages(
messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[],
teamName: string,
teamDisplayName: string,
projectPath?: string
): void {
for (const msg of messages) {
if (msg.from === 'user') continue;
if (!isApiErrorMessage(msg.text)) continue;
// Don't double-notify if it's also a rate limit message
if (isRateLimitMessage(msg.text)) continue;
const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
const dedupeKey = `api-error:${teamName}:${rawKey}`;
if (seenApiErrorKeys.has(dedupeKey)) continue;
seenApiErrorKeys.add(dedupeKey);
if (seenApiErrorKeys.size > SEEN_API_ERROR_KEYS_MAX) {
const first = seenApiErrorKeys.values().next().value;
if (first) seenApiErrorKeys.delete(first);
}
// Extract status code for summary
const statusMatch = /^API Error:\s*(\d{3})/.exec(msg.text);
const statusCode = statusMatch?.[1] ?? '???';
void NotificationManager.getInstance()
.addTeamNotification({
teamEventType: 'api_error',
teamName,
teamDisplayName,
from: msg.from,
summary: `API Error ${statusCode}`,
body: 'Manual restart needed',
dedupeKey,
target: { kind: 'member', teamName, memberName: msg.from, focus: 'logs' },
projectPath,
})
.catch(() => undefined);
}
}
function scanTeamMessageNotifications(
messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[],
teamName: string,
teamDisplayName: string,
projectPath?: string
): void {
if (messages.length === 0) {
return;
}
checkRateLimitMessages(messages, teamName, teamDisplayName, projectPath);
checkApiErrorMessages(messages, teamName, teamDisplayName, projectPath);
}
let teamDataService: TeamDataService | null = null;
let teamProvisioningService: TeamProvisioningService | null = null;
let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
@ -1145,17 +963,24 @@ async function handleGetData(
if (live.length === 0) {
if (durableMessages.length > 0) {
checkRateLimitMessages(
durableMessages,
tn,
displayName,
teamMessageNotificationScanner.checkRateLimitMessages(durableMessages, {
teamName: tn,
teamDisplayName: displayName,
projectPath,
isAlive,
currentLeadSessionId
);
checkApiErrorMessages(durableMessages, tn, displayName, projectPath);
teamIsAlive: isAlive,
currentLeadSessionId,
});
teamMessageNotificationScanner.checkApiErrorMessages(durableMessages, {
teamName: tn,
teamDisplayName: displayName,
projectPath,
});
} else {
scanTeamMessageNotifications(live, tn, displayName, projectPath);
teamMessageNotificationScanner.scan(live, {
teamName: tn,
teamDisplayName: displayName,
projectPath,
});
}
return { success: true, data: { ...data, isAlive } };
}
@ -1177,8 +1002,18 @@ async function handleGetData(
}
}
checkRateLimitMessages(merged, tn, displayName, projectPath, isAlive, currentLeadSessionId);
checkApiErrorMessages(merged, tn, displayName, projectPath);
teamMessageNotificationScanner.checkRateLimitMessages(merged, {
teamName: tn,
teamDisplayName: displayName,
projectPath,
teamIsAlive: isAlive,
currentLeadSessionId,
});
teamMessageNotificationScanner.checkApiErrorMessages(merged, {
teamName: tn,
teamDisplayName: displayName,
projectPath,
});
return { success: true, data: { ...data, isAlive } };
}
@ -2844,12 +2679,11 @@ async function handleGetMessagesPage(
.catch(() => ({ displayName: teamName }));
void notificationContextPromise
.then((notificationContext) => {
scanTeamMessageNotifications(
messagesPage.messages,
teamMessageNotificationScanner.scan(messagesPage.messages, {
teamName,
notificationContext.displayName,
notificationContext.projectPath
);
teamDisplayName: notificationContext.displayName,
projectPath: notificationContext.projectPath,
});
})
.catch((error: unknown) => {
logger.debug(

View file

@ -0,0 +1,240 @@
import { ConfigManager } from '@main/services/infrastructure/ConfigManager';
import { NotificationManager } from '@main/services/infrastructure/NotificationManager';
import {
getAutoResumeService,
planRateLimitAutoResume,
type RateLimitAutoResumePlan,
} from '@main/services/team/AutoResumeService';
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
import type { TeamNotificationPayload } from '@main/utils/teamNotificationBuilder';
export interface TeamNotificationMessage {
messageId?: string;
from: string;
text: string;
timestamp: string;
to?: string;
source?: string;
leadSessionId?: string;
}
interface TeamNotificationSink {
addTeamNotification(payload: TeamNotificationPayload): Promise<unknown>;
}
interface AutoResumeSink {
handleRateLimitMessage(
teamName: string,
messageText: string,
observedAt: Date,
messageTimestamp: Date
): void;
}
interface ConfigReader {
getConfig(): {
notifications: {
autoResumeOnRateLimit: boolean;
};
};
}
export interface TeamMessageNotificationScannerDeps {
configReader?: ConfigReader;
notificationSink?: TeamNotificationSink;
autoResumeSink?: AutoResumeSink;
planAutoResume?: typeof planRateLimitAutoResume;
isRateLimit?: (text: string) => boolean;
isApiError?: (text: string) => boolean;
now?: () => Date;
formatClockTime?: (date: Date) => string;
}
export interface TeamMessageNotificationContext {
teamName: string;
teamDisplayName: string;
projectPath?: string;
teamIsAlive?: boolean;
currentLeadSessionId?: string | null;
}
const SEEN_RATE_LIMIT_KEYS_MAX = 500;
const SEEN_API_ERROR_KEYS_MAX = 500;
function formatNotificationClockTime(date: Date): string {
return new Intl.DateTimeFormat(undefined, {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date);
}
function buildRateLimitNotificationBody(
plan: RateLimitAutoResumePlan,
formatClockTime: (date: Date) => string
): string {
if (plan.kind === 'scheduled') {
return `Auto-resume scheduled at ${formatClockTime(new Date(plan.fireAtMs))}`;
}
return 'Manual restart needed';
}
function evictOldestIfNeeded(keys: Set<string>, maxSize: number): void {
if (keys.size <= maxSize) {
return;
}
const first = keys.values().next().value;
if (first) {
keys.delete(first);
}
}
function createDefaultNotificationSink(): TeamNotificationSink {
return {
addTeamNotification: (payload) => NotificationManager.getInstance().addTeamNotification(payload),
};
}
export class TeamMessageNotificationScanner {
readonly #seenRateLimitKeys = new Set<string>();
readonly #seenApiErrorKeys = new Set<string>();
readonly #configReader: ConfigReader;
readonly #notificationSink: TeamNotificationSink;
readonly #planAutoResume: typeof planRateLimitAutoResume;
readonly #isRateLimit: (text: string) => boolean;
readonly #isApiError: (text: string) => boolean;
readonly #now: () => Date;
readonly #formatClockTime: (date: Date) => string;
readonly #autoResumeSink: AutoResumeSink | null;
constructor(deps: TeamMessageNotificationScannerDeps = {}) {
this.#configReader = deps.configReader ?? ConfigManager.getInstance();
this.#notificationSink = deps.notificationSink ?? createDefaultNotificationSink();
this.#planAutoResume = deps.planAutoResume ?? planRateLimitAutoResume;
this.#isRateLimit = deps.isRateLimit ?? isRateLimitMessage;
this.#isApiError = deps.isApiError ?? isApiErrorMessage;
this.#now = deps.now ?? (() => new Date());
this.#formatClockTime = deps.formatClockTime ?? formatNotificationClockTime;
this.#autoResumeSink = deps.autoResumeSink ?? null;
}
checkRateLimitMessages(
messages: readonly TeamNotificationMessage[],
context: TeamMessageNotificationContext
): void {
const observedAt = this.#now();
const autoResumeEnabled = this.#configReader.getConfig().notifications.autoResumeOnRateLimit;
for (const msg of messages) {
if (msg.from === 'user') continue;
if (!this.#isRateLimit(msg.text)) continue;
const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
const dedupeKey = `rate-limit:${context.teamName}:${rawKey}`;
const isLeadAutoResumeCandidate =
!msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session');
const currentLeadSessionId = context.currentLeadSessionId ?? null;
const autoResumeSessionMatches =
msg.source !== 'lead_session' ||
(Boolean(currentLeadSessionId) && msg.leadSessionId === currentLeadSessionId);
const autoResumePlan = this.#planAutoResume({
enabled: autoResumeEnabled,
canAutoResume:
(context.teamIsAlive ?? true) &&
isLeadAutoResumeCandidate &&
autoResumeSessionMatches,
messageText: msg.text,
observedAt,
messageTimestamp: new Date(msg.timestamp),
});
if (!this.#seenRateLimitKeys.has(dedupeKey)) {
this.#seenRateLimitKeys.add(dedupeKey);
evictOldestIfNeeded(this.#seenRateLimitKeys, SEEN_RATE_LIMIT_KEYS_MAX);
void this.#notificationSink
.addTeamNotification({
teamEventType: 'rate_limit',
teamName: context.teamName,
teamDisplayName: context.teamDisplayName,
from: msg.from,
summary: 'Rate limit',
body: buildRateLimitNotificationBody(autoResumePlan, this.#formatClockTime),
dedupeKey,
target: {
kind: 'member',
teamName: context.teamName,
memberName: msg.from,
focus: 'logs',
},
projectPath: context.projectPath,
})
.catch(() => undefined);
}
if (autoResumePlan.kind === 'scheduled') {
const autoResumeSink = this.#autoResumeSink ?? getAutoResumeService();
autoResumeSink.handleRateLimitMessage(
context.teamName,
msg.text,
observedAt,
new Date(msg.timestamp)
);
}
}
}
checkApiErrorMessages(
messages: readonly TeamNotificationMessage[],
context: TeamMessageNotificationContext
): void {
for (const msg of messages) {
if (msg.from === 'user') continue;
if (!this.#isApiError(msg.text)) continue;
if (this.#isRateLimit(msg.text)) continue;
const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
const dedupeKey = `api-error:${context.teamName}:${rawKey}`;
if (this.#seenApiErrorKeys.has(dedupeKey)) continue;
this.#seenApiErrorKeys.add(dedupeKey);
evictOldestIfNeeded(this.#seenApiErrorKeys, SEEN_API_ERROR_KEYS_MAX);
const statusMatch = /^API Error:\s*(\d{3})/.exec(msg.text);
const statusCode = statusMatch?.[1] ?? '???';
void this.#notificationSink
.addTeamNotification({
teamEventType: 'api_error',
teamName: context.teamName,
teamDisplayName: context.teamDisplayName,
from: msg.from,
summary: `API Error ${statusCode}`,
body: 'Manual restart needed',
dedupeKey,
target: {
kind: 'member',
teamName: context.teamName,
memberName: msg.from,
focus: 'logs',
},
projectPath: context.projectPath,
})
.catch(() => undefined);
}
}
scan(messages: readonly TeamNotificationMessage[], context: TeamMessageNotificationContext): void {
if (messages.length === 0) {
return;
}
this.checkRateLimitMessages(messages, context);
this.checkApiErrorMessages(messages, context);
}
}
export const teamMessageNotificationScanner = new TeamMessageNotificationScanner();

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,108 @@
import type {
TeamAgentRuntimeEntry,
TeamAgentRuntimeResourceSample,
TeamAgentRuntimeSnapshot,
} from '@shared/types';
function isTeamAgentRuntimeResourceSampleLike(
value: unknown
): value is TeamAgentRuntimeResourceSample {
return Boolean(value) && typeof value === 'object';
}
export function areTeamAgentRuntimeResourceSamplesEqual(left: unknown, right: unknown): boolean {
if (left === right) return true;
if (!isTeamAgentRuntimeResourceSampleLike(left) || !isTeamAgentRuntimeResourceSampleLike(right)) {
return false;
}
return (
left.timestamp === right.timestamp &&
left.cpuPercent === right.cpuPercent &&
left.rssBytes === right.rssBytes &&
left.primaryCpuPercent === right.primaryCpuPercent &&
left.primaryRssBytes === right.primaryRssBytes &&
left.childCpuPercent === right.childCpuPercent &&
left.childRssBytes === right.childRssBytes &&
left.processCount === right.processCount &&
left.runtimeLoadScope === right.runtimeLoadScope &&
left.runtimeLoadTruncated === right.runtimeLoadTruncated &&
left.pidSource === right.pidSource &&
left.pid === right.pid &&
left.runtimePid === right.runtimePid
);
}
export function areTeamAgentRuntimeEntriesEqual(
left: TeamAgentRuntimeEntry | undefined,
right: TeamAgentRuntimeEntry | undefined
): boolean {
if (left === right) return true;
if (!left || !right) return left === right;
const leftDiagnostics = Array.isArray(left.diagnostics) ? left.diagnostics : [];
const rightDiagnostics = Array.isArray(right.diagnostics) ? right.diagnostics : [];
const leftResourceHistory = Array.isArray(left.resourceHistory) ? left.resourceHistory : [];
const rightResourceHistory = Array.isArray(right.resourceHistory) ? right.resourceHistory : [];
return (
left.memberName === right.memberName &&
left.alive === right.alive &&
left.restartable === right.restartable &&
left.backendType === right.backendType &&
left.providerId === right.providerId &&
left.providerBackendId === right.providerBackendId &&
left.laneId === right.laneId &&
left.laneKind === right.laneKind &&
left.pid === right.pid &&
left.runtimeModel === right.runtimeModel &&
left.rssBytes === right.rssBytes &&
left.cpuPercent === right.cpuPercent &&
left.primaryCpuPercent === right.primaryCpuPercent &&
left.primaryRssBytes === right.primaryRssBytes &&
left.childCpuPercent === right.childCpuPercent &&
left.childRssBytes === right.childRssBytes &&
left.processCount === right.processCount &&
left.runtimeLoadScope === right.runtimeLoadScope &&
left.runtimeLoadTruncated === right.runtimeLoadTruncated &&
left.livenessKind === right.livenessKind &&
left.pidSource === right.pidSource &&
left.processCommand === right.processCommand &&
left.paneId === right.paneId &&
left.panePid === right.panePid &&
left.paneCurrentCommand === right.paneCurrentCommand &&
left.runtimePid === right.runtimePid &&
left.runtimeSessionId === right.runtimeSessionId &&
left.runtimeDiagnostic === right.runtimeDiagnostic &&
left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity &&
left.runtimeLastSeenAt === right.runtimeLastSeenAt &&
left.historicalBootstrapConfirmed === right.historicalBootstrapConfirmed &&
leftDiagnostics.length === rightDiagnostics.length &&
leftDiagnostics.every((value, index) => value === rightDiagnostics[index]) &&
leftResourceHistory.length === rightResourceHistory.length &&
leftResourceHistory.every((value, index) =>
areTeamAgentRuntimeResourceSamplesEqual(value, rightResourceHistory[index])
)
);
}
export function areTeamAgentRuntimeSnapshotsEqual(
left: TeamAgentRuntimeSnapshot | undefined,
right: TeamAgentRuntimeSnapshot
): boolean {
if (!left) return false;
if (left.teamName !== right.teamName || left.runId !== right.runId) {
return false;
}
const leftKeys = Object.keys(left.members);
const rightKeys = Object.keys(right.members);
if (leftKeys.length !== rightKeys.length) {
return false;
}
for (const key of leftKeys) {
if (!(key in right.members)) {
return false;
}
if (!areTeamAgentRuntimeEntriesEqual(left.members[key], right.members[key])) {
return false;
}
}
return true;
}

View file

@ -0,0 +1,21 @@
const lastResolvedTeamDataRefreshAtByTeam = new Map<string, number>();
export function getLastResolvedTeamDataRefreshAt(teamName: string): number | undefined {
return lastResolvedTeamDataRefreshAtByTeam.get(teamName);
}
export function recordLastResolvedTeamDataRefresh(teamName: string, resolvedAt = Date.now()): void {
lastResolvedTeamDataRefreshAtByTeam.set(teamName, resolvedAt);
}
export function hasLastResolvedTeamDataRefreshAt(teamName: string): boolean {
return lastResolvedTeamDataRefreshAtByTeam.has(teamName);
}
export function clearLastResolvedTeamDataRefreshAt(teamName: string): void {
lastResolvedTeamDataRefreshAtByTeam.delete(teamName);
}
export function clearAllLastResolvedTeamDataRefreshes(): void {
lastResolvedTeamDataRefreshAtByTeam.clear();
}

View file

@ -0,0 +1,39 @@
import type { TeamGetDataOptions } from '@shared/types';
export type TeamDataSnapshotMode = 'full' | 'thin';
export function normalizeTeamGetDataOptions(
options?: TeamGetDataOptions
): TeamGetDataOptions | undefined {
return options?.includeMemberBranches === false ? { includeMemberBranches: false } : undefined;
}
export function shouldIncludeMemberBranches(options?: TeamGetDataOptions): boolean {
return normalizeTeamGetDataOptions(options)?.includeMemberBranches !== false;
}
export function getTeamDataSnapshotMode(options?: TeamGetDataOptions): TeamDataSnapshotMode {
return shouldIncludeMemberBranches(options) ? 'full' : 'thin';
}
export function getTeamDataRequestKey(teamName: string, options?: TeamGetDataOptions): string {
const normalizedOptions = normalizeTeamGetDataOptions(options);
return `${teamName}\u0000mode:${getTeamDataSnapshotMode(normalizedOptions)}`;
}
export function getTeamDataRequestLabel(teamName: string, options?: TeamGetDataOptions): string {
const normalizedOptions = normalizeTeamGetDataOptions(options);
return `team:getData(${teamName},mode=${getTeamDataSnapshotMode(normalizedOptions)})`;
}
export function getFullTeamDataRequestKey(teamName: string): string {
return getTeamDataRequestKey(teamName);
}
export function getThinTeamDataRequestKey(teamName: string): string {
return getTeamDataRequestKey(teamName, { includeMemberBranches: false });
}
export function isTeamDataRequestKeyForTeam(requestKey: string, teamName: string): boolean {
return requestKey.startsWith(`${teamName}\u0000`);
}

View file

@ -0,0 +1,47 @@
import type { TeamViewSnapshot } from '@shared/types';
export interface TeamDataSelectorState {
teamDataCacheByName: Record<string, TeamViewSnapshot>;
selectedTeamName: string | null;
selectedTeamData: TeamViewSnapshot | null;
}
const EMPTY_TEAM_MEMBER_SNAPSHOTS: TeamViewSnapshot['members'] = [];
const EMPTY_TEAM_TASKS: TeamViewSnapshot['tasks'] = [];
export function selectTeamDataForName(
state: TeamDataSelectorState,
teamName: string | null | undefined
): TeamViewSnapshot | null {
if (!teamName) {
return null;
}
if (state.selectedTeamName === teamName && state.selectedTeamData) {
return state.selectedTeamData;
}
return (
state.teamDataCacheByName[teamName] ??
(state.selectedTeamName === teamName ? state.selectedTeamData : null)
);
}
export function selectTeamMemberSnapshotsForName(
state: TeamDataSelectorState,
teamName: string | null | undefined
): TeamViewSnapshot['members'] {
return selectTeamDataForName(state, teamName)?.members ?? EMPTY_TEAM_MEMBER_SNAPSHOTS;
}
export function selectTeamTasksForName(
state: TeamDataSelectorState,
teamName: string | null | undefined
): TeamViewSnapshot['tasks'] {
return selectTeamDataForName(state, teamName)?.tasks ?? EMPTY_TEAM_TASKS;
}
export function selectTeamIsAliveForName(
state: TeamDataSelectorState,
teamName: string | null | undefined
): boolean | undefined {
return selectTeamDataForName(state, teamName)?.isAlive;
}

View file

@ -0,0 +1,33 @@
import { IpcError } from '@renderer/utils/unwrapIpc';
function getErrorMessage(error: unknown): string {
return error instanceof IpcError ? error.message : error instanceof Error ? error.message : '';
}
export function mapSendMessageError(error: unknown): string {
const message = getErrorMessage(error);
if (message.includes('Failed to verify inbox write')) {
return 'Message was written but not verified (race). Please try again.';
}
return message || 'Failed to send message';
}
export function mapReviewError(error: unknown): string {
const message = getErrorMessage(error);
if (message.includes('Task status update verification failed')) {
return 'Failed to update task status (possible agent conflict).';
}
return message || 'Failed to perform review action';
}
export function shouldInvalidateCachedTeamDataForError(
teamName: string,
message: string
): boolean {
return (
message === 'TEAM_DRAFT' ||
message.includes('TEAM_DRAFT') ||
message === `Team not found: ${teamName}` ||
message === 'Team config not found'
);
}

View file

@ -0,0 +1,219 @@
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
import type { GraphOwnerSlotAssignment } from '@claude-teams/agent-graph';
import type { TeamMemberSnapshot, TeamViewSnapshot } from '@shared/types';
export const GRAPH_STABLE_SLOT_LAYOUT_VERSION = 'stable-slots-v1' as const;
export const DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS = true;
export type TeamGraphSlotAssignments = Record<string, GraphOwnerSlotAssignment>;
export type TeamGraphMemberSeedInput = Pick<TeamMemberSnapshot, 'name' | 'agentId' | 'removedAt'>;
export type TeamGraphConfigMemberSeedInput = Pick<
NonNullable<TeamViewSnapshot['config']['members']>[number],
'name' | 'agentId' | 'removedAt'
>;
export interface TeamGraphLayoutSessionState {
mode: 'default' | 'manual';
signature: string | null;
}
export function migrateStableSlotAssignmentsForMembers(
assignments: TeamGraphSlotAssignments | undefined,
members: readonly TeamGraphMemberSeedInput[]
): { assignments: TeamGraphSlotAssignments; changed: boolean } {
const nextAssignments: TeamGraphSlotAssignments = { ...(assignments ?? {}) };
let changed = false;
for (const member of members) {
const fallbackKey = member.name.trim();
const stableOwnerId = getStableTeamOwnerId(member);
const fallbackAssignment = nextAssignments[fallbackKey];
const stableAssignment = nextAssignments[stableOwnerId];
if (stableOwnerId !== fallbackKey && fallbackAssignment && !stableAssignment) {
nextAssignments[stableOwnerId] = fallbackAssignment;
delete nextAssignments[fallbackKey];
changed = true;
continue;
}
if (stableOwnerId !== fallbackKey && fallbackAssignment && stableAssignment) {
delete nextAssignments[fallbackKey];
changed = true;
}
}
return { assignments: nextAssignments, changed };
}
export function seedStableSlotAssignmentsForMembers(
assignments: TeamGraphSlotAssignments,
members: readonly TeamGraphMemberSeedInput[],
configMembers: readonly TeamGraphConfigMemberSeedInput[] = []
): { assignments: TeamGraphSlotAssignments; changed: boolean } {
const defaultSeed = buildTeamGraphDefaultLayoutSeed(members, configMembers);
if (
defaultSeed.orderedVisibleOwnerIds.length === 0 ||
Object.keys(defaultSeed.assignments).length === 0
) {
return { assignments, changed: false };
}
const visibleStableOwnerIds = defaultSeed.orderedVisibleOwnerIds;
const hasAnyVisibleAssignments = visibleStableOwnerIds.some(
(stableOwnerId) => assignments[stableOwnerId] != null
);
if (hasAnyVisibleAssignments) {
return { assignments, changed: false };
}
const nextAssignments: TeamGraphSlotAssignments = { ...assignments };
visibleStableOwnerIds.forEach((stableOwnerId) => {
nextAssignments[stableOwnerId] = defaultSeed.assignments[stableOwnerId]!;
});
return { assignments: nextAssignments, changed: true };
}
export function areTeamGraphSlotAssignmentsEqual(
left: TeamGraphSlotAssignments | undefined,
right: TeamGraphSlotAssignments | undefined
): boolean {
const leftEntries = Object.entries(left ?? {});
const rightEntries = Object.entries(right ?? {});
if (leftEntries.length !== rightEntries.length) {
return false;
}
for (const [stableOwnerId, leftAssignment] of leftEntries) {
const rightAssignment = right?.[stableOwnerId];
if (
rightAssignment?.ringIndex !== leftAssignment.ringIndex ||
rightAssignment.sectorIndex !== leftAssignment.sectorIndex
) {
return false;
}
}
return true;
}
export function normalizeTeamGraphSlotAssignmentsForVisibleOwners(
assignments: TeamGraphSlotAssignments | undefined,
visibleOwnerIds: readonly string[]
): TeamGraphSlotAssignments {
if (visibleOwnerIds.length === 0 || !assignments) {
return {};
}
const normalizedAssignments: TeamGraphSlotAssignments = {};
for (const stableOwnerId of visibleOwnerIds) {
const assignment = assignments[stableOwnerId];
if (!assignment) {
continue;
}
normalizedAssignments[stableOwnerId] = assignment;
}
return normalizeLegacySixRowOrbitAssignments(normalizedAssignments, visibleOwnerIds);
}
export function normalizeLegacySixRowOrbitAssignments(
assignments: TeamGraphSlotAssignments,
visibleOwnerIds: readonly string[]
): TeamGraphSlotAssignments {
if (visibleOwnerIds.length !== 6) {
return assignments;
}
const visibleAssignments = visibleOwnerIds.flatMap((stableOwnerId) => {
const assignment = assignments[stableOwnerId];
return assignment ? [assignment] : [];
});
const hasLegacyTwoRowBottomMarker = visibleAssignments.some(
(assignment) => assignment.ringIndex === 1 && assignment.sectorIndex === 2
);
let changed = false;
const normalizedAssignments: TeamGraphSlotAssignments = { ...assignments };
for (const stableOwnerId of visibleOwnerIds) {
const assignment = normalizedAssignments[stableOwnerId];
if (!assignment) {
continue;
}
if (
hasLegacyTwoRowBottomMarker &&
assignment.ringIndex === 1 &&
assignment.sectorIndex >= 0 &&
assignment.sectorIndex < 3
) {
normalizedAssignments[stableOwnerId] = {
ringIndex: 2,
sectorIndex: assignment.sectorIndex,
};
changed = true;
continue;
}
if (assignment.ringIndex === 0 && assignment.sectorIndex >= 3 && assignment.sectorIndex < 6) {
normalizedAssignments[stableOwnerId] = {
ringIndex: 2,
sectorIndex: assignment.sectorIndex - 3,
};
changed = true;
}
}
return changed ? normalizedAssignments : assignments;
}
export function pruneTeamGraphSlotAssignmentsForVisibleOwners(
assignments: TeamGraphSlotAssignments | undefined,
visibleOwnerIds: readonly string[]
): TeamGraphSlotAssignments | undefined {
const normalizedAssignments = normalizeTeamGraphSlotAssignmentsForVisibleOwners(
assignments,
visibleOwnerIds
);
return Object.keys(normalizedAssignments).length > 0 ? normalizedAssignments : undefined;
}
export function normalizeTeamGraphGridOwnerOrder(
order: readonly string[] | undefined,
visibleOwnerIds: readonly string[]
): string[] {
const visibleOwnerIdSet = new Set(visibleOwnerIds);
const normalizedOrder: string[] = [];
const seenOwnerIds = new Set<string>();
for (const stableOwnerId of order ?? []) {
if (!visibleOwnerIdSet.has(stableOwnerId) || seenOwnerIds.has(stableOwnerId)) {
continue;
}
normalizedOrder.push(stableOwnerId);
seenOwnerIds.add(stableOwnerId);
}
for (const stableOwnerId of visibleOwnerIds) {
if (seenOwnerIds.has(stableOwnerId)) {
continue;
}
normalizedOrder.push(stableOwnerId);
seenOwnerIds.add(stableOwnerId);
}
return normalizedOrder;
}
export function getDefaultTeamGraphSlotAssignmentsForMembers(
members: readonly TeamGraphMemberSeedInput[],
configMembers: readonly TeamGraphConfigMemberSeedInput[] = []
): TeamGraphSlotAssignments {
return buildTeamGraphDefaultLayoutSeed(members, configMembers).assignments;
}
export function isTeamGraphSlotPersistenceDisabled(): boolean {
return DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS;
}

View file

@ -0,0 +1,89 @@
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
import type {
EffortLevel,
TeamCreateRequest,
TeamFastMode,
TeamProviderId,
} from '@shared/types';
/** Per-team launch parameters shown in the header badge. */
export interface TeamLaunchParams {
providerId?: TeamProviderId;
providerBackendId?: string;
model?: string;
effort?: EffortLevel;
fastMode?: TeamFastMode;
limitContext?: boolean;
}
export function extractBaseModel(
raw?: string,
providerId?: TeamProviderId
): string | undefined {
return extractProviderScopedBaseModel(raw, providerId);
}
export function buildLaunchParamsFromRuntimeRequest(
request: Pick<
TeamCreateRequest,
'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode' | 'limitContext'
>,
fallback?: TeamLaunchParams
): TeamLaunchParams {
const providerId = request.providerId ?? fallback?.providerId ?? 'anthropic';
const providerChanged =
request.providerId != null &&
fallback?.providerId != null &&
request.providerId !== fallback.providerId;
const hasModel = Object.hasOwn(request, 'model');
const baseModel =
hasModel && typeof request.model === 'string'
? extractBaseModel(request.model, providerId)
: undefined;
const rawProviderBackendId = Object.hasOwn(request, 'providerBackendId')
? request.providerBackendId
: providerChanged
? undefined
: fallback?.providerBackendId;
return {
providerId,
providerBackendId: migrateProviderBackendId(providerId, rawProviderBackendId),
model: hasModel
? baseModel || 'default'
: (providerChanged ? undefined : fallback?.model) || 'default',
effort: Object.hasOwn(request, 'effort')
? request.effort
: providerChanged
? undefined
: fallback?.effort,
fastMode: Object.hasOwn(request, 'fastMode')
? request.fastMode
: providerChanged
? undefined
: fallback?.fastMode,
limitContext:
typeof request.limitContext === 'boolean'
? request.limitContext
: providerChanged
? false
: (fallback?.limitContext ?? false),
};
}
export function areTeamLaunchParamsEqual(
left: TeamLaunchParams | undefined,
right: TeamLaunchParams | undefined
): boolean {
if (left === right) return true;
if (!left || !right) return false;
return (
left.providerId === right.providerId &&
left.providerBackendId === right.providerBackendId &&
left.model === right.model &&
left.effort === right.effort &&
left.fastMode === right.fastMode &&
left.limitContext === right.limitContext
);
}

View file

@ -0,0 +1,25 @@
const teamLocalStateEpochByTeam = new Map<string, number>();
export function captureTeamLocalStateEpoch(teamName: string): number {
return teamLocalStateEpochByTeam.get(teamName) ?? 0;
}
export function isTeamLocalStateEpochCurrent(teamName: string, epoch: number): boolean {
return captureTeamLocalStateEpoch(teamName) === epoch;
}
export function invalidateTeamLocalStateEpoch(teamName: string): void {
teamLocalStateEpochByTeam.set(teamName, captureTeamLocalStateEpoch(teamName) + 1);
}
export function hasTeamLocalStateEpoch(teamName: string): boolean {
return teamLocalStateEpochByTeam.has(teamName);
}
export function clearTeamLocalStateEpoch(teamName: string): void {
teamLocalStateEpochByTeam.delete(teamName);
}
export function clearAllTeamLocalStateEpochs(): void {
teamLocalStateEpochByTeam.clear();
}

View file

@ -0,0 +1,64 @@
import { getTeamMessagesCacheEntry, type TeamMessagesCacheState } from './teamMessagesCache';
import type { MemberActivityMetaEntry, TeamMemberActivityMeta } from '@shared/types';
export interface TeamMemberActivityMetaState extends TeamMessagesCacheState {
memberActivityMetaByTeam: Record<string, TeamMemberActivityMeta>;
}
export function areMemberActivityMetaEntriesEqual(
left: MemberActivityMetaEntry | undefined,
right: MemberActivityMetaEntry
): boolean {
if (!left) {
return false;
}
return (
left.memberName === right.memberName &&
left.lastAuthoredMessageAt === right.lastAuthoredMessageAt &&
left.messageCountExact === right.messageCountExact &&
left.latestAuthoredMessageSignalsTermination === right.latestAuthoredMessageSignalsTermination
);
}
export function structurallyShareMemberActivityFacts(
previous: Record<string, MemberActivityMetaEntry> | undefined,
next: Record<string, MemberActivityMetaEntry>
): Record<string, MemberActivityMetaEntry> {
if (!previous) {
return next;
}
const nextKeys = Object.keys(next);
const previousKeys = Object.keys(previous);
let changed = nextKeys.length !== previousKeys.length;
const shared: Record<string, MemberActivityMetaEntry> = {};
for (const key of nextKeys) {
const nextEntry = next[key];
const previousEntry = previous[key];
if (!areMemberActivityMetaEntriesEqual(previousEntry, nextEntry)) {
changed = true;
shared[key] = nextEntry;
continue;
}
shared[key] = previousEntry;
}
return changed ? shared : previous;
}
export function isMemberActivityMetaStale(
state: TeamMemberActivityMetaState,
teamName: string
): boolean {
const meta = state.memberActivityMetaByTeam[teamName];
const feedRevision = getTeamMessagesCacheEntry(state, teamName).feedRevision;
if (!meta) {
return true;
}
if (!feedRevision) {
return false;
}
return meta.feedRevision !== feedRevision;
}

View file

@ -0,0 +1,106 @@
import type {
MemberSpawnStatusEntry,
MemberSpawnStatusesSnapshot,
PersistedTeamLaunchSummary,
} from '@shared/types';
export function areLaunchSummaryCountsEqual(
left: PersistedTeamLaunchSummary | undefined,
right: PersistedTeamLaunchSummary | undefined
): boolean {
if (left === right) return true;
if (!left || !right) return left === right;
return (
left.confirmedCount === right.confirmedCount &&
left.pendingCount === right.pendingCount &&
left.failedCount === right.failedCount &&
left.skippedCount === right.skippedCount &&
left.runtimeAlivePendingCount === right.runtimeAlivePendingCount &&
left.shellOnlyPendingCount === right.shellOnlyPendingCount &&
left.runtimeProcessPendingCount === right.runtimeProcessPendingCount &&
left.runtimeCandidatePendingCount === right.runtimeCandidatePendingCount &&
left.noRuntimePendingCount === right.noRuntimePendingCount &&
left.permissionPendingCount === right.permissionPendingCount
);
}
export function areExpectedMembersEqual(
left: readonly string[] | undefined,
right: readonly string[] | undefined
): boolean {
if (left === right) return true;
if (!left || !right) return left === right;
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) {
return false;
}
}
return true;
}
export function areMemberSpawnStatusEntriesEqual(
left: MemberSpawnStatusEntry | undefined,
right: MemberSpawnStatusEntry | undefined
): boolean {
if (left === right) return true;
if (!left || !right) return left === right;
const leftPendingPermissionIds = [...(left.pendingPermissionRequestIds ?? [])].sort();
const rightPendingPermissionIds = [...(right.pendingPermissionRequestIds ?? [])].sort();
// Renderer equality intentionally ignores raw timing fields that do not change
// visible member status. This suppresses heartbeat-only churn in TeamDetailView.
return (
left.status === right.status &&
left.launchState === right.launchState &&
left.error === right.error &&
left.hardFailureReason === right.hardFailureReason &&
left.skippedForLaunch === right.skippedForLaunch &&
left.skipReason === right.skipReason &&
left.skippedAt === right.skippedAt &&
left.livenessSource === right.livenessSource &&
left.runtimeAlive === right.runtimeAlive &&
left.runtimeModel === right.runtimeModel &&
left.livenessKind === right.livenessKind &&
left.runtimeDiagnostic === right.runtimeDiagnostic &&
left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity &&
left.bootstrapConfirmed === right.bootstrapConfirmed &&
left.hardFailure === right.hardFailure &&
leftPendingPermissionIds.length === rightPendingPermissionIds.length &&
leftPendingPermissionIds.every((value, index) => value === rightPendingPermissionIds[index])
);
}
export function areMemberSpawnStatusesEqual(
left: Record<string, MemberSpawnStatusEntry>,
right: Record<string, MemberSpawnStatusEntry>
): boolean {
if (left === right) return true;
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
if (leftKeys.length !== rightKeys.length) return false;
for (const key of leftKeys) {
if (!(key in right)) {
return false;
}
if (!areMemberSpawnStatusEntriesEqual(left[key], right[key])) {
return false;
}
}
return true;
}
export function areMemberSpawnSnapshotsSemanticallyEqual(
left: MemberSpawnStatusesSnapshot | undefined,
right: MemberSpawnStatusesSnapshot
): boolean {
if (!left) return false;
return (
left.runId === right.runId &&
left.teamLaunchState === right.teamLaunchState &&
left.launchPhase === right.launchPhase &&
left.source === right.source &&
areExpectedMembersEqual(left.expectedMembers, right.expectedMembers) &&
areLaunchSummaryCountsEqual(left.summary, right.summary) &&
areMemberSpawnStatusesEqual(left.statuses, right.statuses)
);
}

View file

@ -0,0 +1,39 @@
const memberSpawnStatusesIpcBackoffUntilByTeam = new Map<string, number>();
export function getMemberSpawnStatusesIpcBackoffUntil(teamName: string): number {
return memberSpawnStatusesIpcBackoffUntilByTeam.get(teamName) ?? 0;
}
export function hasMemberSpawnStatusesIpcBackoff(teamName: string): boolean {
return memberSpawnStatusesIpcBackoffUntilByTeam.has(teamName);
}
export function isMemberSpawnStatusesIpcBackoffActive(
teamName: string,
now = Date.now()
): boolean {
return getMemberSpawnStatusesIpcBackoffUntil(teamName) > now;
}
export function recordMemberSpawnStatusesIpcBackoffUntil(
teamName: string,
backoffUntil: number
): void {
memberSpawnStatusesIpcBackoffUntilByTeam.set(teamName, backoffUntil);
}
export function recordMemberSpawnStatusesIpcRetryBackoff(
teamName: string,
retryBackoffMs: number,
now = Date.now()
): void {
recordMemberSpawnStatusesIpcBackoffUntil(teamName, now + retryBackoffMs);
}
export function clearMemberSpawnStatusesIpcBackoff(teamName: string): void {
memberSpawnStatusesIpcBackoffUntilByTeam.delete(teamName);
}
export function clearAllMemberSpawnStatusesIpcBackoffs(): void {
memberSpawnStatusesIpcBackoffUntilByTeam.clear();
}

View file

@ -0,0 +1,30 @@
const memberSpawnUiEqualLastWarnAtByTeam = new Map<string, number>();
export function getMemberSpawnUiEqualLastWarnAt(teamName: string): number | undefined {
return memberSpawnUiEqualLastWarnAtByTeam.get(teamName);
}
export function hasMemberSpawnUiEqualLastWarn(teamName: string): boolean {
return memberSpawnUiEqualLastWarnAtByTeam.has(teamName);
}
export function shouldLogMemberSpawnUiEqualSuppressed(
teamName: string,
throttleMs: number,
now = Date.now()
): boolean {
const lastWarnAt = memberSpawnUiEqualLastWarnAtByTeam.get(teamName) ?? 0;
if (now - lastWarnAt < throttleMs) {
return false;
}
memberSpawnUiEqualLastWarnAtByTeam.set(teamName, now);
return true;
}
export function clearMemberSpawnUiEqualLastWarn(teamName: string): void {
memberSpawnUiEqualLastWarnAtByTeam.delete(teamName);
}
export function clearAllMemberSpawnUiEqualLastWarns(): void {
memberSpawnUiEqualLastWarnAtByTeam.clear();
}

View file

@ -0,0 +1,291 @@
import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import type { InboxMessage } from '@shared/types';
export interface TeamMessagesCacheEntry {
canonicalMessages: InboxMessage[];
optimisticMessages: InboxMessage[];
feedRevision: string | null;
nextCursor: string | null;
hasMore: boolean;
lastFetchedAt: number | null;
loadingHead: boolean;
loadingOlder: boolean;
headHydrated: boolean;
}
export interface RefreshTeamMessagesHeadResult {
feedChanged: boolean;
headChanged: boolean;
feedRevision: string | null;
}
export interface TeamMessagesCacheState {
teamMessagesByName: Record<string, TeamMessagesCacheEntry>;
}
export interface TeamMessageSelectorCacheSnapshot {
hasMergedMessagesSelector: boolean;
memberMessagesSelectorCount: number;
}
export const EMPTY_TEAM_MESSAGES_CACHE_ENTRY: TeamMessagesCacheEntry = {
canonicalMessages: [],
optimisticMessages: [],
feedRevision: null,
nextCursor: null,
hasMore: false,
lastFetchedAt: null,
loadingHead: false,
loadingOlder: false,
headHydrated: false,
};
const mergedMessagesSelectorCache = new Map<
string,
{
canonicalRef: readonly InboxMessage[];
optimisticRef: readonly InboxMessage[];
result: InboxMessage[];
}
>();
const memberMessagesSelectorCache = new Map<
string,
{
messagesRef: readonly InboxMessage[];
result: InboxMessage[];
}
>();
export function clearTeamMessageSelectorCaches(): void {
mergedMessagesSelectorCache.clear();
memberMessagesSelectorCache.clear();
}
export function clearTeamMessageSelectorCachesForTeam(teamName: string): void {
mergedMessagesSelectorCache.delete(teamName);
const teamScopedPrefix = `${teamName}:`;
for (const key of memberMessagesSelectorCache.keys()) {
if (key.startsWith(teamScopedPrefix)) {
memberMessagesSelectorCache.delete(key);
}
}
}
export function getTeamMessageSelectorCacheSnapshotForTeam(
teamName: string
): TeamMessageSelectorCacheSnapshot {
const teamScopedPrefix = `${teamName}:`;
let memberMessagesSelectorCount = 0;
for (const key of memberMessagesSelectorCache.keys()) {
if (key.startsWith(teamScopedPrefix)) {
memberMessagesSelectorCount += 1;
}
}
return {
hasMergedMessagesSelector: mergedMessagesSelectorCache.has(teamName),
memberMessagesSelectorCount,
};
}
export function compareInboxMessagesByTimestamp(a: InboxMessage, b: InboxMessage): number {
const aTime = Date.parse(a.timestamp);
const bTime = Date.parse(b.timestamp);
const aValid = Number.isFinite(aTime);
const bValid = Number.isFinite(bTime);
if (aValid && bValid && aTime !== bTime) {
return aTime - bTime;
}
if (aValid !== bValid) {
return aValid ? -1 : 1;
}
const aId = typeof a.messageId === 'string' ? a.messageId : '';
const bId = typeof b.messageId === 'string' ? b.messageId : '';
return aId.localeCompare(bId);
}
export function getTeamMessagesCacheEntry(
state: TeamMessagesCacheState,
teamName: string
): TeamMessagesCacheEntry {
return state.teamMessagesByName[teamName] ?? EMPTY_TEAM_MESSAGES_CACHE_ENTRY;
}
export function upsertOptimisticTeamMessage(
entry: TeamMessagesCacheEntry,
message: InboxMessage
): TeamMessagesCacheEntry {
const nextOptimistic = [...entry.optimisticMessages];
const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
if (messageId.length > 0) {
const existingIndex = nextOptimistic.findIndex(
(candidate) =>
typeof candidate.messageId === 'string' && candidate.messageId.trim() === messageId
);
if (existingIndex >= 0) {
nextOptimistic[existingIndex] = {
...nextOptimistic[existingIndex],
...message,
};
} else {
nextOptimistic.push(message);
}
} else {
nextOptimistic.push(message);
}
nextOptimistic.sort(compareInboxMessagesByTimestamp);
return {
...entry,
optimisticMessages: nextOptimistic,
};
}
export function areInboxMessageArraysEquivalent(
left: readonly InboxMessage[],
right: readonly InboxMessage[]
): boolean {
if (left === right) return true;
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
const leftItem = left[index];
const rightItem = right[index];
if (
leftItem.messageId !== rightItem.messageId ||
leftItem.timestamp !== rightItem.timestamp ||
leftItem.from !== rightItem.from ||
leftItem.to !== rightItem.to ||
leftItem.text !== rightItem.text ||
leftItem.summary !== rightItem.summary ||
leftItem.read !== rightItem.read ||
leftItem.actionMode !== rightItem.actionMode ||
leftItem.commentId !== rightItem.commentId ||
leftItem.relayOfMessageId !== rightItem.relayOfMessageId ||
leftItem.source !== rightItem.source ||
leftItem.leadSessionId !== rightItem.leadSessionId ||
leftItem.messageKind !== rightItem.messageKind ||
JSON.stringify(leftItem.taskRefs ?? null) !== JSON.stringify(rightItem.taskRefs ?? null)
) {
return false;
}
}
return true;
}
export function pruneOptimisticMessages(
optimistic: readonly InboxMessage[],
canonical: readonly InboxMessage[]
): InboxMessage[] {
if (optimistic.length === 0) {
return [];
}
const canonicalIds = new Set(
canonical
.map((message) => (typeof message.messageId === 'string' ? message.messageId.trim() : ''))
.filter((messageId) => messageId.length > 0)
);
return optimistic.filter((message) => {
const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
return !messageId || !canonicalIds.has(messageId);
});
}
export function getCanonicalHeadSlice(
canonicalMessages: readonly InboxMessage[],
headLength: number
): readonly InboxMessage[] {
if (headLength <= 0) {
return [];
}
return canonicalMessages.slice(0, headLength);
}
export function extractRetainedCanonicalOlderTail(
canonicalMessages: readonly InboxMessage[],
freshHeadMessages: readonly InboxMessage[]
): InboxMessage[] | null {
if (canonicalMessages.length === 0) {
return [];
}
if (freshHeadMessages.length === 0) {
return null;
}
const freshHeadKeys = new Set(freshHeadMessages.map((message) => toMessageKey(message)));
let hasMessagesOutsideFreshHead = false;
for (const message of canonicalMessages) {
if (!freshHeadKeys.has(toMessageKey(message))) {
hasMessagesOutsideFreshHead = true;
break;
}
}
if (!hasMessagesOutsideFreshHead) {
return [];
}
const anchorKey = toMessageKey(freshHeadMessages[freshHeadMessages.length - 1]);
const anchorIndex = canonicalMessages.findIndex((message) => toMessageKey(message) === anchorKey);
if (anchorIndex < 0) {
return null;
}
return canonicalMessages
.slice(anchorIndex + 1)
.filter((message) => !freshHeadKeys.has(toMessageKey(message)));
}
export function selectTeamMessages(
state: TeamMessagesCacheState,
teamName: string | null | undefined
): InboxMessage[] {
if (!teamName) {
return [];
}
const entry = getTeamMessagesCacheEntry(state, teamName);
const cached = mergedMessagesSelectorCache.get(teamName);
if (
cached?.canonicalRef === entry.canonicalMessages &&
cached.optimisticRef === entry.optimisticMessages
) {
return cached.result;
}
const result = mergeTeamMessages(entry.canonicalMessages, entry.optimisticMessages);
mergedMessagesSelectorCache.set(teamName, {
canonicalRef: entry.canonicalMessages,
optimisticRef: entry.optimisticMessages,
result,
});
return result;
}
export function selectMemberMessagesForTeamMember(
state: TeamMessagesCacheState,
teamName: string | null | undefined,
memberName: string | null | undefined
): InboxMessage[] {
if (!teamName || !memberName) {
return [];
}
const messages = selectTeamMessages(state, teamName);
const cacheKey = `${teamName}:${memberName}`;
const cached = memberMessagesSelectorCache.get(cacheKey);
if (cached?.messagesRef === messages) {
return cached.result;
}
const result = messages.filter(
(message) => message.from === memberName || message.to === memberName
);
memberMessagesSelectorCache.set(cacheKey, {
messagesRef: messages,
result,
});
return result;
}

View file

@ -0,0 +1,29 @@
import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode';
const MESSAGES_PANEL_MODE_STORAGE_KEY = 'team:messagesPanelMode';
const DEFAULT_MESSAGES_PANEL_MODE: TeamMessagesPanelMode = 'sidebar';
const VALID_MESSAGES_PANEL_MODES: ReadonlySet<TeamMessagesPanelMode> = new Set([
'sidebar',
'inline',
'bottom-sheet',
'floating-composer',
]);
export function loadPersistedMessagesPanelMode(): TeamMessagesPanelMode {
try {
const persisted = localStorage.getItem(MESSAGES_PANEL_MODE_STORAGE_KEY);
return VALID_MESSAGES_PANEL_MODES.has(persisted as TeamMessagesPanelMode)
? (persisted as TeamMessagesPanelMode)
: DEFAULT_MESSAGES_PANEL_MODE;
} catch {
return DEFAULT_MESSAGES_PANEL_MODE;
}
}
export function savePersistedMessagesPanelMode(mode: TeamMessagesPanelMode): void {
try {
localStorage.setItem(MESSAGES_PANEL_MODE_STORAGE_KEY, mode);
} catch {
// ignore - best-effort UI preference persistence
}
}

View file

@ -0,0 +1,45 @@
const activeTeamPendingReplyWaitSourceIdsByTeam = new Map<string, Set<string>>();
export function hasActiveTeamPendingReplyWait(teamName: string): boolean {
return (activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName)?.size ?? 0) > 0;
}
export function getActiveTeamPendingReplyWaits(): Set<string> {
return new Set(
Array.from(activeTeamPendingReplyWaitSourceIdsByTeam.entries())
.filter(([, sourceIds]) => sourceIds.size > 0)
.map(([teamName]) => teamName)
);
}
export function clearAllPendingReplyRefreshWaits(): void {
activeTeamPendingReplyWaitSourceIdsByTeam.clear();
}
export function clearPendingReplyRefreshWaits(teamName: string): void {
activeTeamPendingReplyWaitSourceIdsByTeam.delete(teamName);
}
export function setPendingReplyRefreshEnabled(
teamName: string,
sourceId: string,
enabled: boolean
): boolean {
if (enabled) {
const existing = activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName) ?? new Set<string>();
existing.add(sourceId);
activeTeamPendingReplyWaitSourceIdsByTeam.set(teamName, existing);
return true;
}
const existing = activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName);
if (!existing) {
return false;
}
existing.delete(sourceId);
if (existing.size === 0) {
activeTeamPendingReplyWaitSourceIdsByTeam.delete(teamName);
return false;
}
return true;
}

View file

@ -0,0 +1,44 @@
import type { TeamProvisioningProgress } from '@shared/types';
type TeamProvisioningProgressState = TeamProvisioningProgress['state'];
const ACTIVE_PROVISIONING_STATES: ReadonlySet<TeamProvisioningProgressState> = new Set([
'validating',
'spawning',
'configuring',
'assembling',
'finalizing',
'verifying',
]);
const TERMINAL_PROVISIONING_STATES: ReadonlySet<TeamProvisioningProgressState> = new Set([
'ready',
'failed',
'disconnected',
'cancelled',
]);
export function isActiveProvisioningState(state: TeamProvisioningProgressState): boolean {
return ACTIVE_PROVISIONING_STATES.has(state);
}
export function isTerminalProvisioningState(state: TeamProvisioningProgressState): boolean {
return TERMINAL_PROVISIONING_STATES.has(state);
}
export function shouldIgnoreProvisioningProgressRegression(
currentState: TeamProvisioningProgressState,
nextState: TeamProvisioningProgressState
): boolean {
if (currentState === 'ready') {
return nextState !== 'ready' && nextState !== 'disconnected';
}
if (
currentState === 'failed' ||
currentState === 'cancelled' ||
currentState === 'disconnected'
) {
return nextState !== currentState;
}
return false;
}

View file

@ -0,0 +1,48 @@
interface TeamRefreshBurstDiagnostic {
windowStartedAt: number;
count: number;
lastWarnAt: number;
}
const teamRefreshBurstDiagnostics = new Map<string, TeamRefreshBurstDiagnostic>();
export function hasTeamRefreshBurstDiagnostics(teamName: string): boolean {
return teamRefreshBurstDiagnostics.has(teamName);
}
export function getTeamRefreshBurstDiagnosticForTests(
teamName: string
): TeamRefreshBurstDiagnostic | undefined {
const diagnostic = teamRefreshBurstDiagnostics.get(teamName);
return diagnostic ? { ...diagnostic } : undefined;
}
export function noteTeamRefreshBurst(
teamName: string,
windowMs: number,
now = Date.now()
): number {
const diagnostic = teamRefreshBurstDiagnostics.get(teamName) ?? {
windowStartedAt: now,
count: 0,
lastWarnAt: 0,
};
if (now - diagnostic.windowStartedAt > windowMs) {
diagnostic.windowStartedAt = now;
diagnostic.count = 0;
}
diagnostic.count += 1;
teamRefreshBurstDiagnostics.set(teamName, diagnostic);
return diagnostic.count;
}
export function clearTeamRefreshBurstDiagnostics(teamName: string): void {
teamRefreshBurstDiagnostics.delete(teamName);
}
export function clearAllTeamRefreshBurstDiagnostics(): void {
teamRefreshBurstDiagnostics.clear();
}

View file

@ -0,0 +1,533 @@
import { getMemberColorByName } from '@shared/constants/memberColors';
import { isLeadMember } from '@shared/utils/leadDetection';
import {
getTeamTaskWorkflowColumn,
isTeamTaskFinalForCompletionNotification,
} from '@shared/utils/teamTaskState';
import { selectTeamDataForName, type TeamDataSelectorState } from './teamDataSelectors';
import type {
MemberActivityMetaEntry,
ResolvedTeamMember,
TeamMemberActivityMeta,
TeamMemberSnapshot,
TeamSummary,
TeamViewSnapshot,
} from '@shared/types';
export interface ResolvedMemberSelectorState extends TeamDataSelectorState {
memberActivityMetaByTeam: Record<string, TeamMemberActivityMeta>;
teamByName?: Record<string, TeamSummary>;
}
export interface ResolvedMemberSelectorCacheSnapshot {
hasResolvedMembersSelector: boolean;
resolvedMemberSelectorCount: number;
}
const resolvedMembersSelectorCache = new Map<
string,
{
snapshotRef: TeamViewSnapshot['members'];
configMembersRef: TeamViewSnapshot['config']['members'] | undefined;
summaryRef: TeamSummary | undefined;
tasksRef: TeamViewSnapshot['tasks'] | undefined;
metaMembersRef: TeamMemberActivityMeta['members'] | undefined;
result: ResolvedTeamMember[];
}
>();
const resolvedMemberSelectorCache = new Map<
string,
{
snapshotMemberRef: TeamMemberSnapshot | undefined;
metaEntryRef: MemberActivityMetaEntry | undefined;
result: ResolvedTeamMember | null;
}
>();
export function clearResolvedMemberSelectorCaches(): void {
resolvedMembersSelectorCache.clear();
resolvedMemberSelectorCache.clear();
}
export function clearResolvedMemberSelectorCachesForTeam(teamName: string): void {
resolvedMembersSelectorCache.delete(teamName);
const teamScopedPrefix = `${teamName}:`;
for (const key of resolvedMemberSelectorCache.keys()) {
if (key.startsWith(teamScopedPrefix)) {
resolvedMemberSelectorCache.delete(key);
}
}
}
export function getResolvedMemberSelectorCacheSnapshotForTeam(
teamName: string
): ResolvedMemberSelectorCacheSnapshot {
const teamScopedPrefix = `${teamName}:`;
let resolvedMemberSelectorCount = 0;
for (const key of resolvedMemberSelectorCache.keys()) {
if (key.startsWith(teamScopedPrefix)) {
resolvedMemberSelectorCount += 1;
}
}
return {
hasResolvedMembersSelector: resolvedMembersSelectorCache.has(teamName),
resolvedMemberSelectorCount,
};
}
function resolveMemberStatus(
snapshot: TeamMemberSnapshot,
activity: MemberActivityMetaEntry | undefined
): ResolvedTeamMember['status'] {
if (activity?.latestAuthoredMessageSignalsTermination) {
return 'terminated';
}
if (!activity?.lastAuthoredMessageAt) {
return snapshot.currentTaskId ? 'active' : 'idle';
}
const ageMs = Date.now() - Date.parse(activity.lastAuthoredMessageAt);
if (Number.isNaN(ageMs)) {
return 'unknown';
}
if (ageMs < 5 * 60 * 1000) {
return 'active';
}
return 'idle';
}
function buildResolvedMembers(
snapshots: readonly TeamMemberSnapshot[],
meta: TeamMemberActivityMeta | undefined
): ResolvedTeamMember[] {
return snapshots.map((member) => buildResolvedMember(member, meta?.members[member.name]));
}
function isDisplayableFallbackCurrentTask(task: TeamViewSnapshot['tasks'][number]): boolean {
return (
task.status === 'in_progress' &&
getTeamTaskWorkflowColumn(task) !== 'review' &&
!isTeamTaskFinalForCompletionNotification(task)
);
}
function buildConfigFallbackMemberSnapshots(snapshot: TeamViewSnapshot): TeamMemberSnapshot[] {
const configMembers = snapshot.config.members ?? [];
const hasConfiguredTeammate = configMembers.some((member) => {
const name = member.name?.trim();
return Boolean(name) && !member.removedAt && !isLeadMember(member);
});
if (!hasConfiguredTeammate) {
return [];
}
const seenNames = new Set<string>();
const fallbackMembers: TeamMemberSnapshot[] = [];
for (const member of configMembers) {
const name = member.name?.trim();
if (!name) continue;
const key = name.toLowerCase();
if (seenNames.has(key)) continue;
seenNames.add(key);
const ownedTasks = snapshot.tasks.filter((task) => task.owner === name);
const currentTask = ownedTasks.find(isDisplayableFallbackCurrentTask);
fallbackMembers.push({
name,
agentId: member.agentId,
currentTaskId: currentTask?.id ?? null,
taskCount: ownedTasks.length,
color: member.color ?? getMemberColorByName(name),
agentType: member.agentType,
role: member.role,
workflow: member.workflow,
isolation: member.isolation,
providerId: member.providerId,
providerBackendId: member.providerBackendId,
model: member.model,
effort: member.effort,
mcpPolicy: member.mcpPolicy,
selectedFastMode: member.fastMode,
cwd: member.cwd,
removedAt: member.removedAt,
});
}
return fallbackMembers;
}
function getActiveRawTeammateNameKeys(snapshot: TeamViewSnapshot | null | undefined): string[] {
if (!snapshot) {
return [];
}
const names = new Set<string>();
for (const member of snapshot.members) {
const name = member.name.trim();
const key = name.toLowerCase();
if (!name || key === 'user' || member.removedAt || isLeadMember(member)) {
continue;
}
names.add(key);
}
return Array.from(names).sort((left, right) => left.localeCompare(right));
}
function hasActiveRawTeammateRoster(snapshot: TeamViewSnapshot | null | undefined): boolean {
return getActiveRawTeammateNameKeys(snapshot).length > 0;
}
function hasRemovedRawMemberRoster(snapshot: TeamViewSnapshot | null | undefined): boolean {
return Boolean(snapshot?.members.some((member) => member.removedAt));
}
function hasConfigTeammateRoster(snapshot: TeamViewSnapshot | null | undefined): boolean {
return Boolean(
snapshot?.config.members?.some((member) => {
const name = member.name?.trim();
return Boolean(name) && !member.removedAt && !isLeadMember(member);
})
);
}
interface SummaryFallbackMemberSource {
name: string;
agentId?: string;
role?: string;
color?: string;
mcpPolicy?: TeamMemberSnapshot['mcpPolicy'];
}
function normalizeSummaryTeammateName(
name: string | undefined | null,
leadName?: string
): string | null {
const trimmed = name?.trim();
const normalizedName = trimmed?.toLowerCase();
const normalizedLeadName = leadName?.trim().toLowerCase();
if (
!trimmed ||
normalizedName === 'user' ||
isLeadMember({ name: trimmed }) ||
(normalizedLeadName && normalizedName === normalizedLeadName)
) {
return null;
}
return trimmed;
}
function getSummaryRosterTeammateSources(summary: TeamSummary): SummaryFallbackMemberSource[] {
const seenNames = new Set<string>();
const sources: SummaryFallbackMemberSource[] = [];
for (const member of summary.members ?? []) {
const name = normalizeSummaryTeammateName(member.name, summary.leadName);
if (!name) {
continue;
}
const key = name.toLowerCase();
if (seenNames.has(key)) {
continue;
}
seenNames.add(key);
sources.push({
name,
agentId: member.agentId,
role: member.role,
color: member.color,
mcpPolicy: member.mcpPolicy,
});
}
return sources;
}
function shouldUseSummaryLaunchTeammateSources(summary: TeamSummary): boolean {
return (
summary.partialLaunchFailure === true ||
summary.teamLaunchState === 'partial_failure' ||
summary.teamLaunchState === 'partial_pending' ||
summary.teamLaunchState === 'partial_skipped'
);
}
function getSummaryLaunchTeammateSources(summary: TeamSummary): SummaryFallbackMemberSource[] {
if (!shouldUseSummaryLaunchTeammateSources(summary)) {
return [];
}
const seenNames = new Set<string>();
const sources: SummaryFallbackMemberSource[] = [];
for (const rawName of [...(summary.missingMembers ?? []), ...(summary.skippedMembers ?? [])]) {
const name = normalizeSummaryTeammateName(rawName, summary.leadName);
if (!name) {
continue;
}
const key = name.toLowerCase();
if (seenNames.has(key)) {
continue;
}
seenNames.add(key);
sources.push({ name });
}
return sources;
}
function getSummaryLaunchTeammateNameKeys(summary: TeamSummary): string[] {
return getSummaryLaunchTeammateSources(summary)
.map((member) => member.name.toLowerCase())
.sort((left, right) => left.localeCompare(right));
}
function getSummaryTeammateNameKeys(summary: TeamSummary): string[] {
const rosterNames = getSummaryRosterTeammateSources(summary)
.map((member) => member.name.toLowerCase())
.sort((left, right) => left.localeCompare(right));
if (rosterNames.length > 0) {
return rosterNames;
}
const launchNames = getSummaryLaunchTeammateNameKeys(summary);
const expectedCount = summary.expectedMemberCount ?? summary.memberCount;
if (expectedCount > 0 && launchNames.length === expectedCount) {
return launchNames;
}
return [];
}
function getSummaryFallbackTeammateSources(summary: TeamSummary): SummaryFallbackMemberSource[] {
return getSummaryRosterTeammateSources(summary);
}
function areNameKeyListsEqual(left: readonly string[], right: readonly string[]): boolean {
return left.length === right.length && left.every((name, index) => name === right[index]);
}
function summaryConfirmsActiveTeammateRoster(
current: TeamViewSnapshot,
summary: TeamSummary
): boolean {
if ((summary.expectedMemberCount ?? summary.memberCount) <= 0) {
return false;
}
const currentNames = getActiveRawTeammateNameKeys(current);
const summaryNames = getSummaryTeammateNameKeys(summary);
if (summaryNames.length === 0 || summaryNames.length !== currentNames.length) {
return false;
}
return areNameKeyListsEqual(summaryNames, currentNames);
}
function buildSummaryFallbackMemberSnapshots(
snapshot: TeamViewSnapshot,
summary: TeamSummary | undefined
): TeamMemberSnapshot[] {
if (!summary) {
return [];
}
const summaryMembers = getSummaryFallbackTeammateSources(summary);
if (summaryMembers.length === 0) {
return [];
}
const seenNames = new Set<string>();
const buildSnapshot = (
name: string,
source?: Omit<SummaryFallbackMemberSource, 'name'>,
lead = false
): TeamMemberSnapshot | null => {
const trimmed = name.trim();
if (!trimmed) return null;
const key = trimmed.toLowerCase();
if (seenNames.has(key)) return null;
seenNames.add(key);
const ownedTasks = snapshot.tasks.filter((task) => task.owner === trimmed);
const currentTask = ownedTasks.find(isDisplayableFallbackCurrentTask);
return {
name: trimmed,
agentId: source?.agentId,
currentTaskId: currentTask?.id ?? null,
taskCount: ownedTasks.length,
color: source?.color ?? getMemberColorByName(trimmed),
agentType: lead ? 'team-lead' : undefined,
role: source?.role ?? (lead ? 'Team Lead' : undefined),
mcpPolicy: source?.mcpPolicy,
};
};
const teammates = summaryMembers.flatMap((member) => {
const item = buildSnapshot(member.name, member);
return item ? [item] : [];
});
if (teammates.length === 0) {
return [];
}
const existingLead = snapshot.members.find((member) => !member.removedAt && isLeadMember(member));
if (existingLead) {
return [existingLead, ...teammates];
}
const configuredLead = snapshot.config.members?.find(
(member) => !member.removedAt && isLeadMember(member)
);
const leadName = configuredLead?.name?.trim() || summary.leadName?.trim();
const lead = leadName
? buildSnapshot(
leadName,
{
agentId: configuredLead?.agentId,
role: configuredLead?.role,
color: configuredLead?.color ?? summary.leadColor,
},
true
)
: null;
return lead ? [lead, ...teammates] : teammates;
}
function getResolvableMemberSnapshots(
snapshot: TeamViewSnapshot,
summary?: TeamSummary
): readonly TeamMemberSnapshot[] {
if (
snapshot.members.length > 0 &&
(hasActiveRawTeammateRoster(snapshot) || hasRemovedRawMemberRoster(snapshot))
) {
return snapshot.members;
}
const configFallbackMembers = buildConfigFallbackMemberSnapshots(snapshot);
if (configFallbackMembers.length > 0) {
return configFallbackMembers;
}
const summaryFallbackMembers = buildSummaryFallbackMemberSnapshots(snapshot, summary);
if (summaryFallbackMembers.length > 0) {
return summaryFallbackMembers;
}
return snapshot.members;
}
export function shouldPreserveSelectedTeamSnapshot(
current: TeamViewSnapshot | null,
baseline: TeamViewSnapshot | null | undefined,
incoming: TeamViewSnapshot,
summary: TeamSummary | undefined
): boolean {
if (!current || !hasActiveRawTeammateRoster(current)) {
return false;
}
if (
hasActiveRawTeammateRoster(incoming) ||
hasRemovedRawMemberRoster(incoming) ||
hasConfigTeammateRoster(incoming)
) {
return false;
}
const currentNames = getActiveRawTeammateNameKeys(current);
if (
current !== baseline &&
!areNameKeyListsEqual(currentNames, getActiveRawTeammateNameKeys(baseline))
) {
return true;
}
if (summary) {
return summaryConfirmsActiveTeammateRoster(current, summary);
}
return false;
}
function buildResolvedMember(
snapshot: TeamMemberSnapshot,
activity: MemberActivityMetaEntry | undefined
): ResolvedTeamMember {
return {
...snapshot,
status: resolveMemberStatus(snapshot, activity),
messageCount: activity?.messageCountExact ?? 0,
lastActiveAt: activity?.lastAuthoredMessageAt ?? null,
};
}
export function selectResolvedMembersForTeamName(
state: ResolvedMemberSelectorState,
teamName: string | null | undefined
): ResolvedTeamMember[] {
const snapshot = selectTeamDataForName(state, teamName);
if (!snapshot || !teamName) {
return [];
}
const meta = state.memberActivityMetaByTeam[teamName];
const metaMembers = meta?.members;
const shouldUseMemberFallback =
snapshot.members.length === 0 ||
(!hasActiveRawTeammateRoster(snapshot) && !hasRemovedRawMemberRoster(snapshot));
const configMembersRef = shouldUseMemberFallback ? snapshot.config.members : undefined;
const summaryRef = shouldUseMemberFallback ? state.teamByName?.[teamName] : undefined;
const tasksRef = shouldUseMemberFallback ? snapshot.tasks : undefined;
const cached = resolvedMembersSelectorCache.get(teamName);
if (
cached?.snapshotRef === snapshot.members &&
cached.configMembersRef === configMembersRef &&
cached.summaryRef === summaryRef &&
cached.tasksRef === tasksRef &&
cached.metaMembersRef === metaMembers
) {
return cached.result;
}
const result = buildResolvedMembers(getResolvableMemberSnapshots(snapshot, summaryRef), meta);
resolvedMembersSelectorCache.set(teamName, {
snapshotRef: snapshot.members,
configMembersRef,
summaryRef,
tasksRef,
metaMembersRef: metaMembers,
result,
});
return result;
}
export function selectResolvedMemberForTeamName(
state: ResolvedMemberSelectorState,
teamName: string | null | undefined,
memberName: string | null | undefined
): ResolvedTeamMember | null {
const snapshot = selectTeamDataForName(state, teamName);
if (!snapshot || !teamName || !memberName) {
return null;
}
const snapshotMember = getResolvableMemberSnapshots(snapshot, state.teamByName?.[teamName]).find(
(member) => member.name === memberName
);
if (!snapshotMember) {
return null;
}
const metaEntry = state.memberActivityMetaByTeam[teamName]?.members[memberName];
const cacheKey = `${teamName}:${memberName}`;
const cached = resolvedMemberSelectorCache.get(cacheKey);
if (cached?.snapshotMemberRef === snapshotMember && cached.metaEntryRef === metaEntry) {
return cached.result;
}
const result = buildResolvedMember(snapshotMember, metaEntry);
resolvedMemberSelectorCache.set(cacheKey, {
snapshotMemberRef: snapshotMember,
metaEntryRef: metaEntry,
result,
});
return result;
}

View file

@ -0,0 +1,190 @@
interface TeamMessagesLoadingEntry {
loadingHead: boolean;
loadingOlder: boolean;
}
interface TeamScopedVisibleLoadingResetState<
TTeamMessagesEntry extends TeamMessagesLoadingEntry,
> {
teamMessagesByName: Record<string, TTeamMessagesEntry>;
selectedTeamName: string | null;
selectedTeamLoading: boolean;
selectedTeamError: string | null;
}
interface TeamScopedProvisioningRun {
teamName: string;
}
type TeamScopedRecord = Record<string, unknown>;
interface TeamScopedStateRemovalState<
TProvisioningRun extends TeamScopedProvisioningRun = TeamScopedProvisioningRun,
> {
provisioningRuns: Record<string, TProvisioningRun>;
teamDataCacheByName: TeamScopedRecord;
teamAgentRuntimeByTeam: TeamScopedRecord;
teamMessagesByName: TeamScopedRecord;
memberActivityMetaByTeam: TeamScopedRecord;
provisioningSnapshotByTeam: TeamScopedRecord;
currentProvisioningRunIdByTeam: TeamScopedRecord;
currentRuntimeRunIdByTeam: TeamScopedRecord;
provisioningStartedAtFloorByTeam: TeamScopedRecord;
leadActivityByTeam: TeamScopedRecord;
leadContextByTeam: TeamScopedRecord;
activeTaskLogActivityByTeam: TeamScopedRecord;
activeToolsByTeam: TeamScopedRecord;
finishedVisibleByTeam: TeamScopedRecord;
toolHistoryByTeam: TeamScopedRecord;
memberSpawnStatusesByTeam: TeamScopedRecord;
memberSpawnSnapshotsByTeam: TeamScopedRecord;
provisioningErrorByTeam: TeamScopedRecord;
}
type TeamScopedStateRemovalKey = keyof TeamScopedStateRemovalState;
interface TeamScopedProgressTombstoneState {
currentProvisioningRunIdByTeam: Record<string, string | null | undefined>;
currentRuntimeRunIdByTeam: Record<string, string | null | undefined>;
ignoredProvisioningRunIds: Record<string, string>;
ignoredRuntimeRunIds: Record<string, string>;
provisioningStartedAtFloorByTeam: Record<string, string>;
}
export function collectTeamScopedVisibleLoadingResets<
TTeamMessagesEntry extends TeamMessagesLoadingEntry,
>(
state: TeamScopedVisibleLoadingResetState<TTeamMessagesEntry>,
teamName: string
): Partial<TeamScopedVisibleLoadingResetState<TTeamMessagesEntry>> {
const nextTeamMessagesEntry = state.teamMessagesByName[teamName];
const nextTeamMessagesByName =
nextTeamMessagesEntry &&
(nextTeamMessagesEntry.loadingHead || nextTeamMessagesEntry.loadingOlder)
? {
...state.teamMessagesByName,
[teamName]: {
...nextTeamMessagesEntry,
loadingHead: false,
loadingOlder: false,
} as TTeamMessagesEntry,
}
: null;
const shouldResetSelectedSurface =
state.selectedTeamName === teamName &&
(state.selectedTeamLoading || state.selectedTeamError != null);
return {
...(nextTeamMessagesByName ? { teamMessagesByName: nextTeamMessagesByName } : {}),
...(shouldResetSelectedSurface
? {
selectedTeamLoading: false,
selectedTeamError: null,
}
: {}),
};
}
function omitTeamKey<TRecord extends Record<string, unknown>>(
record: TRecord,
teamName: string
): TRecord | null {
if (!(teamName in record)) {
return null;
}
const next = { ...record };
delete next[teamName];
return next;
}
export function collectTeamScopedStateRemovals<TState extends TeamScopedStateRemovalState>(
state: TState,
teamName: string
): Partial<Pick<TState, TeamScopedStateRemovalKey>> {
const nextProvisioningRuns = Object.fromEntries(
Object.entries(state.provisioningRuns).filter(([, run]) => run.teamName !== teamName)
) as TState['provisioningRuns'];
const nextTeamDataCache = omitTeamKey(state.teamDataCacheByName, teamName);
const nextTeamAgentRuntime = omitTeamKey(state.teamAgentRuntimeByTeam, teamName);
const nextTeamMessages = omitTeamKey(state.teamMessagesByName, teamName);
const nextMemberActivityMeta = omitTeamKey(state.memberActivityMetaByTeam, teamName);
const nextProvisioningSnapshot = omitTeamKey(state.provisioningSnapshotByTeam, teamName);
const nextCurrentProvisioningRunId = omitTeamKey(state.currentProvisioningRunIdByTeam, teamName);
const nextCurrentRuntimeRunId = omitTeamKey(state.currentRuntimeRunIdByTeam, teamName);
const nextProvisioningStartedAtFloor = omitTeamKey(
state.provisioningStartedAtFloorByTeam,
teamName
);
const nextLeadActivity = omitTeamKey(state.leadActivityByTeam, teamName);
const nextLeadContext = omitTeamKey(state.leadContextByTeam, teamName);
const nextActiveTaskLogActivity = omitTeamKey(state.activeTaskLogActivityByTeam, teamName);
const nextActiveTools = omitTeamKey(state.activeToolsByTeam, teamName);
const nextFinishedVisible = omitTeamKey(state.finishedVisibleByTeam, teamName);
const nextToolHistory = omitTeamKey(state.toolHistoryByTeam, teamName);
const nextMemberSpawnStatuses = omitTeamKey(state.memberSpawnStatusesByTeam, teamName);
const nextMemberSpawnSnapshots = omitTeamKey(state.memberSpawnSnapshotsByTeam, teamName);
const nextProvisioningErrors = omitTeamKey(state.provisioningErrorByTeam, teamName);
return {
...(Object.keys(nextProvisioningRuns).length !== Object.keys(state.provisioningRuns).length
? { provisioningRuns: nextProvisioningRuns }
: {}),
...(nextTeamDataCache ? { teamDataCacheByName: nextTeamDataCache } : {}),
...(nextTeamAgentRuntime ? { teamAgentRuntimeByTeam: nextTeamAgentRuntime } : {}),
...(nextTeamMessages ? { teamMessagesByName: nextTeamMessages } : {}),
...(nextMemberActivityMeta ? { memberActivityMetaByTeam: nextMemberActivityMeta } : {}),
...(nextProvisioningSnapshot ? { provisioningSnapshotByTeam: nextProvisioningSnapshot } : {}),
...(nextCurrentProvisioningRunId
? { currentProvisioningRunIdByTeam: nextCurrentProvisioningRunId }
: {}),
...(nextCurrentRuntimeRunId ? { currentRuntimeRunIdByTeam: nextCurrentRuntimeRunId } : {}),
...(nextProvisioningStartedAtFloor
? { provisioningStartedAtFloorByTeam: nextProvisioningStartedAtFloor }
: {}),
...(nextLeadActivity ? { leadActivityByTeam: nextLeadActivity } : {}),
...(nextLeadContext ? { leadContextByTeam: nextLeadContext } : {}),
...(nextActiveTaskLogActivity
? { activeTaskLogActivityByTeam: nextActiveTaskLogActivity }
: {}),
...(nextActiveTools ? { activeToolsByTeam: nextActiveTools } : {}),
...(nextFinishedVisible ? { finishedVisibleByTeam: nextFinishedVisible } : {}),
...(nextToolHistory ? { toolHistoryByTeam: nextToolHistory } : {}),
...(nextMemberSpawnStatuses ? { memberSpawnStatusesByTeam: nextMemberSpawnStatuses } : {}),
...(nextMemberSpawnSnapshots ? { memberSpawnSnapshotsByTeam: nextMemberSpawnSnapshots } : {}),
...(nextProvisioningErrors ? { provisioningErrorByTeam: nextProvisioningErrors } : {}),
};
}
export function buildTeamScopedProgressTombstones<TState extends TeamScopedProgressTombstoneState>(
state: TState,
teamName: string,
floor: string
): Pick<
TState,
'ignoredProvisioningRunIds' | 'ignoredRuntimeRunIds' | 'provisioningStartedAtFloorByTeam'
> {
const nextIgnoredProvisioningRunIds = { ...state.ignoredProvisioningRunIds };
const nextIgnoredRuntimeRunIds = { ...state.ignoredRuntimeRunIds };
const currentProvisioningRunId = state.currentProvisioningRunIdByTeam[teamName];
const currentRuntimeRunId = state.currentRuntimeRunIdByTeam[teamName];
if (currentProvisioningRunId) {
nextIgnoredProvisioningRunIds[currentProvisioningRunId] = teamName;
}
if (currentRuntimeRunId) {
nextIgnoredRuntimeRunIds[currentRuntimeRunId] = teamName;
}
return {
ignoredProvisioningRunIds: nextIgnoredProvisioningRunIds,
ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds,
provisioningStartedAtFloorByTeam: {
...state.provisioningStartedAtFloorByTeam,
[teamName]: floor,
},
} as Pick<
TState,
'ignoredProvisioningRunIds' | 'ignoredRuntimeRunIds' | 'provisioningStartedAtFloorByTeam'
>;
}

View file

@ -0,0 +1,61 @@
import type { TeamViewSnapshot } from '@shared/types';
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (value == null || typeof value !== 'object') {
return false;
}
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
export function structurallySharePlainValue<T>(previous: T, next: T): T {
if (Object.is(previous, next)) {
return previous;
}
if (Array.isArray(previous) && Array.isArray(next)) {
let changed = previous.length !== next.length;
const result = next.map((nextItem, index) => {
const sharedItem = structurallySharePlainValue(previous[index], nextItem);
if (!Object.is(sharedItem, previous[index])) {
changed = true;
}
return sharedItem;
});
return changed ? (result as T) : previous;
}
if (isPlainObject(previous) && isPlainObject(next)) {
const previousRecord = previous as Record<string, unknown>;
const nextRecord = next as Record<string, unknown>;
const previousKeys = Object.keys(previousRecord);
const nextKeys = Object.keys(nextRecord);
let changed = previousKeys.length !== nextKeys.length;
const result: Record<string, unknown> = {};
for (const key of nextKeys) {
if (!Object.prototype.hasOwnProperty.call(previousRecord, key)) {
changed = true;
}
const sharedValue = structurallySharePlainValue(previousRecord[key], nextRecord[key]);
if (!Object.is(sharedValue, previousRecord[key])) {
changed = true;
}
result[key] = sharedValue;
}
return changed ? (result as T) : previous;
}
return next;
}
export function structurallyShareTeamSnapshot(
previous: TeamViewSnapshot | null | undefined,
next: TeamViewSnapshot
): TeamViewSnapshot {
if (!previous) {
return next;
}
return structurallySharePlainValue(previous, next);
}

View file

@ -0,0 +1,42 @@
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
import type { ToolApprovalSettings } from '@shared/types';
const VALID_TIMEOUT_ACTIONS: ReadonlySet<ToolApprovalSettings['timeoutAction']> = new Set([
'allow',
'deny',
'wait',
]);
export function parseToolApprovalSettings(raw: string | null): ToolApprovalSettings {
if (!raw) return DEFAULT_TOOL_APPROVAL_SETTINGS;
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
const d = DEFAULT_TOOL_APPROVAL_SETTINGS;
return {
autoAllowAll: typeof parsed.autoAllowAll === 'boolean' ? parsed.autoAllowAll : d.autoAllowAll,
autoAllowFileEdits:
typeof parsed.autoAllowFileEdits === 'boolean'
? parsed.autoAllowFileEdits
: d.autoAllowFileEdits,
autoAllowSafeBash:
typeof parsed.autoAllowSafeBash === 'boolean'
? parsed.autoAllowSafeBash
: d.autoAllowSafeBash,
timeoutAction:
typeof parsed.timeoutAction === 'string' &&
VALID_TIMEOUT_ACTIONS.has(parsed.timeoutAction as ToolApprovalSettings['timeoutAction'])
? (parsed.timeoutAction as ToolApprovalSettings['timeoutAction'])
: d.timeoutAction,
timeoutSeconds:
typeof parsed.timeoutSeconds === 'number' &&
Number.isFinite(parsed.timeoutSeconds) &&
parsed.timeoutSeconds >= 5 &&
parsed.timeoutSeconds <= 300
? parsed.timeoutSeconds
: d.timeoutSeconds,
};
} catch {
return DEFAULT_TOOL_APPROVAL_SETTINGS;
}
}

View file

@ -0,0 +1,171 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
TeamMessageNotificationScanner,
type TeamNotificationMessage,
} from '../../../../src/main/ipc/teams/teamMessageNotificationScanner';
import type { RateLimitAutoResumePlan } from '../../../../src/main/services/team/AutoResumeService';
import type { TeamNotificationPayload } from '../../../../src/main/utils/teamNotificationBuilder';
function createMessage(overrides: Partial<TeamNotificationMessage> = {}): TeamNotificationMessage {
return {
from: 'team-lead',
text: "You've hit your limit. Resets in 5 minutes.",
timestamp: '2026-04-17T12:00:00.000Z',
messageId: 'msg-1',
source: 'lead_session',
leadSessionId: 'sess-live',
...overrides,
};
}
describe('TeamMessageNotificationScanner', () => {
const notificationSink = {
addTeamNotification: vi.fn<() => Promise<unknown>>(),
};
const autoResumeSink = {
handleRateLimitMessage: vi.fn(),
};
let autoResumeEnabled = true;
beforeEach(() => {
notificationSink.addTeamNotification.mockReset();
notificationSink.addTeamNotification.mockResolvedValue(null);
autoResumeSink.handleRateLimitMessage.mockReset();
autoResumeEnabled = true;
});
function createScanner(options?: {
isRateLimit?: (text: string) => boolean;
isApiError?: (text: string) => boolean;
planAutoResume?: (input: {
enabled: boolean;
canAutoResume: boolean;
messageText: string;
observedAt: Date;
messageTimestamp?: Date;
}) => RateLimitAutoResumePlan;
}): TeamMessageNotificationScanner {
return new TeamMessageNotificationScanner({
configReader: {
getConfig: () => ({ notifications: { autoResumeOnRateLimit: autoResumeEnabled } }),
},
notificationSink,
autoResumeSink,
now: () => new Date('2026-04-17T12:02:00.000Z'),
formatClockTime: () => '12:05',
isRateLimit: options?.isRateLimit ?? ((text) => text.includes('limit')),
isApiError: options?.isApiError ?? ((text) => text.startsWith('API Error:')),
planAutoResume:
options?.planAutoResume ??
((input) =>
input.enabled && input.canAutoResume
? {
kind: 'scheduled',
resetTime: new Date('2026-04-17T12:05:00.000Z'),
delayMs: 180_000,
fireAtMs: Date.parse('2026-04-17T12:05:30.000Z'),
rawDelayMs: 180_000,
}
: { kind: 'manual', reason: 'disabled' }),
});
}
it('notifies and schedules auto-resume for a live lead rate-limit message', () => {
const scanner = createScanner();
scanner.checkRateLimitMessages([createMessage()], {
teamName: 'my-team',
teamDisplayName: 'My Team',
projectPath: '/tmp/project',
teamIsAlive: true,
currentLeadSessionId: 'sess-live',
});
expect(notificationSink.addTeamNotification).toHaveBeenCalledWith(
expect.objectContaining<TeamNotificationPayload>({
teamEventType: 'rate_limit',
teamName: 'my-team',
teamDisplayName: 'My Team',
from: 'team-lead',
summary: 'Rate limit',
body: 'Auto-resume scheduled at 12:05',
dedupeKey: 'rate-limit:my-team:msg-1',
target: { kind: 'member', teamName: 'my-team', memberName: 'team-lead', focus: 'logs' },
projectPath: '/tmp/project',
})
);
expect(autoResumeSink.handleRateLimitMessage).toHaveBeenCalledWith(
'my-team',
"You've hit your limit. Resets in 5 minutes.",
new Date('2026-04-17T12:02:00.000Z'),
new Date('2026-04-17T12:00:00.000Z')
);
});
it('dedupes notification storage but still re-evaluates auto-resume later', () => {
const scanner = createScanner();
const context = {
teamName: 'my-team',
teamDisplayName: 'My Team',
teamIsAlive: true,
currentLeadSessionId: 'sess-live',
};
autoResumeEnabled = false;
scanner.checkRateLimitMessages([createMessage()], context);
expect(notificationSink.addTeamNotification).toHaveBeenCalledTimes(1);
expect(autoResumeSink.handleRateLimitMessage).not.toHaveBeenCalled();
autoResumeEnabled = true;
scanner.checkRateLimitMessages([createMessage()], context);
expect(notificationSink.addTeamNotification).toHaveBeenCalledTimes(1);
expect(autoResumeSink.handleRateLimitMessage).toHaveBeenCalledTimes(1);
});
it('does not schedule auto-resume from an older lead session', () => {
const scanner = createScanner();
scanner.checkRateLimitMessages(
[createMessage({ leadSessionId: 'sess-old', messageId: 'old-session' })],
{
teamName: 'my-team',
teamDisplayName: 'My Team',
teamIsAlive: true,
currentLeadSessionId: 'sess-live',
}
);
expect(notificationSink.addTeamNotification).toHaveBeenCalledTimes(1);
expect(autoResumeSink.handleRateLimitMessage).not.toHaveBeenCalled();
});
it('sends API-error notifications while leaving rate limits to the rate-limit path', () => {
const scanner = createScanner({
isRateLimit: (text) => text.includes('429'),
isApiError: (text) => text.startsWith('API Error:'),
});
scanner.checkApiErrorMessages(
[
createMessage({ text: 'API Error: 429 rate limited', messageId: 'rate-limit-api' }),
createMessage({ text: 'API Error: 500 server failed', messageId: 'api-500' }),
],
{
teamName: 'my-team',
teamDisplayName: 'My Team',
}
);
expect(notificationSink.addTeamNotification).toHaveBeenCalledTimes(1);
expect(notificationSink.addTeamNotification).toHaveBeenCalledWith(
expect.objectContaining({
teamEventType: 'api_error',
summary: 'API Error 500',
dedupeKey: 'api-error:my-team:api-500',
})
);
});
});

View file

@ -0,0 +1,204 @@
import { describe, expect, it } from 'vitest';
import {
areTeamAgentRuntimeEntriesEqual,
areTeamAgentRuntimeResourceSamplesEqual,
areTeamAgentRuntimeSnapshotsEqual,
} from '../../../src/renderer/store/team/teamAgentRuntimeSnapshotEquality';
import type {
TeamAgentRuntimeEntry,
TeamAgentRuntimeResourceSample,
TeamAgentRuntimeSnapshot,
} from '../../../src/shared/types';
function createResourceSample(
overrides: Partial<TeamAgentRuntimeResourceSample> = {}
): TeamAgentRuntimeResourceSample {
return {
timestamp: '2026-05-22T10:00:00.000Z',
cpuPercent: 4,
rssBytes: 1024,
primaryCpuPercent: 3,
primaryRssBytes: 768,
childCpuPercent: 1,
childRssBytes: 256,
processCount: 2,
runtimeLoadScope: 'process-tree',
runtimeLoadTruncated: false,
pidSource: 'agent_process_table',
pid: 111,
runtimePid: 222,
...overrides,
};
}
function createRuntimeEntry(overrides: Partial<TeamAgentRuntimeEntry> = {}): TeamAgentRuntimeEntry {
return {
memberName: 'alice',
alive: true,
restartable: true,
backendType: 'process',
providerId: 'codex',
providerBackendId: 'codex-native',
laneId: 'lane-1',
laneKind: 'primary',
pid: 111,
runtimeModel: 'gpt-5.3-codex',
cwd: '/tmp/old',
rssBytes: 1024,
cpuPercent: 4,
primaryCpuPercent: 3,
primaryRssBytes: 768,
childCpuPercent: 1,
childRssBytes: 256,
processCount: 2,
runtimeLoadScope: 'process-tree',
runtimeLoadTruncated: false,
resourceHistory: [createResourceSample()],
livenessKind: 'confirmed_bootstrap',
pidSource: 'agent_process_table',
processCommand: 'codex',
paneId: '%1',
panePid: 333,
paneCurrentCommand: 'node',
runtimePid: 222,
runtimeSessionId: 'runtime-session-1',
runtimeLeaseExpiresAt: '2026-05-22T10:10:00.000Z',
runtimeLastSeenAt: '2026-05-22T10:00:00.000Z',
historicalBootstrapConfirmed: true,
runtimeDiagnostic: 'Ready',
runtimeDiagnosticSeverity: 'info',
diagnostics: ['healthy'],
updatedAt: '2026-05-22T10:00:00.000Z',
...overrides,
};
}
function createRuntimeSnapshot(
overrides: Partial<TeamAgentRuntimeSnapshot> = {}
): TeamAgentRuntimeSnapshot {
return {
teamName: 'my-team',
updatedAt: '2026-05-22T10:00:00.000Z',
runId: 'run-1',
providerBackendId: 'codex-native',
fastMode: 'inherit',
members: {
alice: createRuntimeEntry(),
},
...overrides,
};
}
describe('teamAgentRuntimeSnapshotEquality', () => {
it('compares runtime resource samples by visible process metrics', () => {
expect(
areTeamAgentRuntimeResourceSamplesEqual(createResourceSample(), createResourceSample())
).toBe(true);
expect(
areTeamAgentRuntimeResourceSamplesEqual(
createResourceSample(),
createResourceSample({ cpuPercent: 5 })
)
).toBe(false);
expect(areTeamAgentRuntimeResourceSamplesEqual(null, createResourceSample())).toBe(false);
});
it('ignores runtime entry fields that do not currently affect equality', () => {
const left = createRuntimeEntry({
cwd: '/tmp/old',
runtimeLeaseExpiresAt: '2026-05-22T10:10:00.000Z',
updatedAt: '2026-05-22T10:00:00.000Z',
});
const right = createRuntimeEntry({
cwd: '/tmp/new',
runtimeLeaseExpiresAt: '2026-05-22T10:20:00.000Z',
updatedAt: '2026-05-22T10:05:00.000Z',
});
expect(areTeamAgentRuntimeEntriesEqual(left, right)).toBe(true);
});
it('detects visible runtime entry field changes', () => {
expect(
areTeamAgentRuntimeEntriesEqual(
createRuntimeEntry(),
createRuntimeEntry({ runtimeDiagnosticSeverity: 'warning' })
)
).toBe(false);
expect(
areTeamAgentRuntimeEntriesEqual(
createRuntimeEntry(),
createRuntimeEntry({ resourceHistory: [createResourceSample({ rssBytes: 2048 })] })
)
).toBe(false);
});
it('compares diagnostics and resource history arrays in stable order', () => {
expect(
areTeamAgentRuntimeEntriesEqual(
createRuntimeEntry({ diagnostics: ['a', 'b'] }),
createRuntimeEntry({ diagnostics: ['b', 'a'] })
)
).toBe(false);
expect(
areTeamAgentRuntimeEntriesEqual(
createRuntimeEntry({
resourceHistory: [
createResourceSample({ timestamp: '2026-05-22T10:00:00.000Z' }),
createResourceSample({ timestamp: '2026-05-22T10:01:00.000Z' }),
],
}),
createRuntimeEntry({
resourceHistory: [
createResourceSample({ timestamp: '2026-05-22T10:01:00.000Z' }),
createResourceSample({ timestamp: '2026-05-22T10:00:00.000Z' }),
],
})
)
).toBe(false);
});
it('compares runtime snapshots by team, run id, and semantic member entries', () => {
expect(areTeamAgentRuntimeSnapshotsEqual(createRuntimeSnapshot(), createRuntimeSnapshot())).toBe(
true
);
expect(
areTeamAgentRuntimeSnapshotsEqual(
createRuntimeSnapshot(),
createRuntimeSnapshot({ runId: 'run-2' })
)
).toBe(false);
expect(
areTeamAgentRuntimeSnapshotsEqual(
createRuntimeSnapshot(),
createRuntimeSnapshot({
members: {
alice: createRuntimeEntry(),
bob: createRuntimeEntry({ memberName: 'bob' }),
},
})
)
).toBe(false);
});
it('ignores snapshot metadata fields that do not currently affect equality', () => {
const left = createRuntimeSnapshot({
updatedAt: '2026-05-22T10:00:00.000Z',
providerBackendId: 'codex-native',
fastMode: 'inherit',
});
const right = createRuntimeSnapshot({
updatedAt: '2026-05-22T10:05:00.000Z',
providerBackendId: 'api',
fastMode: 'on',
});
expect(areTeamAgentRuntimeSnapshotsEqual(left, right)).toBe(true);
});
it('returns false when there is no previous runtime snapshot', () => {
expect(areTeamAgentRuntimeSnapshotsEqual(undefined, createRuntimeSnapshot())).toBe(false);
});
});

View file

@ -0,0 +1,60 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
clearAllLastResolvedTeamDataRefreshes,
clearLastResolvedTeamDataRefreshAt,
getLastResolvedTeamDataRefreshAt,
hasLastResolvedTeamDataRefreshAt,
recordLastResolvedTeamDataRefresh,
} from '../../../src/renderer/store/team/teamDataRefreshTimestamps';
afterEach(() => {
vi.useRealTimers();
clearAllLastResolvedTeamDataRefreshes();
});
describe('teamDataRefreshTimestamps', () => {
it('returns undefined for teams without a recorded refresh', () => {
expect(getLastResolvedTeamDataRefreshAt('my-team')).toBeUndefined();
expect(hasLastResolvedTeamDataRefreshAt('my-team')).toBe(false);
});
it('records explicit refresh timestamps by team', () => {
recordLastResolvedTeamDataRefresh('my-team', 100);
recordLastResolvedTeamDataRefresh('other-team', 200);
expect(getLastResolvedTeamDataRefreshAt('my-team')).toBe(100);
expect(getLastResolvedTeamDataRefreshAt('other-team')).toBe(200);
expect(hasLastResolvedTeamDataRefreshAt('my-team')).toBe(true);
});
it('uses Date.now by default to preserve current call-site behavior', () => {
vi.setSystemTime(new Date('2026-05-22T06:30:00.000Z'));
recordLastResolvedTeamDataRefresh('my-team');
expect(getLastResolvedTeamDataRefreshAt('my-team')).toBe(
new Date('2026-05-22T06:30:00.000Z').getTime()
);
});
it('clears one team timestamp without touching other teams', () => {
recordLastResolvedTeamDataRefresh('my-team', 100);
recordLastResolvedTeamDataRefresh('other-team', 200);
clearLastResolvedTeamDataRefreshAt('my-team');
expect(getLastResolvedTeamDataRefreshAt('my-team')).toBeUndefined();
expect(getLastResolvedTeamDataRefreshAt('other-team')).toBe(200);
});
it('clears all recorded timestamps', () => {
recordLastResolvedTeamDataRefresh('my-team', 100);
recordLastResolvedTeamDataRefresh('other-team', 200);
clearAllLastResolvedTeamDataRefreshes();
expect(hasLastResolvedTeamDataRefreshAt('my-team')).toBe(false);
expect(hasLastResolvedTeamDataRefreshAt('other-team')).toBe(false);
});
});

View file

@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import {
getFullTeamDataRequestKey,
getTeamDataRequestKey,
getTeamDataRequestLabel,
getTeamDataSnapshotMode,
getThinTeamDataRequestKey,
isTeamDataRequestKeyForTeam,
normalizeTeamGetDataOptions,
shouldIncludeMemberBranches,
} from '../../../src/renderer/store/team/teamDataRequestKeys';
describe('teamDataRequestKeys', () => {
it('normalizes only the thin snapshot option and treats all other inputs as full snapshots', () => {
expect(normalizeTeamGetDataOptions()).toBeUndefined();
expect(normalizeTeamGetDataOptions({})).toBeUndefined();
expect(normalizeTeamGetDataOptions({ includeMemberBranches: true })).toBeUndefined();
expect(normalizeTeamGetDataOptions({ includeMemberBranches: false })).toEqual({
includeMemberBranches: false,
});
expect(shouldIncludeMemberBranches()).toBe(true);
expect(shouldIncludeMemberBranches({ includeMemberBranches: true })).toBe(true);
expect(shouldIncludeMemberBranches({ includeMemberBranches: false })).toBe(false);
});
it('maps normalized request options to stable full and thin snapshot modes', () => {
expect(getTeamDataSnapshotMode()).toBe('full');
expect(getTeamDataSnapshotMode({ includeMemberBranches: true })).toBe('full');
expect(getTeamDataSnapshotMode({ includeMemberBranches: false })).toBe('thin');
});
it('builds request keys that preserve the existing null-separated team/mode contract', () => {
expect(getTeamDataRequestKey('my-team')).toBe('my-team\u0000mode:full');
expect(getTeamDataRequestKey('my-team', { includeMemberBranches: true })).toBe(
'my-team\u0000mode:full'
);
expect(getTeamDataRequestKey('my-team', { includeMemberBranches: false })).toBe(
'my-team\u0000mode:thin'
);
expect(getFullTeamDataRequestKey('my-team')).toBe('my-team\u0000mode:full');
expect(getThinTeamDataRequestKey('my-team')).toBe('my-team\u0000mode:thin');
});
it('builds timeout/debug labels from the same normalized mode policy', () => {
expect(getTeamDataRequestLabel('my-team')).toBe('team:getData(my-team,mode=full)');
expect(getTeamDataRequestLabel('my-team', { includeMemberBranches: false })).toBe(
'team:getData(my-team,mode=thin)'
);
});
it('matches request keys only for the exact team prefix boundary', () => {
expect(isTeamDataRequestKeyForTeam('my-team\u0000mode:full', 'my-team')).toBe(true);
expect(isTeamDataRequestKeyForTeam('my-team-extra\u0000mode:full', 'my-team')).toBe(false);
expect(isTeamDataRequestKeyForTeam('my-team', 'my-team')).toBe(false);
});
});

View file

@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';
import {
selectTeamDataForName,
selectTeamIsAliveForName,
selectTeamMemberSnapshotsForName,
selectTeamTasksForName,
type TeamDataSelectorState,
} from '../../../src/renderer/store/team/teamDataSelectors';
import type { TeamViewSnapshot } from '../../../src/shared/types';
function createSnapshot(overrides: Partial<TeamViewSnapshot> = {}): TeamViewSnapshot {
return {
teamName: 'my-team',
config: { name: 'My Team' },
members: [],
tasks: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
...overrides,
};
}
function createState(overrides: Partial<TeamDataSelectorState> = {}): TeamDataSelectorState {
return {
teamDataCacheByName: {},
selectedTeamName: null,
selectedTeamData: null,
...overrides,
};
}
describe('teamDataSelectors', () => {
it('returns null when no team name is selected or cached', () => {
const state = createState();
expect(selectTeamDataForName(state, null)).toBeNull();
expect(selectTeamDataForName(state, undefined)).toBeNull();
expect(selectTeamDataForName(state, 'missing-team')).toBeNull();
});
it('prefers selected team data over cached data for the active team', () => {
const cachedSnapshot = createSnapshot({ teamName: 'my-team', isAlive: false });
const selectedSnapshot = createSnapshot({ teamName: 'my-team', isAlive: true });
const state = createState({
selectedTeamName: 'my-team',
selectedTeamData: selectedSnapshot,
teamDataCacheByName: {
'my-team': cachedSnapshot,
},
});
expect(selectTeamDataForName(state, 'my-team')).toBe(selectedSnapshot);
});
it('falls back to cached team data outside the selected snapshot', () => {
const cachedSnapshot = createSnapshot({ teamName: 'cached-team', isAlive: true });
const state = createState({
selectedTeamName: 'other-team',
selectedTeamData: createSnapshot({ teamName: 'other-team' }),
teamDataCacheByName: {
'cached-team': cachedSnapshot,
},
});
expect(selectTeamDataForName(state, 'cached-team')).toBe(cachedSnapshot);
});
it('returns stable empty arrays and scalar fields from team snapshots', () => {
const task = { id: 'task-1', subject: 'Build', status: 'pending' as const };
const member = { name: 'alice', role: 'developer', currentTaskId: null, taskCount: 0 };
const state = createState({
teamDataCacheByName: {
'my-team': createSnapshot({
members: [member],
tasks: [task],
isAlive: true,
}),
},
});
expect(selectTeamMemberSnapshotsForName(state, 'my-team')).toEqual([member]);
expect(selectTeamTasksForName(state, 'my-team')).toEqual([task]);
expect(selectTeamIsAliveForName(state, 'my-team')).toBe(true);
expect(selectTeamMemberSnapshotsForName(state, 'missing-team')).toBe(
selectTeamMemberSnapshotsForName(state, 'missing-team')
);
expect(selectTeamTasksForName(state, 'missing-team')).toBe(
selectTeamTasksForName(state, 'missing-team')
);
expect(selectTeamIsAliveForName(state, 'missing-team')).toBeUndefined();
});
});

View file

@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import {
mapReviewError,
mapSendMessageError,
shouldInvalidateCachedTeamDataForError,
} from '../../../src/renderer/store/team/teamErrorPolicies';
import { IpcError } from '../../../src/renderer/utils/unwrapIpc';
describe('teamErrorPolicies', () => {
it('maps send-message verification races to the user-facing retry copy', () => {
expect(mapSendMessageError(new Error('Failed to verify inbox write for message-1'))).toBe(
'Message was written but not verified (race). Please try again.'
);
expect(
mapSendMessageError(
new IpcError('team:sendMessage', 'Failed to verify inbox write after timeout')
)
).toBe('Message was written but not verified (race). Please try again.');
});
it('maps send-message errors to original messages or fallback copy', () => {
expect(mapSendMessageError(new Error('Transport failed'))).toBe('Transport failed');
expect(mapSendMessageError('plain failure')).toBe('Failed to send message');
expect(mapSendMessageError(null)).toBe('Failed to send message');
});
it('maps review verification conflicts to the user-facing conflict copy', () => {
expect(mapReviewError(new Error('Task status update verification failed for task-1'))).toBe(
'Failed to update task status (possible agent conflict).'
);
expect(
mapReviewError(
new IpcError('team:updateKanban', 'Task status update verification failed after retry')
)
).toBe('Failed to update task status (possible agent conflict).');
});
it('maps review errors to original messages or fallback copy', () => {
expect(mapReviewError(new Error('Review failed'))).toBe('Review failed');
expect(mapReviewError({ message: 'ignored non-error shape' })).toBe(
'Failed to perform review action'
);
expect(mapReviewError(undefined)).toBe('Failed to perform review action');
});
it('invalidates cached team data for draft and missing-team errors', () => {
expect(shouldInvalidateCachedTeamDataForError('my-team', 'TEAM_DRAFT')).toBe(true);
expect(
shouldInvalidateCachedTeamDataForError('my-team', 'Cannot read team: TEAM_DRAFT')
).toBe(true);
expect(shouldInvalidateCachedTeamDataForError('my-team', 'Team not found: my-team')).toBe(true);
expect(shouldInvalidateCachedTeamDataForError('my-team', 'Team config not found')).toBe(true);
});
it('does not invalidate cached team data for unrelated or other-team errors', () => {
expect(shouldInvalidateCachedTeamDataForError('my-team', 'Network timeout')).toBe(false);
expect(shouldInvalidateCachedTeamDataForError('my-team', 'Team not found: other-team')).toBe(
false
);
expect(shouldInvalidateCachedTeamDataForError('my-team', 'Team config missing')).toBe(false);
});
});

View file

@ -0,0 +1,133 @@
import { describe, expect, it } from 'vitest';
import {
areTeamGraphSlotAssignmentsEqual,
getDefaultTeamGraphSlotAssignmentsForMembers,
isTeamGraphSlotPersistenceDisabled,
migrateStableSlotAssignmentsForMembers,
normalizeLegacySixRowOrbitAssignments,
normalizeTeamGraphGridOwnerOrder,
normalizeTeamGraphSlotAssignmentsForVisibleOwners,
pruneTeamGraphSlotAssignmentsForVisibleOwners,
seedStableSlotAssignmentsForMembers,
} from '../../../src/renderer/store/team/teamGraphLayout';
describe('teamGraphLayout', () => {
it('migrates legacy name-keyed assignments to stable owner ids', () => {
const migrated = migrateStableSlotAssignmentsForMembers(
{
alice: { ringIndex: 0, sectorIndex: 1 },
},
[{ name: 'alice', agentId: 'agent-a' }]
);
expect(migrated.changed).toBe(true);
expect(migrated.assignments).toEqual({
'agent-a': { ringIndex: 0, sectorIndex: 1 },
});
});
it('drops stale name-keyed assignments when stable assignments already exist', () => {
const migrated = migrateStableSlotAssignmentsForMembers(
{
alice: { ringIndex: 0, sectorIndex: 1 },
'agent-a': { ringIndex: 0, sectorIndex: 2 },
},
[{ name: 'alice', agentId: 'agent-a' }]
);
expect(migrated.changed).toBe(true);
expect(migrated.assignments).toEqual({
'agent-a': { ringIndex: 0, sectorIndex: 2 },
});
});
it('seeds default assignments only when no visible owner has a persisted assignment', () => {
const seeded = seedStableSlotAssignmentsForMembers(
{ unrelated: { ringIndex: 4, sectorIndex: 0 } },
[
{ name: 'alice', agentId: 'agent-a' },
{ name: 'bob', agentId: 'agent-b' },
]
);
expect(seeded.changed).toBe(true);
expect(Object.keys(seeded.assignments)).toEqual(['unrelated', 'agent-a', 'agent-b']);
expect(seeded.assignments['agent-a']).toEqual({ ringIndex: 0, sectorIndex: 0 });
expect(seeded.assignments['agent-b']).toEqual({ ringIndex: 0, sectorIndex: 1 });
const preserved = seedStableSlotAssignmentsForMembers(seeded.assignments, [
{ name: 'alice', agentId: 'agent-a' },
{ name: 'bob', agentId: 'agent-b' },
]);
expect(preserved.changed).toBe(false);
expect(preserved.assignments).toBe(seeded.assignments);
});
it('normalizes six-owner legacy two-row orbit assignments', () => {
const ownerIds = ['a', 'b', 'c', 'd', 'e', 'f'];
const normalized = normalizeLegacySixRowOrbitAssignments(
{
a: { ringIndex: 0, sectorIndex: 0 },
b: { ringIndex: 0, sectorIndex: 4 },
c: { ringIndex: 1, sectorIndex: 2 },
d: { ringIndex: 1, sectorIndex: 0 },
},
ownerIds
);
expect(normalized).toEqual({
a: { ringIndex: 0, sectorIndex: 0 },
b: { ringIndex: 2, sectorIndex: 1 },
c: { ringIndex: 2, sectorIndex: 2 },
d: { ringIndex: 2, sectorIndex: 0 },
});
});
it('normalizes and prunes assignments to visible owners', () => {
const normalized = normalizeTeamGraphSlotAssignmentsForVisibleOwners(
{
a: { ringIndex: 0, sectorIndex: 0 },
hidden: { ringIndex: 4, sectorIndex: 4 },
},
['a']
);
expect(normalized).toEqual({ a: { ringIndex: 0, sectorIndex: 0 } });
expect(pruneTeamGraphSlotAssignmentsForVisibleOwners({ hidden: { ringIndex: 4, sectorIndex: 4 } }, ['a']))
.toBeUndefined();
});
it('normalizes grid owner order by filtering stale and duplicate ids then appending missing ids', () => {
expect(normalizeTeamGraphGridOwnerOrder(['b', 'stale', 'b'], ['a', 'b', 'c'])).toEqual([
'b',
'a',
'c',
]);
});
it('compares assignments by owner id and slot coordinates', () => {
expect(
areTeamGraphSlotAssignmentsEqual(
{ a: { ringIndex: 0, sectorIndex: 0 } },
{ a: { ringIndex: 0, sectorIndex: 0 } }
)
).toBe(true);
expect(
areTeamGraphSlotAssignmentsEqual(
{ a: { ringIndex: 0, sectorIndex: 0 } },
{ a: { ringIndex: 0, sectorIndex: 1 } }
)
).toBe(false);
});
it('exposes default assignment and persistence guardrail helpers', () => {
expect(
getDefaultTeamGraphSlotAssignmentsForMembers([
{ name: 'team-lead', agentId: 'lead-agent' },
{ name: 'alice', agentId: 'agent-a' },
])
).toEqual({ 'agent-a': { ringIndex: 0, sectorIndex: 0 } });
expect(isTeamGraphSlotPersistenceDisabled()).toBe(true);
});
});

View file

@ -0,0 +1,132 @@
import { describe, expect, it } from 'vitest';
import {
areTeamLaunchParamsEqual,
buildLaunchParamsFromRuntimeRequest,
extractBaseModel,
} from '../../../src/renderer/store/team/teamLaunchParams';
import type { TeamLaunchParams } from '../../../src/renderer/store/team/teamLaunchParams';
const codexFallback: TeamLaunchParams = {
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.5',
effort: 'medium',
fastMode: 'on',
limitContext: true,
};
describe('teamLaunchParams', () => {
it('extracts provider-scoped base models', () => {
expect(extractBaseModel(' opus[1m] ', 'anthropic')).toBe('opus');
expect(extractBaseModel('sonnet', 'anthropic')).toBe('sonnet');
expect(extractBaseModel('gpt-5.5[1m]', 'codex')).toBe('gpt-5.5[1m]');
expect(extractBaseModel(' ', 'anthropic')).toBeUndefined();
expect(extractBaseModel(undefined, 'anthropic')).toBeUndefined();
});
it('builds default anthropic launch params without fallback', () => {
expect(buildLaunchParamsFromRuntimeRequest({})).toEqual({
providerId: 'anthropic',
providerBackendId: undefined,
model: 'default',
effort: undefined,
fastMode: undefined,
limitContext: false,
});
});
it('preserves fallback values for metadata-only requests on the same provider', () => {
expect(buildLaunchParamsFromRuntimeRequest({}, codexFallback)).toEqual(codexFallback);
});
it('resets provider-scoped values when the provider changes without explicit fields', () => {
expect(
buildLaunchParamsFromRuntimeRequest(
{
providerId: 'anthropic',
},
codexFallback
)
).toEqual({
providerId: 'anthropic',
providerBackendId: undefined,
model: 'default',
effort: undefined,
fastMode: undefined,
limitContext: false,
});
});
it('uses explicit model, effort, fast mode, and limitContext when present', () => {
expect(
buildLaunchParamsFromRuntimeRequest(
{
providerId: 'anthropic',
model: 'haiku[1m]',
effort: 'low',
fastMode: 'off',
limitContext: false,
},
codexFallback
)
).toEqual({
providerId: 'anthropic',
providerBackendId: undefined,
model: 'haiku',
effort: 'low',
fastMode: 'off',
limitContext: false,
});
});
it('treats an explicit undefined model as Default for the active provider', () => {
expect(
buildLaunchParamsFromRuntimeRequest(
{
providerId: 'codex',
providerBackendId: 'codex-native',
model: undefined,
effort: 'low',
},
codexFallback
)
).toEqual({
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'default',
effort: 'low',
fastMode: 'on',
limitContext: true,
});
});
it('migrates legacy provider backend ids for codex requests', () => {
expect(
buildLaunchParamsFromRuntimeRequest({
providerId: 'codex',
providerBackendId: 'api',
})
).toEqual({
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'default',
effort: undefined,
fastMode: undefined,
limitContext: false,
});
});
it('compares launch params by all persisted fields', () => {
expect(areTeamLaunchParamsEqual(codexFallback, { ...codexFallback })).toBe(true);
expect(
areTeamLaunchParamsEqual(codexFallback, {
...codexFallback,
fastMode: 'off',
})
).toBe(false);
expect(areTeamLaunchParamsEqual(undefined, undefined)).toBe(true);
expect(areTeamLaunchParamsEqual(undefined, codexFallback)).toBe(false);
});
});

View file

@ -0,0 +1,57 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
captureTeamLocalStateEpoch,
clearAllTeamLocalStateEpochs,
clearTeamLocalStateEpoch,
hasTeamLocalStateEpoch,
invalidateTeamLocalStateEpoch,
isTeamLocalStateEpochCurrent,
} from '../../../src/renderer/store/team/teamLocalStateEpoch';
afterEach(() => {
clearAllTeamLocalStateEpochs();
});
describe('teamLocalStateEpoch', () => {
it('starts missing teams at epoch zero without materializing an entry', () => {
expect(captureTeamLocalStateEpoch('my-team')).toBe(0);
expect(isTeamLocalStateEpochCurrent('my-team', 0)).toBe(true);
expect(hasTeamLocalStateEpoch('my-team')).toBe(false);
});
it('increments epochs independently per team', () => {
invalidateTeamLocalStateEpoch('my-team');
invalidateTeamLocalStateEpoch('my-team');
invalidateTeamLocalStateEpoch('other-team');
expect(captureTeamLocalStateEpoch('my-team')).toBe(2);
expect(captureTeamLocalStateEpoch('other-team')).toBe(1);
expect(isTeamLocalStateEpochCurrent('my-team', 1)).toBe(false);
expect(isTeamLocalStateEpochCurrent('my-team', 2)).toBe(true);
});
it('clears one team epoch without touching other teams', () => {
invalidateTeamLocalStateEpoch('my-team');
invalidateTeamLocalStateEpoch('other-team');
clearTeamLocalStateEpoch('my-team');
expect(captureTeamLocalStateEpoch('my-team')).toBe(0);
expect(hasTeamLocalStateEpoch('my-team')).toBe(false);
expect(captureTeamLocalStateEpoch('other-team')).toBe(1);
expect(hasTeamLocalStateEpoch('other-team')).toBe(true);
});
it('clears all materialized epochs', () => {
invalidateTeamLocalStateEpoch('my-team');
invalidateTeamLocalStateEpoch('other-team');
clearAllTeamLocalStateEpochs();
expect(hasTeamLocalStateEpoch('my-team')).toBe(false);
expect(hasTeamLocalStateEpoch('other-team')).toBe(false);
expect(captureTeamLocalStateEpoch('my-team')).toBe(0);
expect(captureTeamLocalStateEpoch('other-team')).toBe(0);
});
});

View file

@ -0,0 +1,197 @@
import { describe, expect, it } from 'vitest';
import {
areMemberActivityMetaEntriesEqual,
isMemberActivityMetaStale,
structurallyShareMemberActivityFacts,
} from '../../../src/renderer/store/team/teamMemberActivityMeta';
import type { TeamMessagesCacheEntry } from '../../../src/renderer/store/team/teamMessagesCache';
import type {
MemberActivityMetaEntry,
TeamMemberActivityMeta,
} from '../../../src/shared/types';
function createEntry(overrides: Partial<MemberActivityMetaEntry> = {}): MemberActivityMetaEntry {
return {
memberName: 'alice',
lastAuthoredMessageAt: '2026-05-22T10:00:00.000Z',
messageCountExact: 3,
latestAuthoredMessageSignalsTermination: false,
...overrides,
};
}
function createMeta(overrides: Partial<TeamMemberActivityMeta> = {}): TeamMemberActivityMeta {
return {
teamName: 'my-team',
computedAt: '2026-05-22T10:00:00.000Z',
members: {
alice: createEntry(),
},
feedRevision: 'feed-1',
...overrides,
};
}
function createMessagesEntry(
overrides: Partial<TeamMessagesCacheEntry> = {}
): TeamMessagesCacheEntry {
return {
canonicalMessages: [],
optimisticMessages: [],
feedRevision: 'feed-1',
nextCursor: null,
hasMore: false,
lastFetchedAt: null,
loadingHead: false,
loadingOlder: false,
headHydrated: true,
...overrides,
};
}
describe('teamMemberActivityMeta', () => {
it('compares member activity entries by visible facts', () => {
expect(areMemberActivityMetaEntriesEqual(createEntry(), createEntry())).toBe(true);
expect(
areMemberActivityMetaEntriesEqual(createEntry(), createEntry({ messageCountExact: 4 }))
).toBe(false);
expect(
areMemberActivityMetaEntriesEqual(
createEntry(),
createEntry({ latestAuthoredMessageSignalsTermination: true })
)
).toBe(false);
expect(areMemberActivityMetaEntriesEqual(undefined, createEntry())).toBe(false);
});
it('returns next activity facts when there is no previous record', () => {
const next = {
alice: createEntry(),
};
expect(structurallyShareMemberActivityFacts(undefined, next)).toBe(next);
});
it('preserves the previous record when all entries are semantically equal', () => {
const previous = {
alice: createEntry(),
bob: createEntry({ memberName: 'bob', messageCountExact: 1 }),
};
const next = {
alice: createEntry(),
bob: createEntry({ memberName: 'bob', messageCountExact: 1 }),
};
expect(structurallyShareMemberActivityFacts(previous, next)).toBe(previous);
});
it('shares unchanged entries and replaces changed entries', () => {
const previousAlice = createEntry();
const previousBob = createEntry({ memberName: 'bob', messageCountExact: 1 });
const nextBob = createEntry({ memberName: 'bob', messageCountExact: 2 });
const previous = {
alice: previousAlice,
bob: previousBob,
};
const shared = structurallyShareMemberActivityFacts(
previous,
{
alice: createEntry(),
bob: nextBob,
}
);
expect(shared).not.toBe(previous);
expect(shared.alice).toBe(previousAlice);
expect(shared.bob).toBe(nextBob);
});
it('returns a new record when activity keys are added or removed', () => {
const previous = {
alice: createEntry(),
bob: createEntry({ memberName: 'bob' }),
};
const removed = structurallyShareMemberActivityFacts(previous, {
alice: createEntry(),
});
expect(removed).not.toBe(previous);
expect(removed).toEqual({
alice: previous.alice,
});
expect(removed.alice).toBe(previous.alice);
const singlePrevious = {
alice: createEntry(),
};
const added = structurallyShareMemberActivityFacts(singlePrevious, {
alice: createEntry(),
bob: createEntry({ memberName: 'bob' }),
});
expect(added).not.toBe(singlePrevious);
expect(added.alice).toBe(singlePrevious.alice);
expect(added.bob).toEqual(createEntry({ memberName: 'bob' }));
});
it('treats missing member activity meta as stale', () => {
expect(
isMemberActivityMetaStale(
{
memberActivityMetaByTeam: {},
teamMessagesByName: {},
},
'my-team'
)
).toBe(true);
});
it('does not require refresh when the message feed has no revision yet', () => {
expect(
isMemberActivityMetaStale(
{
memberActivityMetaByTeam: {
'my-team': createMeta({ feedRevision: 'old-feed' }),
},
teamMessagesByName: {
'my-team': createMessagesEntry({ feedRevision: null }),
},
},
'my-team'
)
).toBe(false);
});
it('compares member activity meta feedRevision against the messages feed revision', () => {
expect(
isMemberActivityMetaStale(
{
memberActivityMetaByTeam: {
'my-team': createMeta({ feedRevision: 'feed-1' }),
},
teamMessagesByName: {
'my-team': createMessagesEntry({ feedRevision: 'feed-1' }),
},
},
'my-team'
)
).toBe(false);
expect(
isMemberActivityMetaStale(
{
memberActivityMetaByTeam: {
'my-team': createMeta({ feedRevision: 'feed-1' }),
},
teamMessagesByName: {
'my-team': createMessagesEntry({ feedRevision: 'feed-2' }),
},
},
'my-team'
)
).toBe(true);
});
});

View file

@ -0,0 +1,186 @@
import { describe, expect, it } from 'vitest';
import {
areExpectedMembersEqual,
areLaunchSummaryCountsEqual,
areMemberSpawnSnapshotsSemanticallyEqual,
areMemberSpawnStatusEntriesEqual,
areMemberSpawnStatusesEqual,
} from '../../../src/renderer/store/team/teamMemberSpawnSnapshotEquality';
import type {
MemberSpawnStatusEntry,
MemberSpawnStatusesSnapshot,
PersistedTeamLaunchSummary,
} from '../../../src/shared/types';
function createSummary(
overrides: Partial<PersistedTeamLaunchSummary> = {}
): PersistedTeamLaunchSummary {
return {
confirmedCount: 1,
pendingCount: 0,
failedCount: 0,
skippedCount: 0,
runtimeAlivePendingCount: 0,
shellOnlyPendingCount: 0,
runtimeProcessPendingCount: 0,
runtimeCandidatePendingCount: 0,
noRuntimePendingCount: 0,
permissionPendingCount: 0,
...overrides,
};
}
function createStatusEntry(
overrides: Partial<MemberSpawnStatusEntry> = {}
): MemberSpawnStatusEntry {
return {
status: 'online',
launchState: 'confirmed_alive',
updatedAt: '2026-05-22T10:00:00.000Z',
livenessSource: 'heartbeat',
runtimeAlive: true,
runtimeModel: 'gpt-5.3-codex',
livenessKind: 'confirmed_bootstrap',
runtimeDiagnostic: 'Ready',
runtimeDiagnosticSeverity: 'info',
bootstrapConfirmed: true,
hardFailure: false,
pendingPermissionRequestIds: ['perm-a', 'perm-b'],
...overrides,
};
}
function createSnapshot(
overrides: Partial<MemberSpawnStatusesSnapshot> = {}
): MemberSpawnStatusesSnapshot {
return {
statuses: {
alice: createStatusEntry(),
},
runId: 'run-1',
teamLaunchState: 'clean_success',
launchPhase: 'active',
expectedMembers: ['alice'],
updatedAt: '2026-05-22T10:00:00.000Z',
summary: createSummary(),
source: 'live',
...overrides,
};
}
describe('teamMemberSpawnSnapshotEquality', () => {
it('compares launch summaries by visible counts', () => {
expect(areLaunchSummaryCountsEqual(createSummary(), createSummary())).toBe(true);
expect(
areLaunchSummaryCountsEqual(createSummary(), createSummary({ permissionPendingCount: 1 }))
).toBe(false);
expect(areLaunchSummaryCountsEqual(undefined, undefined)).toBe(true);
expect(areLaunchSummaryCountsEqual(undefined, createSummary())).toBe(false);
});
it('compares expected members in stable order', () => {
expect(areExpectedMembersEqual(['alice', 'bob'], ['alice', 'bob'])).toBe(true);
expect(areExpectedMembersEqual(['alice', 'bob'], ['bob', 'alice'])).toBe(false);
expect(areExpectedMembersEqual(undefined, undefined)).toBe(true);
expect(areExpectedMembersEqual(undefined, [])).toBe(false);
});
it('ignores non-visible status churn and unordered pending permission ids', () => {
const left = createStatusEntry({
pendingPermissionRequestIds: ['perm-b', 'perm-a'],
updatedAt: '2026-05-22T10:00:00.000Z',
agentToolAccepted: true,
firstSpawnAcceptedAt: '2026-05-22T10:00:01.000Z',
lastHeartbeatAt: '2026-05-22T10:00:02.000Z',
livenessLastCheckedAt: '2026-05-22T10:00:03.000Z',
bootstrapStalled: true,
});
const right = createStatusEntry({
pendingPermissionRequestIds: ['perm-a', 'perm-b'],
updatedAt: '2026-05-22T10:05:00.000Z',
agentToolAccepted: false,
firstSpawnAcceptedAt: '2026-05-22T10:05:01.000Z',
lastHeartbeatAt: '2026-05-22T10:05:02.000Z',
livenessLastCheckedAt: '2026-05-22T10:05:03.000Z',
bootstrapStalled: false,
});
expect(areMemberSpawnStatusEntriesEqual(left, right)).toBe(true);
});
it('detects visible status entry changes', () => {
expect(
areMemberSpawnStatusEntriesEqual(
createStatusEntry(),
createStatusEntry({ runtimeDiagnosticSeverity: 'warning' })
)
).toBe(false);
expect(
areMemberSpawnStatusEntriesEqual(
createStatusEntry(),
createStatusEntry({ pendingPermissionRequestIds: ['perm-a'] })
)
).toBe(false);
});
it('compares per-member status maps by keys and semantic entries', () => {
expect(
areMemberSpawnStatusesEqual(
{
alice: createStatusEntry(),
bob: createStatusEntry({ runtimeModel: 'gpt-5.4' }),
},
{
bob: createStatusEntry({ runtimeModel: 'gpt-5.4' }),
alice: createStatusEntry(),
}
)
).toBe(true);
expect(
areMemberSpawnStatusesEqual(
{
alice: createStatusEntry(),
},
{
alice: createStatusEntry(),
bob: createStatusEntry(),
}
)
).toBe(false);
});
it('compares snapshots by semantic launch fields and ignores snapshot updatedAt churn', () => {
const left = createSnapshot({
updatedAt: '2026-05-22T10:00:00.000Z',
});
const right = createSnapshot({
updatedAt: '2026-05-22T10:05:00.000Z',
statuses: {
alice: createStatusEntry({
pendingPermissionRequestIds: ['perm-b', 'perm-a'],
updatedAt: '2026-05-22T10:05:00.000Z',
}),
},
});
expect(areMemberSpawnSnapshotsSemanticallyEqual(left, right)).toBe(true);
});
it('detects semantic snapshot changes', () => {
expect(
areMemberSpawnSnapshotsSemanticallyEqual(
createSnapshot(),
createSnapshot({ runId: 'run-2' })
)
).toBe(false);
expect(
areMemberSpawnSnapshotsSemanticallyEqual(
createSnapshot(),
createSnapshot({ expectedMembers: ['alice', 'bob'] })
)
).toBe(false);
expect(areMemberSpawnSnapshotsSemanticallyEqual(undefined, createSnapshot())).toBe(false);
});
});

View file

@ -0,0 +1,70 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
clearAllMemberSpawnStatusesIpcBackoffs,
clearMemberSpawnStatusesIpcBackoff,
getMemberSpawnStatusesIpcBackoffUntil,
hasMemberSpawnStatusesIpcBackoff,
isMemberSpawnStatusesIpcBackoffActive,
recordMemberSpawnStatusesIpcBackoffUntil,
recordMemberSpawnStatusesIpcRetryBackoff,
} from '../../../src/renderer/store/team/teamMemberSpawnStatusBackoff';
afterEach(() => {
vi.useRealTimers();
clearAllMemberSpawnStatusesIpcBackoffs();
});
describe('teamMemberSpawnStatusBackoff', () => {
it('defaults to no backoff for unknown teams', () => {
expect(getMemberSpawnStatusesIpcBackoffUntil('my-team')).toBe(0);
expect(hasMemberSpawnStatusesIpcBackoff('my-team')).toBe(false);
expect(isMemberSpawnStatusesIpcBackoffActive('my-team', 100)).toBe(false);
});
it('tracks active backoff deadlines by team', () => {
recordMemberSpawnStatusesIpcBackoffUntil('my-team', 150);
recordMemberSpawnStatusesIpcBackoffUntil('other-team', 250);
expect(getMemberSpawnStatusesIpcBackoffUntil('my-team')).toBe(150);
expect(isMemberSpawnStatusesIpcBackoffActive('my-team', 149)).toBe(true);
expect(isMemberSpawnStatusesIpcBackoffActive('my-team', 150)).toBe(false);
expect(isMemberSpawnStatusesIpcBackoffActive('other-team', 249)).toBe(true);
});
it('records retry backoff from Date.now by default', () => {
vi.setSystemTime(new Date('2026-05-22T07:00:00.000Z'));
recordMemberSpawnStatusesIpcRetryBackoff('my-team', 5_000);
expect(getMemberSpawnStatusesIpcBackoffUntil('my-team')).toBe(
new Date('2026-05-22T07:00:05.000Z').getTime()
);
});
it('records retry backoff from an explicit clock for deterministic callers', () => {
recordMemberSpawnStatusesIpcRetryBackoff('my-team', 5_000, 100);
expect(getMemberSpawnStatusesIpcBackoffUntil('my-team')).toBe(5_100);
});
it('clears one team backoff without touching others', () => {
recordMemberSpawnStatusesIpcBackoffUntil('my-team', 150);
recordMemberSpawnStatusesIpcBackoffUntil('other-team', 250);
clearMemberSpawnStatusesIpcBackoff('my-team');
expect(hasMemberSpawnStatusesIpcBackoff('my-team')).toBe(false);
expect(getMemberSpawnStatusesIpcBackoffUntil('other-team')).toBe(250);
});
it('clears all recorded backoffs', () => {
recordMemberSpawnStatusesIpcBackoffUntil('my-team', 150);
recordMemberSpawnStatusesIpcBackoffUntil('other-team', 250);
clearAllMemberSpawnStatusesIpcBackoffs();
expect(hasMemberSpawnStatusesIpcBackoff('my-team')).toBe(false);
expect(hasMemberSpawnStatusesIpcBackoff('other-team')).toBe(false);
});
});

View file

@ -0,0 +1,71 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
clearAllMemberSpawnUiEqualLastWarns,
clearMemberSpawnUiEqualLastWarn,
getMemberSpawnUiEqualLastWarnAt,
hasMemberSpawnUiEqualLastWarn,
shouldLogMemberSpawnUiEqualSuppressed,
} from '../../../src/renderer/store/team/teamMemberSpawnUiEqualWarningThrottle';
afterEach(() => {
vi.useRealTimers();
clearAllMemberSpawnUiEqualLastWarns();
});
describe('teamMemberSpawnUiEqualWarningThrottle', () => {
it('preserves the existing zero fallback boundary for unknown teams', () => {
expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 1_999)).toBe(false);
expect(hasMemberSpawnUiEqualLastWarn('my-team')).toBe(false);
expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 2_000)).toBe(true);
expect(getMemberSpawnUiEqualLastWarnAt('my-team')).toBe(2_000);
});
it('throttles repeated warnings until the boundary is reached', () => {
expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 10_000)).toBe(true);
expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 11_999)).toBe(false);
expect(getMemberSpawnUiEqualLastWarnAt('my-team')).toBe(10_000);
expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 12_000)).toBe(true);
expect(getMemberSpawnUiEqualLastWarnAt('my-team')).toBe(12_000);
});
it('tracks teams independently', () => {
expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 10_000)).toBe(true);
expect(shouldLogMemberSpawnUiEqualSuppressed('other-team', 2_000, 10_500)).toBe(true);
expect(getMemberSpawnUiEqualLastWarnAt('my-team')).toBe(10_000);
expect(getMemberSpawnUiEqualLastWarnAt('other-team')).toBe(10_500);
});
it('uses Date.now by default for production callers', () => {
vi.setSystemTime(new Date('2026-05-22T07:30:00.000Z'));
expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000)).toBe(true);
expect(getMemberSpawnUiEqualLastWarnAt('my-team')).toBe(
new Date('2026-05-22T07:30:00.000Z').getTime()
);
});
it('clears one team without touching other teams', () => {
shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 10_000);
shouldLogMemberSpawnUiEqualSuppressed('other-team', 2_000, 10_500);
clearMemberSpawnUiEqualLastWarn('my-team');
expect(hasMemberSpawnUiEqualLastWarn('my-team')).toBe(false);
expect(getMemberSpawnUiEqualLastWarnAt('other-team')).toBe(10_500);
});
it('clears all tracked warnings', () => {
shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 10_000);
shouldLogMemberSpawnUiEqualSuppressed('other-team', 2_000, 10_500);
clearAllMemberSpawnUiEqualLastWarns();
expect(hasMemberSpawnUiEqualLastWarn('my-team')).toBe(false);
expect(hasMemberSpawnUiEqualLastWarn('other-team')).toBe(false);
});
});

View file

@ -0,0 +1,186 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
areInboxMessageArraysEquivalent,
clearTeamMessageSelectorCaches,
clearTeamMessageSelectorCachesForTeam,
EMPTY_TEAM_MESSAGES_CACHE_ENTRY,
extractRetainedCanonicalOlderTail,
getCanonicalHeadSlice,
getTeamMessagesCacheEntry,
getTeamMessageSelectorCacheSnapshotForTeam,
pruneOptimisticMessages,
selectMemberMessagesForTeamMember,
selectTeamMessages,
type TeamMessagesCacheEntry,
type TeamMessagesCacheState,
upsertOptimisticTeamMessage,
} from '../../../src/renderer/store/team/teamMessagesCache';
import type { InboxMessage } from '../../../src/shared/types';
afterEach(() => {
clearTeamMessageSelectorCaches();
});
function createMessage(overrides: Partial<InboxMessage> & { messageId: string }): InboxMessage {
return {
from: 'lead',
to: 'alice',
text: overrides.messageId,
timestamp: '2026-03-12T10:00:00.000Z',
read: false,
...overrides,
};
}
function createEntry(overrides: Partial<TeamMessagesCacheEntry> = {}): TeamMessagesCacheEntry {
return {
...EMPTY_TEAM_MESSAGES_CACHE_ENTRY,
...overrides,
};
}
describe('teamMessagesCache', () => {
it('returns the immutable empty entry when a team has no cached messages', () => {
const state: TeamMessagesCacheState = { teamMessagesByName: {} };
expect(getTeamMessagesCacheEntry(state, 'missing-team')).toBe(EMPTY_TEAM_MESSAGES_CACHE_ENTRY);
});
it('upserts optimistic messages by durable id and keeps deterministic timestamp order', () => {
const first = upsertOptimisticTeamMessage(
createEntry(),
createMessage({
messageId: 'msg-new',
timestamp: '2026-03-12T10:00:03.000Z',
text: 'draft',
})
);
const second = upsertOptimisticTeamMessage(
first,
createMessage({
messageId: 'msg-old',
timestamp: '2026-03-12T10:00:01.000Z',
})
);
const replaced = upsertOptimisticTeamMessage(
second,
createMessage({
messageId: 'msg-new',
timestamp: '2026-03-12T10:00:03.000Z',
text: 'sent',
})
);
expect(replaced.optimisticMessages.map((message) => message.messageId)).toEqual([
'msg-old',
'msg-new',
]);
expect(replaced.optimisticMessages[1].text).toBe('sent');
});
it('compares semantic message arrays and prunes optimistic rows confirmed by canonical data', () => {
const canonical = [
createMessage({ messageId: 'msg-1', text: 'confirmed' }),
createMessage({ messageId: 'msg-2' }),
];
const equivalentCanonical = [
createMessage({ messageId: 'msg-1', text: 'confirmed' }),
createMessage({ messageId: 'msg-2' }),
];
const optimistic = [
createMessage({ messageId: 'msg-1', text: 'draft that arrived' }),
createMessage({ messageId: 'msg-local', text: 'still local' }),
];
expect(areInboxMessageArraysEquivalent(canonical, equivalentCanonical)).toBe(true);
expect(
areInboxMessageArraysEquivalent(canonical, [
createMessage({ messageId: 'msg-1', text: 'changed' }),
createMessage({ messageId: 'msg-2' }),
])
).toBe(false);
expect(pruneOptimisticMessages(optimistic, canonical).map((message) => message.messageId)).toEqual(
['msg-local']
);
});
it('retains already-loaded older tail only when the fresh head anchors into canonical data', () => {
const canonical = [
createMessage({ messageId: 'msg-4', timestamp: '2026-03-12T10:00:04.000Z' }),
createMessage({ messageId: 'msg-3', timestamp: '2026-03-12T10:00:03.000Z' }),
createMessage({ messageId: 'msg-2', timestamp: '2026-03-12T10:00:02.000Z' }),
createMessage({ messageId: 'msg-1', timestamp: '2026-03-12T10:00:01.000Z' }),
];
const freshHead = [
createMessage({ messageId: 'msg-5', timestamp: '2026-03-12T10:00:05.000Z' }),
createMessage({ messageId: 'msg-3', timestamp: '2026-03-12T10:00:03.000Z' }),
];
expect(getCanonicalHeadSlice(canonical, 2).map((message) => message.messageId)).toEqual([
'msg-4',
'msg-3',
]);
expect(
extractRetainedCanonicalOlderTail(canonical, freshHead)?.map((message) => message.messageId)
).toEqual(['msg-2', 'msg-1']);
expect(
extractRetainedCanonicalOlderTail(canonical, [createMessage({ messageId: 'disjoint' })])
).toBeNull();
});
it('memoizes merged and member-scoped selectors and clears team-scoped caches', () => {
const state: TeamMessagesCacheState = {
teamMessagesByName: {
'my-team': createEntry({
canonicalMessages: [
createMessage({
messageId: 'msg-1',
to: 'alice',
timestamp: '2026-03-12T10:00:01.000Z',
}),
createMessage({
messageId: 'msg-2',
to: 'bob',
timestamp: '2026-03-12T10:00:02.000Z',
}),
],
optimisticMessages: [
createMessage({
messageId: 'msg-3',
from: 'alice',
to: 'lead',
timestamp: '2026-03-12T10:00:03.000Z',
}),
],
}),
},
};
const firstTeamMessages = selectTeamMessages(state, 'my-team');
const secondTeamMessages = selectTeamMessages(state, 'my-team');
const firstAliceMessages = selectMemberMessagesForTeamMember(state, 'my-team', 'alice');
const secondAliceMessages = selectMemberMessagesForTeamMember(state, 'my-team', 'alice');
expect(firstTeamMessages).toBe(secondTeamMessages);
expect(firstAliceMessages).toBe(secondAliceMessages);
expect(firstTeamMessages.map((message) => message.messageId)).toEqual([
'msg-3',
'msg-2',
'msg-1',
]);
expect(firstAliceMessages.map((message) => message.messageId)).toEqual(['msg-3', 'msg-1']);
expect(getTeamMessageSelectorCacheSnapshotForTeam('my-team')).toEqual({
hasMergedMessagesSelector: true,
memberMessagesSelectorCount: 1,
});
clearTeamMessageSelectorCachesForTeam('my-team');
expect(getTeamMessageSelectorCacheSnapshotForTeam('my-team')).toEqual({
hasMergedMessagesSelector: false,
memberMessagesSelectorCount: 0,
});
});
});

View file

@ -0,0 +1,67 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
loadPersistedMessagesPanelMode,
savePersistedMessagesPanelMode,
} from '../../../src/renderer/store/team/teamMessagesPanelModePersistence';
import type { TeamMessagesPanelMode } from '../../../src/renderer/types/teamMessagesPanelMode';
const STORAGE_KEY = 'team:messagesPanelMode';
describe('teamMessagesPanelModePersistence', () => {
beforeEach(() => {
window.localStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('defaults to sidebar when no value was persisted', () => {
expect(loadPersistedMessagesPanelMode()).toBe('sidebar');
});
it('loads each supported persisted mode', () => {
const modes: TeamMessagesPanelMode[] = [
'sidebar',
'inline',
'bottom-sheet',
'floating-composer',
];
for (const mode of modes) {
window.localStorage.setItem(STORAGE_KEY, mode);
expect(loadPersistedMessagesPanelMode()).toBe(mode);
}
});
it('falls back to sidebar for invalid persisted values', () => {
window.localStorage.setItem(STORAGE_KEY, 'bad-mode');
expect(loadPersistedMessagesPanelMode()).toBe('sidebar');
});
it('persists the selected mode', () => {
savePersistedMessagesPanelMode('inline');
expect(window.localStorage.getItem(STORAGE_KEY)).toBe('inline');
});
it('falls back to sidebar when localStorage read fails', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('blocked');
});
expect(loadPersistedMessagesPanelMode()).toBe('sidebar');
});
it('ignores localStorage write failures', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('blocked');
});
expect(() => savePersistedMessagesPanelMode('bottom-sheet')).not.toThrow();
});
});

View file

@ -0,0 +1,65 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
clearAllPendingReplyRefreshWaits,
clearPendingReplyRefreshWaits,
getActiveTeamPendingReplyWaits,
hasActiveTeamPendingReplyWait,
setPendingReplyRefreshEnabled,
} from '../../../src/renderer/store/team/teamPendingReplyWaits';
afterEach(() => {
clearAllPendingReplyRefreshWaits();
});
describe('teamPendingReplyWaits', () => {
it('tracks active teams with at least one enabled source', () => {
expect(setPendingReplyRefreshEnabled('my-team', 'tab-a', true)).toBe(true);
expect(setPendingReplyRefreshEnabled('other-team', 'tab-b', true)).toBe(true);
expect(hasActiveTeamPendingReplyWait('my-team')).toBe(true);
expect(hasActiveTeamPendingReplyWait('other-team')).toBe(true);
expect(getActiveTeamPendingReplyWaits()).toEqual(new Set(['my-team', 'other-team']));
});
it('keeps a team active until the last source is disabled', () => {
setPendingReplyRefreshEnabled('my-team', 'tab-a', true);
setPendingReplyRefreshEnabled('my-team', 'tab-b', true);
expect(setPendingReplyRefreshEnabled('my-team', 'tab-b', false)).toBe(true);
expect(hasActiveTeamPendingReplyWait('my-team')).toBe(true);
expect(getActiveTeamPendingReplyWaits()).toEqual(new Set(['my-team']));
expect(setPendingReplyRefreshEnabled('my-team', 'tab-a', false)).toBe(false);
expect(hasActiveTeamPendingReplyWait('my-team')).toBe(false);
expect(getActiveTeamPendingReplyWaits().size).toBe(0);
});
it('is idempotent for repeated enables from the same source', () => {
setPendingReplyRefreshEnabled('my-team', 'tab-a', true);
setPendingReplyRefreshEnabled('my-team', 'tab-a', true);
expect(setPendingReplyRefreshEnabled('my-team', 'tab-a', false)).toBe(false);
expect(hasActiveTeamPendingReplyWait('my-team')).toBe(false);
});
it('returns false when disabling a source that has no active wait', () => {
expect(setPendingReplyRefreshEnabled('missing-team', 'tab-a', false)).toBe(false);
expect(getActiveTeamPendingReplyWaits().size).toBe(0);
});
it('clears waits by team or globally', () => {
setPendingReplyRefreshEnabled('my-team', 'tab-a', true);
setPendingReplyRefreshEnabled('other-team', 'tab-b', true);
clearPendingReplyRefreshWaits('my-team');
expect(hasActiveTeamPendingReplyWait('my-team')).toBe(false);
expect(getActiveTeamPendingReplyWaits()).toEqual(new Set(['other-team']));
clearAllPendingReplyRefreshWaits();
expect(hasActiveTeamPendingReplyWait('other-team')).toBe(false);
expect(getActiveTeamPendingReplyWaits().size).toBe(0);
});
});

View file

@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import {
isActiveProvisioningState,
isTerminalProvisioningState,
shouldIgnoreProvisioningProgressRegression,
} from '../../../src/renderer/store/team/teamProvisioningStateRules';
import type { TeamProvisioningProgress } from '../../../src/shared/types';
type ProgressState = TeamProvisioningProgress['state'];
const activeStates: ProgressState[] = [
'validating',
'spawning',
'configuring',
'assembling',
'finalizing',
'verifying',
];
const terminalStates: ProgressState[] = ['ready', 'failed', 'disconnected', 'cancelled'];
describe('teamProvisioningStateRules', () => {
it('classifies active provisioning states', () => {
for (const state of activeStates) {
expect(isActiveProvisioningState(state), state).toBe(true);
expect(isTerminalProvisioningState(state), state).toBe(false);
}
});
it('classifies terminal provisioning states', () => {
for (const state of terminalStates) {
expect(isTerminalProvisioningState(state), state).toBe(true);
expect(isActiveProvisioningState(state), state).toBe(false);
}
});
it('allows active state progressions and regressions to be processed', () => {
expect(shouldIgnoreProvisioningProgressRegression('spawning', 'validating')).toBe(false);
expect(shouldIgnoreProvisioningProgressRegression('validating', 'spawning')).toBe(false);
expect(shouldIgnoreProvisioningProgressRegression('verifying', 'ready')).toBe(false);
});
it('prevents ready from regressing except to disconnected', () => {
expect(shouldIgnoreProvisioningProgressRegression('ready', 'validating')).toBe(true);
expect(shouldIgnoreProvisioningProgressRegression('ready', 'failed')).toBe(true);
expect(shouldIgnoreProvisioningProgressRegression('ready', 'cancelled')).toBe(true);
expect(shouldIgnoreProvisioningProgressRegression('ready', 'ready')).toBe(false);
expect(shouldIgnoreProvisioningProgressRegression('ready', 'disconnected')).toBe(false);
});
it('locks failed, cancelled, and disconnected to their current terminal state', () => {
expect(shouldIgnoreProvisioningProgressRegression('failed', 'failed')).toBe(false);
expect(shouldIgnoreProvisioningProgressRegression('failed', 'ready')).toBe(true);
expect(shouldIgnoreProvisioningProgressRegression('cancelled', 'cancelled')).toBe(false);
expect(shouldIgnoreProvisioningProgressRegression('cancelled', 'spawning')).toBe(true);
expect(shouldIgnoreProvisioningProgressRegression('disconnected', 'disconnected')).toBe(false);
expect(shouldIgnoreProvisioningProgressRegression('disconnected', 'ready')).toBe(true);
});
});

View file

@ -0,0 +1,83 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
clearAllTeamRefreshBurstDiagnostics,
clearTeamRefreshBurstDiagnostics,
getTeamRefreshBurstDiagnosticForTests,
hasTeamRefreshBurstDiagnostics,
noteTeamRefreshBurst,
} from '../../../src/renderer/store/team/teamRefreshBurstDiagnostics';
afterEach(() => {
clearAllTeamRefreshBurstDiagnostics();
});
describe('teamRefreshBurstDiagnostics store', () => {
it('creates a window on the first refresh note', () => {
expect(noteTeamRefreshBurst('my-team', 4_000, 10_000)).toBe(1);
expect(getTeamRefreshBurstDiagnosticForTests('my-team')).toEqual({
windowStartedAt: 10_000,
count: 1,
lastWarnAt: 0,
});
expect(hasTeamRefreshBurstDiagnostics('my-team')).toBe(true);
});
it('increments inside the active burst window', () => {
expect(noteTeamRefreshBurst('my-team', 4_000, 10_000)).toBe(1);
expect(noteTeamRefreshBurst('my-team', 4_000, 13_999)).toBe(2);
expect(noteTeamRefreshBurst('my-team', 4_000, 14_000)).toBe(3);
expect(getTeamRefreshBurstDiagnosticForTests('my-team')).toEqual({
windowStartedAt: 10_000,
count: 3,
lastWarnAt: 0,
});
});
it('resets only after now is strictly beyond the burst window', () => {
expect(noteTeamRefreshBurst('my-team', 4_000, 10_000)).toBe(1);
expect(noteTeamRefreshBurst('my-team', 4_000, 14_001)).toBe(1);
expect(getTeamRefreshBurstDiagnosticForTests('my-team')).toEqual({
windowStartedAt: 14_001,
count: 1,
lastWarnAt: 0,
});
});
it('tracks each team independently', () => {
noteTeamRefreshBurst('my-team', 4_000, 10_000);
noteTeamRefreshBurst('my-team', 4_000, 10_500);
noteTeamRefreshBurst('other-team', 4_000, 11_000);
expect(getTeamRefreshBurstDiagnosticForTests('my-team')?.count).toBe(2);
expect(getTeamRefreshBurstDiagnosticForTests('other-team')?.count).toBe(1);
});
it('clears one team or all diagnostics', () => {
noteTeamRefreshBurst('my-team', 4_000, 10_000);
noteTeamRefreshBurst('other-team', 4_000, 11_000);
clearTeamRefreshBurstDiagnostics('my-team');
expect(hasTeamRefreshBurstDiagnostics('my-team')).toBe(false);
expect(hasTeamRefreshBurstDiagnostics('other-team')).toBe(true);
clearAllTeamRefreshBurstDiagnostics();
expect(hasTeamRefreshBurstDiagnostics('other-team')).toBe(false);
});
it('returns defensive diagnostic snapshots for tests', () => {
noteTeamRefreshBurst('my-team', 4_000, 10_000);
const snapshot = getTeamRefreshBurstDiagnosticForTests('my-team');
if (snapshot) {
snapshot.count = 99;
}
expect(getTeamRefreshBurstDiagnosticForTests('my-team')?.count).toBe(1);
});
});

View file

@ -0,0 +1,210 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
clearResolvedMemberSelectorCaches,
getResolvedMemberSelectorCacheSnapshotForTeam,
selectResolvedMemberForTeamName,
selectResolvedMembersForTeamName,
shouldPreserveSelectedTeamSnapshot,
} from '../../../src/renderer/store/team/teamResolvedMembers';
import type {
TeamMemberActivityMeta,
TeamMemberSnapshot,
TeamSummary,
TeamTask,
TeamViewSnapshot,
} from '../../../src/shared/types';
function createTask(overrides: Partial<TeamTask> = {}): TeamTask {
return {
id: 'task-1',
subject: 'Task',
owner: 'alice',
status: 'in_progress',
...overrides,
};
}
function createSnapshot(overrides: Partial<TeamViewSnapshot> = {}): TeamViewSnapshot {
return {
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
...overrides,
} as TeamViewSnapshot;
}
function createState(
snapshot: TeamViewSnapshot,
options: {
summary?: TeamSummary;
meta?: TeamMemberActivityMeta;
} = {}
) {
return {
selectedTeamName: snapshot.teamName,
selectedTeamData: snapshot,
teamDataCacheByName: { [snapshot.teamName]: snapshot },
memberActivityMetaByTeam: options.meta ? { [snapshot.teamName]: options.meta } : {},
teamByName: options.summary ? { [snapshot.teamName]: options.summary } : {},
};
}
describe('teamResolvedMembers', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-17T12:00:00.000Z'));
clearResolvedMemberSelectorCaches();
});
afterEach(() => {
vi.useRealTimers();
clearResolvedMemberSelectorCaches();
});
it('builds config fallback members when runtime snapshots are empty', () => {
const snapshot = createSnapshot({
config: {
name: 'My Team',
members: [
{ name: 'team-lead', role: 'Lead' },
{ name: 'alice', agentId: 'agent-a', role: 'Engineer' },
{ name: 'Alice', agentId: 'duplicate' },
],
},
tasks: [
createTask({ id: 'task-active', owner: 'alice', status: 'in_progress' }),
createTask({ id: 'task-done', owner: 'alice', status: 'completed' }),
],
});
const members = selectResolvedMembersForTeamName(createState(snapshot), 'my-team');
expect(members.map((member) => member.name)).toEqual(['team-lead', 'alice']);
expect(members[1]).toMatchObject({
name: 'alice',
agentId: 'agent-a',
currentTaskId: 'task-active',
taskCount: 2,
role: 'Engineer',
status: 'active',
messageCount: 0,
lastActiveAt: null,
});
});
it('builds summary fallback members with a lead when config and runtime snapshots are empty', () => {
const snapshot = createSnapshot();
const summary = {
teamName: 'my-team',
displayName: 'My Team',
memberCount: 2,
taskCount: 0,
lastActivity: null,
leadName: 'lead-one',
leadColor: '#fff',
members: [
{ name: 'lead-one', role: 'Lead' },
{ name: 'bob', agentId: 'agent-b', role: 'Reviewer', color: '#123456' },
{ name: 'Bob', agentId: 'duplicate' },
],
} as TeamSummary;
const members = selectResolvedMembersForTeamName(createState(snapshot, { summary }), 'my-team');
expect(members.map((member) => member.name)).toEqual(['lead-one', 'bob']);
expect(members[0]).toMatchObject({ agentType: 'team-lead', role: 'Team Lead' });
expect(members[1]).toMatchObject({
agentId: 'agent-b',
role: 'Reviewer',
color: '#123456',
});
});
it('memoizes selector results until resolved-member cache is cleared', () => {
const snapshot = createSnapshot({
members: [{ name: 'alice', currentTaskId: null, taskCount: 0 } as TeamMemberSnapshot],
});
const state = createState(snapshot);
const firstMembers = selectResolvedMembersForTeamName(state, 'my-team');
const secondMembers = selectResolvedMembersForTeamName(state, 'my-team');
const firstAlice = selectResolvedMemberForTeamName(state, 'my-team', 'alice');
const secondAlice = selectResolvedMemberForTeamName(state, 'my-team', 'alice');
expect(secondMembers).toBe(firstMembers);
expect(secondAlice).toBe(firstAlice);
expect(getResolvedMemberSelectorCacheSnapshotForTeam('my-team')).toEqual({
hasResolvedMembersSelector: true,
resolvedMemberSelectorCount: 1,
});
clearResolvedMemberSelectorCaches();
expect(selectResolvedMembersForTeamName(state, 'my-team')).not.toBe(firstMembers);
expect(getResolvedMemberSelectorCacheSnapshotForTeam('my-team')).toEqual({
hasResolvedMembersSelector: true,
resolvedMemberSelectorCount: 0,
});
});
it('derives activity status from member activity metadata', () => {
const snapshot = createSnapshot({
members: [{ name: 'alice', currentTaskId: null, taskCount: 0 } as TeamMemberSnapshot],
});
const meta = {
teamName: 'my-team',
feedRevision: 'rev-1',
computedAt: '2026-04-17T12:00:00.000Z',
members: {
alice: {
memberName: 'alice',
lastAuthoredMessageAt: '2026-04-17T11:57:00.000Z',
messageCountExact: 3,
latestAuthoredMessageSignalsTermination: false,
},
},
} as TeamMemberActivityMeta;
expect(selectResolvedMemberForTeamName(createState(snapshot, { meta }), 'my-team', 'alice'))
.toMatchObject({
status: 'active',
messageCount: 3,
lastActiveAt: '2026-04-17T11:57:00.000Z',
});
});
it('preserves the selected snapshot when an incoming empty snapshot is confirmed by summary', () => {
const current = createSnapshot({
members: [{ name: 'alice', currentTaskId: null, taskCount: 0 } as TeamMemberSnapshot],
});
const incoming = createSnapshot({ members: [], config: { name: 'My Team' } });
const summary = {
teamName: 'my-team',
displayName: 'My Team',
memberCount: 1,
expectedMemberCount: 1,
taskCount: 0,
lastActivity: null,
members: [{ name: 'alice' }],
} as TeamSummary;
expect(shouldPreserveSelectedTeamSnapshot(current, current, incoming, summary)).toBe(true);
});
it('does not preserve the selected snapshot when incoming data has a config roster', () => {
const current = createSnapshot({
members: [{ name: 'alice', currentTaskId: null, taskCount: 0 } as TeamMemberSnapshot],
});
const incoming = createSnapshot({
members: [],
config: { name: 'My Team', members: [{ name: 'bob' }] },
});
expect(shouldPreserveSelectedTeamSnapshot(current, current, incoming, undefined)).toBe(false);
});
});

View file

@ -0,0 +1,203 @@
import { describe, expect, it } from 'vitest';
import {
buildTeamScopedProgressTombstones,
collectTeamScopedStateRemovals,
collectTeamScopedVisibleLoadingResets,
} from '../../../src/renderer/store/team/teamScopedStateCleanup';
const teamScopedRecordKeys = [
'teamDataCacheByName',
'teamAgentRuntimeByTeam',
'teamMessagesByName',
'memberActivityMetaByTeam',
'provisioningSnapshotByTeam',
'currentProvisioningRunIdByTeam',
'currentRuntimeRunIdByTeam',
'provisioningStartedAtFloorByTeam',
'leadActivityByTeam',
'leadContextByTeam',
'activeTaskLogActivityByTeam',
'activeToolsByTeam',
'finishedVisibleByTeam',
'toolHistoryByTeam',
'memberSpawnStatusesByTeam',
'memberSpawnSnapshotsByTeam',
'provisioningErrorByTeam',
] as const;
function buildRecord(label: string): Record<string, unknown> {
return {
'my-team': `${label}:mine`,
'other-team': `${label}:other`,
};
}
function buildRemovalState(): Parameters<typeof collectTeamScopedStateRemovals>[0] {
return {
provisioningRuns: {
'run-mine-1': { teamName: 'my-team' },
'run-other': { teamName: 'other-team' },
'run-mine-2': { teamName: 'my-team' },
},
teamDataCacheByName: buildRecord('teamDataCacheByName'),
teamAgentRuntimeByTeam: buildRecord('teamAgentRuntimeByTeam'),
teamMessagesByName: buildRecord('teamMessagesByName'),
memberActivityMetaByTeam: buildRecord('memberActivityMetaByTeam'),
provisioningSnapshotByTeam: buildRecord('provisioningSnapshotByTeam'),
currentProvisioningRunIdByTeam: buildRecord('currentProvisioningRunIdByTeam'),
currentRuntimeRunIdByTeam: buildRecord('currentRuntimeRunIdByTeam'),
provisioningStartedAtFloorByTeam: buildRecord('provisioningStartedAtFloorByTeam'),
leadActivityByTeam: buildRecord('leadActivityByTeam'),
leadContextByTeam: buildRecord('leadContextByTeam'),
activeTaskLogActivityByTeam: buildRecord('activeTaskLogActivityByTeam'),
activeToolsByTeam: buildRecord('activeToolsByTeam'),
finishedVisibleByTeam: buildRecord('finishedVisibleByTeam'),
toolHistoryByTeam: buildRecord('toolHistoryByTeam'),
memberSpawnStatusesByTeam: buildRecord('memberSpawnStatusesByTeam'),
memberSpawnSnapshotsByTeam: buildRecord('memberSpawnSnapshotsByTeam'),
provisioningErrorByTeam: buildRecord('provisioningErrorByTeam'),
};
}
describe('teamScopedStateCleanup', () => {
it('resets visible team loading and message loading flags for the scoped team', () => {
const otherEntry = {
loadingHead: true,
loadingOlder: false,
marker: 'other',
};
const patch = collectTeamScopedVisibleLoadingResets(
{
teamMessagesByName: {
'my-team': {
loadingHead: true,
loadingOlder: true,
marker: 'mine',
},
'other-team': otherEntry,
},
selectedTeamName: 'my-team',
selectedTeamLoading: true,
selectedTeamError: 'Boom',
},
'my-team'
);
expect(patch).toEqual({
teamMessagesByName: {
'my-team': {
loadingHead: false,
loadingOlder: false,
marker: 'mine',
},
'other-team': otherEntry,
},
selectedTeamLoading: false,
selectedTeamError: null,
});
});
it('does not emit visible loading changes when the scoped team is already idle', () => {
const patch = collectTeamScopedVisibleLoadingResets(
{
teamMessagesByName: {
'my-team': {
loadingHead: false,
loadingOlder: false,
},
},
selectedTeamName: 'other-team',
selectedTeamLoading: false,
selectedTeamError: null,
},
'my-team'
);
expect(patch).toEqual({});
});
it('removes scoped team records and provisioning runs while preserving other teams', () => {
const patch = collectTeamScopedStateRemovals(buildRemovalState(), 'my-team');
expect(patch.provisioningRuns).toEqual({
'run-other': { teamName: 'other-team' },
});
for (const key of teamScopedRecordKeys) {
expect(patch[key]).toEqual({
'other-team': `${key}:other`,
});
}
});
it('does not emit removal changes when the team is absent', () => {
const state = buildRemovalState();
const patch = collectTeamScopedStateRemovals(state, 'missing-team');
expect(patch).toEqual({});
});
it('tombstones current provisioning and runtime run ids for the scoped team', () => {
const tombstones = buildTeamScopedProgressTombstones(
{
currentProvisioningRunIdByTeam: {
'my-team': 'provisioning-run-1',
'other-team': 'provisioning-run-2',
},
currentRuntimeRunIdByTeam: {
'my-team': 'runtime-run-1',
},
ignoredProvisioningRunIds: {
old: 'old-team',
},
ignoredRuntimeRunIds: {
'old-runtime': 'old-team',
},
provisioningStartedAtFloorByTeam: {
'other-team': '2026-01-01T00:00:00.000Z',
},
},
'my-team',
'2026-05-22T10:00:00.000Z'
);
expect(tombstones).toEqual({
ignoredProvisioningRunIds: {
old: 'old-team',
'provisioning-run-1': 'my-team',
},
ignoredRuntimeRunIds: {
'old-runtime': 'old-team',
'runtime-run-1': 'my-team',
},
provisioningStartedAtFloorByTeam: {
'other-team': '2026-01-01T00:00:00.000Z',
'my-team': '2026-05-22T10:00:00.000Z',
},
});
});
it('still records a floor when there are no current run ids to tombstone', () => {
const tombstones = buildTeamScopedProgressTombstones(
{
currentProvisioningRunIdByTeam: {},
currentRuntimeRunIdByTeam: {
'my-team': null,
},
ignoredProvisioningRunIds: {},
ignoredRuntimeRunIds: {},
provisioningStartedAtFloorByTeam: {},
},
'my-team',
'2026-05-22T10:00:00.000Z'
);
expect(tombstones).toEqual({
ignoredProvisioningRunIds: {},
ignoredRuntimeRunIds: {},
provisioningStartedAtFloorByTeam: {
'my-team': '2026-05-22T10:00:00.000Z',
},
});
});
});

View file

@ -0,0 +1,117 @@
import { describe, expect, it } from 'vitest';
import {
structurallySharePlainValue,
structurallyShareTeamSnapshot,
} from '../../../src/renderer/store/team/teamSnapshotStructuralSharing';
import type { TeamViewSnapshot } from '../../../src/shared/types';
function createSnapshot(overrides: Partial<TeamViewSnapshot> = {}): TeamViewSnapshot {
return {
teamName: 'my-team',
config: { name: 'My Team' },
members: [],
tasks: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
...overrides,
};
}
describe('teamSnapshotStructuralSharing', () => {
it('returns the next snapshot when there is no previous snapshot', () => {
const next = createSnapshot();
expect(structurallyShareTeamSnapshot(null, next)).toBe(next);
expect(structurallyShareTeamSnapshot(undefined, next)).toBe(next);
});
it('preserves the previous snapshot reference when values are deeply equal', () => {
const previous = createSnapshot({
config: { name: 'My Team', description: 'Same description' },
warnings: ['same warning'],
isAlive: true,
});
const next = createSnapshot({
config: { name: 'My Team', description: 'Same description' },
warnings: ['same warning'],
isAlive: true,
});
expect(structurallyShareTeamSnapshot(previous, next)).toBe(previous);
});
it('replaces only changed snapshot branches while sharing unchanged branches', () => {
const previousWarnings = ['same warning'];
const previous = createSnapshot({
config: { name: 'My Team', description: 'Old description' },
warnings: previousWarnings,
isAlive: true,
});
const next = createSnapshot({
config: { name: 'My Team', description: 'New description' },
warnings: ['same warning'],
isAlive: true,
});
const shared = structurallyShareTeamSnapshot(previous, next);
expect(shared).not.toBe(previous);
expect(shared).toEqual(next);
expect(shared.config).not.toBe(previous.config);
expect(shared.warnings).toBe(previousWarnings);
expect(shared.members).toBe(previous.members);
expect(shared.tasks).toBe(previous.tasks);
expect(shared.kanbanState).toBe(previous.kanbanState);
expect(shared.processes).toBe(previous.processes);
});
it('shares unchanged array entries and replaces changed entries', () => {
const previous = [
{ id: 'task-1', title: 'Keep' },
{ id: 'task-2', title: 'Old' },
];
const next = [
{ id: 'task-1', title: 'Keep' },
{ id: 'task-2', title: 'New' },
];
const shared = structurallySharePlainValue(previous, next);
expect(shared).not.toBe(previous);
expect(shared).toEqual(next);
expect(shared[0]).toBe(previous[0]);
expect(shared[1]).not.toBe(previous[1]);
});
it('replaces objects when keys are added or removed', () => {
const previous = { id: 'task-1', title: 'Same', extra: true };
const next = { id: 'task-1', title: 'Same' };
const shared = structurallySharePlainValue(previous, next);
expect(shared).not.toBe(previous);
expect(shared).toEqual(next);
});
it('treats null-prototype objects as plain values', () => {
const previous = Object.assign(Object.create(null) as Record<string, unknown>, {
id: 'task-1',
title: 'Same',
});
const next = Object.assign(Object.create(null) as Record<string, unknown>, {
id: 'task-1',
title: 'Same',
});
expect(structurallySharePlainValue(previous, next)).toBe(previous);
});
it('replaces non-plain objects instead of traversing them', () => {
const previous = new Date('2026-05-22T10:00:00.000Z');
const next = new Date('2026-05-22T10:00:00.000Z');
expect(structurallySharePlainValue(previous, next)).toBe(next);
});
});

View file

@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest';
import { parseToolApprovalSettings } from '../../../src/renderer/store/team/teamToolApprovalSettings';
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '../../../src/shared/types/team';
describe('teamToolApprovalSettings', () => {
it('returns defaults for missing or invalid JSON', () => {
expect(parseToolApprovalSettings(null)).toBe(DEFAULT_TOOL_APPROVAL_SETTINGS);
expect(parseToolApprovalSettings('')).toBe(DEFAULT_TOOL_APPROVAL_SETTINGS);
expect(parseToolApprovalSettings('{not json')).toBe(DEFAULT_TOOL_APPROVAL_SETTINGS);
});
it('parses valid complete settings', () => {
expect(
parseToolApprovalSettings(
JSON.stringify({
autoAllowAll: true,
autoAllowFileEdits: true,
autoAllowSafeBash: true,
timeoutAction: 'allow',
timeoutSeconds: 120,
})
)
).toEqual({
autoAllowAll: true,
autoAllowFileEdits: true,
autoAllowSafeBash: true,
timeoutAction: 'allow',
timeoutSeconds: 120,
});
});
it('falls back per field when values have invalid types', () => {
expect(
parseToolApprovalSettings(
JSON.stringify({
autoAllowAll: 'yes',
autoAllowFileEdits: true,
autoAllowSafeBash: 1,
timeoutAction: 'maybe',
timeoutSeconds: '60',
})
)
).toEqual({
...DEFAULT_TOOL_APPROVAL_SETTINGS,
autoAllowFileEdits: true,
});
});
it('accepts timeout actions allow, deny, and wait', () => {
expect(parseToolApprovalSettings(JSON.stringify({ timeoutAction: 'allow' })).timeoutAction).toBe(
'allow'
);
expect(parseToolApprovalSettings(JSON.stringify({ timeoutAction: 'deny' })).timeoutAction).toBe(
'deny'
);
expect(parseToolApprovalSettings(JSON.stringify({ timeoutAction: 'wait' })).timeoutAction).toBe(
'wait'
);
});
it('accepts timeout seconds at inclusive boundaries', () => {
expect(parseToolApprovalSettings(JSON.stringify({ timeoutSeconds: 5 })).timeoutSeconds).toBe(5);
expect(parseToolApprovalSettings(JSON.stringify({ timeoutSeconds: 300 })).timeoutSeconds).toBe(
300
);
});
it('rejects timeout seconds outside allowed boundaries or non-finite values', () => {
expect(parseToolApprovalSettings(JSON.stringify({ timeoutSeconds: 4 })).timeoutSeconds).toBe(
DEFAULT_TOOL_APPROVAL_SETTINGS.timeoutSeconds
);
expect(parseToolApprovalSettings(JSON.stringify({ timeoutSeconds: 301 })).timeoutSeconds).toBe(
DEFAULT_TOOL_APPROVAL_SETTINGS.timeoutSeconds
);
expect(
parseToolApprovalSettings(JSON.stringify({ timeoutSeconds: Number.POSITIVE_INFINITY }))
.timeoutSeconds
).toBe(DEFAULT_TOOL_APPROVAL_SETTINGS.timeoutSeconds);
});
});