chore: merge team ipc clean architecture refactor
This commit is contained in:
commit
f4ff278ac4
46 changed files with 5134 additions and 2066 deletions
|
|
@ -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(
|
||||
|
|
|
|||
240
src/main/ipc/teams/teamMessageNotificationScanner.ts
Normal file
240
src/main/ipc/teams/teamMessageNotificationScanner.ts
Normal 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
108
src/renderer/store/team/teamAgentRuntimeSnapshotEquality.ts
Normal file
108
src/renderer/store/team/teamAgentRuntimeSnapshotEquality.ts
Normal 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;
|
||||
}
|
||||
21
src/renderer/store/team/teamDataRefreshTimestamps.ts
Normal file
21
src/renderer/store/team/teamDataRefreshTimestamps.ts
Normal 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();
|
||||
}
|
||||
39
src/renderer/store/team/teamDataRequestKeys.ts
Normal file
39
src/renderer/store/team/teamDataRequestKeys.ts
Normal 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`);
|
||||
}
|
||||
47
src/renderer/store/team/teamDataSelectors.ts
Normal file
47
src/renderer/store/team/teamDataSelectors.ts
Normal 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;
|
||||
}
|
||||
33
src/renderer/store/team/teamErrorPolicies.ts
Normal file
33
src/renderer/store/team/teamErrorPolicies.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
219
src/renderer/store/team/teamGraphLayout.ts
Normal file
219
src/renderer/store/team/teamGraphLayout.ts
Normal 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;
|
||||
}
|
||||
89
src/renderer/store/team/teamLaunchParams.ts
Normal file
89
src/renderer/store/team/teamLaunchParams.ts
Normal 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
|
||||
);
|
||||
}
|
||||
25
src/renderer/store/team/teamLocalStateEpoch.ts
Normal file
25
src/renderer/store/team/teamLocalStateEpoch.ts
Normal 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();
|
||||
}
|
||||
64
src/renderer/store/team/teamMemberActivityMeta.ts
Normal file
64
src/renderer/store/team/teamMemberActivityMeta.ts
Normal 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;
|
||||
}
|
||||
106
src/renderer/store/team/teamMemberSpawnSnapshotEquality.ts
Normal file
106
src/renderer/store/team/teamMemberSpawnSnapshotEquality.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
39
src/renderer/store/team/teamMemberSpawnStatusBackoff.ts
Normal file
39
src/renderer/store/team/teamMemberSpawnStatusBackoff.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
291
src/renderer/store/team/teamMessagesCache.ts
Normal file
291
src/renderer/store/team/teamMessagesCache.ts
Normal 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;
|
||||
}
|
||||
29
src/renderer/store/team/teamMessagesPanelModePersistence.ts
Normal file
29
src/renderer/store/team/teamMessagesPanelModePersistence.ts
Normal 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
|
||||
}
|
||||
}
|
||||
45
src/renderer/store/team/teamPendingReplyWaits.ts
Normal file
45
src/renderer/store/team/teamPendingReplyWaits.ts
Normal 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;
|
||||
}
|
||||
44
src/renderer/store/team/teamProvisioningStateRules.ts
Normal file
44
src/renderer/store/team/teamProvisioningStateRules.ts
Normal 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;
|
||||
}
|
||||
48
src/renderer/store/team/teamRefreshBurstDiagnostics.ts
Normal file
48
src/renderer/store/team/teamRefreshBurstDiagnostics.ts
Normal 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();
|
||||
}
|
||||
533
src/renderer/store/team/teamResolvedMembers.ts
Normal file
533
src/renderer/store/team/teamResolvedMembers.ts
Normal 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;
|
||||
}
|
||||
190
src/renderer/store/team/teamScopedStateCleanup.ts
Normal file
190
src/renderer/store/team/teamScopedStateCleanup.ts
Normal 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'
|
||||
>;
|
||||
}
|
||||
61
src/renderer/store/team/teamSnapshotStructuralSharing.ts
Normal file
61
src/renderer/store/team/teamSnapshotStructuralSharing.ts
Normal 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);
|
||||
}
|
||||
42
src/renderer/store/team/teamToolApprovalSettings.ts
Normal file
42
src/renderer/store/team/teamToolApprovalSettings.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
171
test/main/ipc/teams/teamMessageNotificationScanner.test.ts
Normal file
171
test/main/ipc/teams/teamMessageNotificationScanner.test.ts
Normal 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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
204
test/renderer/store/teamAgentRuntimeSnapshotEquality.test.ts
Normal file
204
test/renderer/store/teamAgentRuntimeSnapshotEquality.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
60
test/renderer/store/teamDataRefreshTimestamps.test.ts
Normal file
60
test/renderer/store/teamDataRefreshTimestamps.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
58
test/renderer/store/teamDataRequestKeys.test.ts
Normal file
58
test/renderer/store/teamDataRequestKeys.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
95
test/renderer/store/teamDataSelectors.test.ts
Normal file
95
test/renderer/store/teamDataSelectors.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
63
test/renderer/store/teamErrorPolicies.test.ts
Normal file
63
test/renderer/store/teamErrorPolicies.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
133
test/renderer/store/teamGraphLayout.test.ts
Normal file
133
test/renderer/store/teamGraphLayout.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
132
test/renderer/store/teamLaunchParams.test.ts
Normal file
132
test/renderer/store/teamLaunchParams.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
57
test/renderer/store/teamLocalStateEpoch.test.ts
Normal file
57
test/renderer/store/teamLocalStateEpoch.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
197
test/renderer/store/teamMemberActivityMeta.test.ts
Normal file
197
test/renderer/store/teamMemberActivityMeta.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
186
test/renderer/store/teamMemberSpawnSnapshotEquality.test.ts
Normal file
186
test/renderer/store/teamMemberSpawnSnapshotEquality.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
70
test/renderer/store/teamMemberSpawnStatusBackoff.test.ts
Normal file
70
test/renderer/store/teamMemberSpawnStatusBackoff.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
186
test/renderer/store/teamMessagesCache.test.ts
Normal file
186
test/renderer/store/teamMessagesCache.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
67
test/renderer/store/teamMessagesPanelModePersistence.test.ts
Normal file
67
test/renderer/store/teamMessagesPanelModePersistence.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
65
test/renderer/store/teamPendingReplyWaits.test.ts
Normal file
65
test/renderer/store/teamPendingReplyWaits.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
61
test/renderer/store/teamProvisioningStateRules.test.ts
Normal file
61
test/renderer/store/teamProvisioningStateRules.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
83
test/renderer/store/teamRefreshBurstDiagnosticsStore.test.ts
Normal file
83
test/renderer/store/teamRefreshBurstDiagnosticsStore.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
210
test/renderer/store/teamResolvedMembers.test.ts
Normal file
210
test/renderer/store/teamResolvedMembers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
203
test/renderer/store/teamScopedStateCleanup.test.ts
Normal file
203
test/renderer/store/teamScopedStateCleanup.test.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
117
test/renderer/store/teamSnapshotStructuralSharing.test.ts
Normal file
117
test/renderer/store/teamSnapshotStructuralSharing.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
81
test/renderer/store/teamToolApprovalSettings.test.ts
Normal file
81
test/renderer/store/teamToolApprovalSettings.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue