4514 lines
158 KiB
TypeScript
4514 lines
158 KiB
TypeScript
import * as os from 'os';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { setClaudeBasePathOverride } from '@main/utils/pathDecoder';
|
|
import type {
|
|
BoardTaskActivityDetailResult,
|
|
BoardTaskActivityEntry,
|
|
BoardTaskLogStreamResponse,
|
|
BoardTaskExactLogDetailResult,
|
|
BoardTaskExactLogSummariesResponse,
|
|
InboxMessage,
|
|
MessagesPage,
|
|
SendMessageResult,
|
|
TeamViewSnapshot,
|
|
TeamCreateRequest,
|
|
TeamLaunchRequest,
|
|
TeamProviderId,
|
|
TeamProvisioningProgress,
|
|
} from '@shared/types/team';
|
|
|
|
vi.mock('electron', () => ({
|
|
app: { getLocale: vi.fn(() => 'en'), getPath: vi.fn(() => '/tmp'), isPackaged: false },
|
|
Notification: Object.assign(vi.fn(), { isSupported: vi.fn(() => false) }),
|
|
BrowserWindow: { fromWebContents: vi.fn(() => null), getAllWindows: vi.fn(() => []) },
|
|
}));
|
|
|
|
// Keep this mock resilient to new exports (avoid drift).
|
|
vi.mock('@preload/constants/ipcChannels', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@preload/constants/ipcChannels')>();
|
|
return { ...actual };
|
|
});
|
|
|
|
// Mock NotificationManager — handleShowMessageNotification calls addTeamNotification
|
|
const { mockAddTeamNotification } = vi.hoisted(() => ({
|
|
mockAddTeamNotification: vi
|
|
.fn()
|
|
.mockResolvedValue({ id: 'n1', isRead: false, createdAt: Date.now() }),
|
|
}));
|
|
const { mockGetMembersMeta } = vi.hoisted(() => ({
|
|
mockGetMembersMeta: vi.fn(),
|
|
}));
|
|
const { mockGetMembersMetaFile, mockWriteMembersMeta } = vi.hoisted(() => ({
|
|
mockGetMembersMetaFile: vi.fn(),
|
|
mockWriteMembersMeta: vi.fn(),
|
|
}));
|
|
const { mockTeamDataWorkerClient } = vi.hoisted(() => ({
|
|
mockTeamDataWorkerClient: {
|
|
isAvailable: vi.fn(),
|
|
getTeamData: vi.fn(),
|
|
getMessagesPage: vi.fn(),
|
|
getMemberActivityMeta: vi.fn(),
|
|
findLogsForTask: vi.fn(),
|
|
invalidateTeamConfig: vi.fn(),
|
|
invalidateTeamMessageFeed: vi.fn(),
|
|
invalidateMemberRuntimeAdvisory: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
function createDeferred<T>(): {
|
|
promise: Promise<T>;
|
|
resolve: (value: T) => void;
|
|
reject: (error: unknown) => void;
|
|
} {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (error: unknown) => void;
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
async function flushMicrotasks(): Promise<void> {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
}
|
|
|
|
vi.mock('@main/services/infrastructure/NotificationManager', () => ({
|
|
NotificationManager: {
|
|
getInstance: vi.fn().mockReturnValue({
|
|
addTeamNotification: mockAddTeamNotification,
|
|
}),
|
|
},
|
|
}));
|
|
vi.mock('@main/services/team/TeamMembersMetaStore', () => ({
|
|
TeamMembersMetaStore: vi.fn().mockImplementation(() => ({
|
|
getMembers: mockGetMembersMeta,
|
|
getMeta: mockGetMembersMetaFile,
|
|
writeMembers: mockWriteMembersMeta,
|
|
})),
|
|
}));
|
|
vi.mock('@main/services/team/TeamDataWorkerClient', () => ({
|
|
getTeamDataWorkerClient: () => mockTeamDataWorkerClient,
|
|
}));
|
|
|
|
import {
|
|
TEAM_ALIVE_LIST,
|
|
TEAM_STOP,
|
|
TEAM_CANCEL_PROVISIONING,
|
|
TEAM_CREATE,
|
|
TEAM_CREATE_CONFIG,
|
|
TEAM_CREATE_TASK,
|
|
TEAM_DELETE_TEAM,
|
|
TEAM_GET_DATA,
|
|
TEAM_GET_MEMBER_ACTIVITY_META,
|
|
TEAM_GET_MESSAGES_PAGE,
|
|
TEAM_LAUNCH,
|
|
TEAM_LIST,
|
|
TEAM_PREPARE_PROVISIONING,
|
|
TEAM_PROCESS_ALIVE,
|
|
TEAM_PROCESS_SEND,
|
|
TEAM_PROVISIONING_STATUS,
|
|
TEAM_REQUEST_REVIEW,
|
|
TEAM_SEND_MESSAGE,
|
|
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
|
TEAM_GET_ALL_TASKS,
|
|
TEAM_GET_LOGS_FOR_TASK,
|
|
TEAM_GET_TASK_ACTIVITY,
|
|
TEAM_GET_TASK_ACTIVITY_DETAIL,
|
|
TEAM_GET_TASK_LOG_STREAM,
|
|
TEAM_GET_TASK_EXACT_LOG_DETAIL,
|
|
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
|
|
TEAM_GET_MEMBER_LOGS,
|
|
TEAM_GET_MEMBER_STATS,
|
|
TEAM_START_TASK,
|
|
TEAM_UPDATE_CONFIG,
|
|
TEAM_UPDATE_KANBAN,
|
|
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
|
|
TEAM_UPDATE_TASK_STATUS,
|
|
TEAM_ADD_MEMBER,
|
|
TEAM_ADD_TASK_COMMENT,
|
|
TEAM_GET_ATTACHMENTS,
|
|
TEAM_GET_DELETED_TASKS,
|
|
TEAM_GET_TASK_CHANGE_PRESENCE,
|
|
TEAM_GET_PROJECT_BRANCH,
|
|
TEAM_KILL_PROCESS,
|
|
TEAM_LEAD_ACTIVITY,
|
|
TEAM_PERMANENTLY_DELETE,
|
|
TEAM_REMOVE_MEMBER,
|
|
TEAM_RESTORE,
|
|
TEAM_SET_TASK_CLARIFICATION,
|
|
TEAM_SOFT_DELETE_TASK,
|
|
TEAM_UPDATE_MEMBER_ROLE,
|
|
TEAM_ADD_TASK_RELATIONSHIP,
|
|
TEAM_REMOVE_TASK_RELATIONSHIP,
|
|
TEAM_REPLACE_MEMBERS,
|
|
TEAM_UPDATE_TASK_OWNER,
|
|
TEAM_UPDATE_TASK_FIELDS,
|
|
TEAM_LEAD_CONTEXT,
|
|
TEAM_RESTORE_TASK,
|
|
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
|
TEAM_SAVE_TASK_ATTACHMENT,
|
|
TEAM_GET_TASK_ATTACHMENT,
|
|
TEAM_DELETE_TASK_ATTACHMENT,
|
|
} from '../../../src/preload/constants/ipcChannels';
|
|
import {
|
|
initializeTeamHandlers,
|
|
registerTeamHandlers,
|
|
removeTeamHandlers,
|
|
} from '../../../src/main/ipc/teams';
|
|
import { ConfigManager } from '../../../src/main/services/infrastructure/ConfigManager';
|
|
import { LaunchIoGovernor } from '../../../src/main/services/team/LaunchIoGovernor';
|
|
import { getAppDataPath } from '../../../src/main/utils/pathDecoder';
|
|
|
|
describe('ipc teams handlers', () => {
|
|
const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>();
|
|
const ipcMain = {
|
|
handle: vi.fn((channel: string, fn: (...args: unknown[]) => Promise<unknown>) => {
|
|
handlers.set(channel, fn);
|
|
}),
|
|
removeHandler: vi.fn((channel: string) => {
|
|
handlers.delete(channel);
|
|
}),
|
|
};
|
|
let launchIoGovernor: LaunchIoGovernor;
|
|
|
|
const service = {
|
|
listTeams: vi.fn(async () => [{ teamName: 'my-team', displayName: 'My Team' }]),
|
|
getTeamData: vi.fn(
|
|
async (): Promise<TeamViewSnapshot & { messages?: InboxMessage[] }> => ({
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team' },
|
|
tasks: [],
|
|
members: [],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
})
|
|
),
|
|
getMessageFeed: vi.fn(async () => ({
|
|
teamName: 'my-team',
|
|
feedRevision: 'rev-1',
|
|
messages: [] as InboxMessage[],
|
|
})),
|
|
getAllTasks: vi.fn(async () => [{ id: 'task-1', teamName: 'my-team', subject: 'Task 1' }]),
|
|
getMessagesPage: vi.fn(
|
|
async (..._args: unknown[]): Promise<MessagesPage> => ({
|
|
messages: [] as InboxMessage[],
|
|
nextCursor: null,
|
|
hasMore: false,
|
|
feedRevision: 'rev-1',
|
|
})
|
|
),
|
|
getMemberActivityMeta: vi.fn(async () => ({
|
|
teamName: 'my-team',
|
|
computedAt: '2026-03-12T10:00:00.000Z',
|
|
members: {},
|
|
feedRevision: 'rev-1',
|
|
})),
|
|
getTaskChangePresence: vi.fn(async () => ({ 'task-1': 'has_changes' })),
|
|
reconcileTeamArtifacts: vi.fn(async () => undefined),
|
|
setTaskChangePresenceTracking: vi.fn(() => undefined),
|
|
getTeamNotificationContext: vi.fn(async () => ({
|
|
displayName: 'My Team',
|
|
projectPath: '/tmp/project',
|
|
})),
|
|
deleteTeam: vi.fn(async () => undefined),
|
|
restoreTeam: vi.fn(async () => undefined),
|
|
permanentlyDeleteTeam: vi.fn(async () => undefined),
|
|
getLeadMemberName: vi.fn(async () => 'team-lead'),
|
|
getTeamDisplayName: vi.fn(async () => 'My Team'),
|
|
updateConfig: vi.fn(async () => ({ name: 'My Team' })),
|
|
sendMessage: vi.fn(async (_teamName: string, _request: unknown) => ({
|
|
deliveredToInbox: true,
|
|
messageId: 'm1',
|
|
})) as ReturnType<
|
|
typeof vi.fn<
|
|
(
|
|
teamName: string,
|
|
request: unknown
|
|
) => Promise<{ deliveredToInbox: boolean; messageId: string }>
|
|
>
|
|
>,
|
|
sendDirectToLead: vi.fn(async () => ({ deliveredToInbox: false, messageId: 'direct-1' })),
|
|
createTask: vi.fn(async () => ({ id: '1', subject: 'Test', status: 'pending' })),
|
|
requestReview: vi.fn(async () => undefined),
|
|
updateKanban: vi.fn(async () => undefined),
|
|
updateKanbanColumnOrder: vi.fn(async () => undefined),
|
|
updateTaskStatus: vi.fn(async () => undefined),
|
|
startTask: vi.fn(async () => undefined),
|
|
addTaskComment: vi.fn(async () => ({
|
|
id: 'c1',
|
|
author: 'user',
|
|
text: 'test comment',
|
|
createdAt: new Date().toISOString(),
|
|
})),
|
|
addMember: vi.fn(async () => undefined),
|
|
removeMember: vi.fn(async () => undefined),
|
|
updateMemberRole: vi.fn(async () => ({ oldRole: undefined, changed: true })),
|
|
softDeleteTask: vi.fn(async () => undefined),
|
|
getDeletedTasks: vi.fn(async () => []),
|
|
setTaskNeedsClarification: vi.fn(async () => undefined),
|
|
addTaskRelationship: vi.fn(async () => undefined),
|
|
removeTaskRelationship: vi.fn(async () => undefined),
|
|
replaceMembers: vi.fn(async () => undefined),
|
|
invalidateMessageFeed: vi.fn(() => undefined),
|
|
invalidateTeamRuntimeAdvisories: vi.fn(() => undefined),
|
|
createTeamConfig: vi.fn(async () => undefined),
|
|
getSavedRequest: vi.fn(async (): Promise<TeamCreateRequest | null> => null),
|
|
};
|
|
const provisioningService = {
|
|
prepareForProvisioning: vi.fn(async () => ({
|
|
ready: true,
|
|
message: 'CLI прогрет и готов к запуску',
|
|
})),
|
|
createTeam: vi.fn(
|
|
async (_req: TeamCreateRequest, _onProgress: (p: TeamProvisioningProgress) => void) => ({
|
|
runId: 'run-1',
|
|
})
|
|
),
|
|
getProvisioningStatus: vi.fn(async () => ({
|
|
runId: 'run-1',
|
|
teamName: 'my-team',
|
|
state: 'spawning',
|
|
message: 'Starting',
|
|
startedAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
})),
|
|
cancelProvisioning: vi.fn(async () => undefined),
|
|
launchTeam: vi.fn(async () => ({ runId: 'run-2' })),
|
|
sendMessageToTeam: vi.fn(async () => undefined),
|
|
isTeamAlive: vi.fn(() => true),
|
|
getCurrentRunId: vi.fn(() => 'run-2' as string | null),
|
|
pushLiveLeadProcessMessage: vi.fn(),
|
|
relayLeadInboxMessages: vi.fn(async () => 0),
|
|
relayMemberInboxMessages: vi.fn(async () => 0),
|
|
resolveRuntimeRecipientProviderId: vi.fn(
|
|
async (_teamName: string, _memberName: string): Promise<TeamProviderId | undefined> =>
|
|
undefined
|
|
) as ReturnType<
|
|
typeof vi.fn<(teamName: string, memberName: string) => Promise<TeamProviderId | undefined>>
|
|
>,
|
|
isOpenCodeRuntimeRecipient: vi.fn(async () => false),
|
|
relayOpenCodeMemberInboxMessages: vi.fn(async () => ({
|
|
relayed: 0,
|
|
attempted: 0,
|
|
delivered: 0,
|
|
failed: 0,
|
|
lastDelivery: undefined as
|
|
| {
|
|
delivered: boolean;
|
|
accepted?: boolean;
|
|
responsePending?: boolean;
|
|
acceptanceUnknown?: boolean;
|
|
responseState?: NonNullable<SendMessageResult['runtimeDelivery']>['responseState'];
|
|
ledgerStatus?: NonNullable<SendMessageResult['runtimeDelivery']>['ledgerStatus'];
|
|
reason?: string;
|
|
diagnostics?: string[];
|
|
}
|
|
| undefined,
|
|
})),
|
|
buildOpenCodeRuntimeDeliveryUserVisibleImpact: vi.fn(() => ({ state: 'none' })),
|
|
getLiveLeadProcessMessages: vi.fn(() => [] as InboxMessage[]),
|
|
getCurrentLeadSessionId: vi.fn(() => null as string | null),
|
|
getAliveTeams: vi.fn(() => ['my-team']),
|
|
getLeadActivityState: vi.fn(() => 'idle'),
|
|
stopTeam: vi.fn(() => Promise.resolve()),
|
|
repairStaleTaskActivityIntervalsBeforeSnapshot: vi.fn(() => Promise.resolve(undefined)),
|
|
reattachOpenCodeOwnedMemberLane: vi.fn(async () => undefined),
|
|
detachOpenCodeOwnedMemberLane: vi.fn(async () => undefined),
|
|
};
|
|
const boardTaskActivityService = {
|
|
getTaskActivity: vi.fn<() => Promise<BoardTaskActivityEntry[]>>(async () => []),
|
|
};
|
|
const boardTaskActivityDetailService = {
|
|
getTaskActivityDetail: vi.fn<() => Promise<BoardTaskActivityDetailResult>>(async () => ({
|
|
status: 'missing',
|
|
})),
|
|
};
|
|
const boardTaskLogStreamService = {
|
|
getTaskLogStream: vi.fn<() => Promise<BoardTaskLogStreamResponse>>(async () => ({
|
|
participants: [],
|
|
defaultFilter: 'all',
|
|
segments: [],
|
|
})),
|
|
};
|
|
const boardTaskExactLogsService = {
|
|
getTaskExactLogSummaries: vi.fn<() => Promise<BoardTaskExactLogSummariesResponse>>(
|
|
async () => ({ items: [] })
|
|
),
|
|
};
|
|
const boardTaskExactLogDetailService = {
|
|
getTaskExactLogDetail: vi.fn<() => Promise<BoardTaskExactLogDetailResult>>(async () => ({
|
|
status: 'missing',
|
|
})),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
handlers.clear();
|
|
vi.clearAllMocks();
|
|
service.listTeams.mockReset();
|
|
service.getAllTasks.mockReset();
|
|
service.listTeams.mockResolvedValue([{ teamName: 'my-team', displayName: 'My Team' }]);
|
|
service.getAllTasks.mockResolvedValue([
|
|
{ id: 'task-1', teamName: 'my-team', subject: 'Task 1' },
|
|
]);
|
|
mockGetMembersMeta.mockReset();
|
|
mockGetMembersMeta.mockResolvedValue([]);
|
|
mockGetMembersMetaFile.mockReset();
|
|
mockGetMembersMetaFile.mockResolvedValue({
|
|
version: 1,
|
|
providerBackendId: undefined,
|
|
members: [],
|
|
});
|
|
mockWriteMembersMeta.mockReset();
|
|
mockWriteMembersMeta.mockResolvedValue(undefined);
|
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
|
mockTeamDataWorkerClient.getTeamData.mockReset();
|
|
mockTeamDataWorkerClient.getMessagesPage.mockReset();
|
|
mockTeamDataWorkerClient.getMemberActivityMeta.mockReset();
|
|
mockTeamDataWorkerClient.findLogsForTask.mockReset();
|
|
mockTeamDataWorkerClient.invalidateTeamConfig.mockReset();
|
|
mockTeamDataWorkerClient.invalidateTeamMessageFeed.mockReset();
|
|
mockTeamDataWorkerClient.invalidateMemberRuntimeAdvisory.mockReset();
|
|
provisioningService.resolveRuntimeRecipientProviderId.mockReset();
|
|
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValue(undefined);
|
|
provisioningService.repairStaleTaskActivityIntervalsBeforeSnapshot.mockReset();
|
|
provisioningService.repairStaleTaskActivityIntervalsBeforeSnapshot.mockResolvedValue(undefined);
|
|
launchIoGovernor = new LaunchIoGovernor({ quietWindowMs: 100 });
|
|
initializeTeamHandlers(
|
|
service as never,
|
|
provisioningService as never,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
boardTaskActivityService as never,
|
|
boardTaskActivityDetailService as never,
|
|
boardTaskLogStreamService as never,
|
|
boardTaskExactLogsService as never,
|
|
boardTaskExactLogDetailService as never,
|
|
launchIoGovernor
|
|
);
|
|
registerTeamHandlers(ipcMain as never);
|
|
});
|
|
|
|
afterEach(() => {
|
|
launchIoGovernor.clearForTests();
|
|
vi.useRealTimers();
|
|
setClaudeBasePathOverride(null);
|
|
});
|
|
|
|
it('registers all expected handlers', () => {
|
|
expect(handlers.has(TEAM_LIST)).toBe(true);
|
|
expect(handlers.has(TEAM_GET_DATA)).toBe(true);
|
|
expect(handlers.has(TEAM_GET_MESSAGES_PAGE)).toBe(true);
|
|
expect(handlers.has(TEAM_GET_MEMBER_ACTIVITY_META)).toBe(true);
|
|
expect(handlers.has(TEAM_GET_TASK_CHANGE_PRESENCE)).toBe(true);
|
|
expect(handlers.has(TEAM_SET_CHANGE_PRESENCE_TRACKING)).toBe(true);
|
|
expect(handlers.has(TEAM_DELETE_TEAM)).toBe(true);
|
|
expect(handlers.has(TEAM_PREPARE_PROVISIONING)).toBe(true);
|
|
expect(handlers.has(TEAM_CREATE)).toBe(true);
|
|
expect(handlers.has(TEAM_LAUNCH)).toBe(true);
|
|
expect(handlers.has(TEAM_CREATE_TASK)).toBe(true);
|
|
expect(handlers.has(TEAM_PROVISIONING_STATUS)).toBe(true);
|
|
expect(handlers.has(TEAM_CANCEL_PROVISIONING)).toBe(true);
|
|
expect(handlers.has(TEAM_SEND_MESSAGE)).toBe(true);
|
|
expect(handlers.has(TEAM_REQUEST_REVIEW)).toBe(true);
|
|
expect(handlers.has(TEAM_UPDATE_KANBAN)).toBe(true);
|
|
expect(handlers.has(TEAM_UPDATE_KANBAN_COLUMN_ORDER)).toBe(true);
|
|
expect(handlers.has(TEAM_UPDATE_TASK_STATUS)).toBe(true);
|
|
expect(handlers.has(TEAM_START_TASK)).toBe(true);
|
|
expect(handlers.has(TEAM_PROCESS_SEND)).toBe(true);
|
|
expect(handlers.has(TEAM_PROCESS_ALIVE)).toBe(true);
|
|
expect(handlers.has(TEAM_ALIVE_LIST)).toBe(true);
|
|
expect(handlers.has(TEAM_STOP)).toBe(true);
|
|
expect(handlers.has(TEAM_CREATE_CONFIG)).toBe(true);
|
|
expect(handlers.has(TEAM_GET_MEMBER_LOGS)).toBe(true);
|
|
expect(handlers.has(TEAM_GET_LOGS_FOR_TASK)).toBe(true);
|
|
expect(handlers.has(TEAM_GET_TASK_ACTIVITY)).toBe(true);
|
|
expect(handlers.has(TEAM_GET_TASK_LOG_STREAM)).toBe(true);
|
|
expect(handlers.has(TEAM_GET_TASK_EXACT_LOG_SUMMARIES)).toBe(true);
|
|
expect(handlers.has(TEAM_GET_TASK_EXACT_LOG_DETAIL)).toBe(true);
|
|
expect(handlers.has(TEAM_GET_MEMBER_STATS)).toBe(true);
|
|
expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(true);
|
|
expect(handlers.has(TEAM_GET_ALL_TASKS)).toBe(true);
|
|
expect(handlers.has(TEAM_ADD_TASK_COMMENT)).toBe(true);
|
|
expect(handlers.has(TEAM_ADD_MEMBER)).toBe(true);
|
|
expect(handlers.has(TEAM_REMOVE_MEMBER)).toBe(true);
|
|
expect(handlers.has(TEAM_UPDATE_MEMBER_ROLE)).toBe(true);
|
|
expect(handlers.has(TEAM_KILL_PROCESS)).toBe(true);
|
|
expect(handlers.has(TEAM_LEAD_ACTIVITY)).toBe(true);
|
|
expect(handlers.has(TEAM_SOFT_DELETE_TASK)).toBe(true);
|
|
expect(handlers.has(TEAM_GET_DELETED_TASKS)).toBe(true);
|
|
expect(handlers.has(TEAM_SET_TASK_CLARIFICATION)).toBe(true);
|
|
expect(handlers.has(TEAM_RESTORE)).toBe(true);
|
|
expect(handlers.has(TEAM_PERMANENTLY_DELETE)).toBe(true);
|
|
expect(handlers.has(TEAM_ADD_TASK_RELATIONSHIP)).toBe(true);
|
|
expect(handlers.has(TEAM_REMOVE_TASK_RELATIONSHIP)).toBe(true);
|
|
expect(handlers.has(TEAM_UPDATE_TASK_OWNER)).toBe(true);
|
|
expect(handlers.has(TEAM_UPDATE_TASK_FIELDS)).toBe(true);
|
|
expect(handlers.has(TEAM_REPLACE_MEMBERS)).toBe(true);
|
|
expect(handlers.has(TEAM_GET_PROJECT_BRANCH)).toBe(true);
|
|
expect(handlers.has(TEAM_GET_ATTACHMENTS)).toBe(true);
|
|
expect(handlers.has(TEAM_LEAD_CONTEXT)).toBe(true);
|
|
expect(handlers.has(TEAM_RESTORE_TASK)).toBe(true);
|
|
expect(handlers.has(TEAM_SHOW_MESSAGE_NOTIFICATION)).toBe(true);
|
|
expect(handlers.has(TEAM_SAVE_TASK_ATTACHMENT)).toBe(true);
|
|
expect(handlers.has(TEAM_GET_TASK_ATTACHMENT)).toBe(true);
|
|
expect(handlers.has(TEAM_DELETE_TASK_ATTACHMENT)).toBe(true);
|
|
});
|
|
|
|
it('updates change presence tracking for a team', async () => {
|
|
const handler = handlers.get(TEAM_SET_CHANGE_PRESENCE_TRACKING);
|
|
expect(handler).toBeDefined();
|
|
|
|
const result = (await handler!({} as never, 'my-team', true)) as {
|
|
success: boolean;
|
|
data?: void;
|
|
};
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(service.setTaskChangePresenceTracking).toHaveBeenCalledWith('my-team', true);
|
|
});
|
|
|
|
it('returns lightweight task change presence for a team', async () => {
|
|
const handler = handlers.get(TEAM_GET_TASK_CHANGE_PRESENCE);
|
|
expect(handler).toBeDefined();
|
|
|
|
const result = (await handler!({} as never, 'my-team')) as {
|
|
success: boolean;
|
|
data?: Record<string, string>;
|
|
};
|
|
|
|
expect(result).toEqual({ success: true, data: { 'task-1': 'has_changes' } });
|
|
expect(service.getTaskChangePresence).toHaveBeenCalledWith('my-team');
|
|
});
|
|
|
|
it('returns stored task attachments with source-code MIME types', async () => {
|
|
const handler = handlers.get(TEAM_GET_TASK_ATTACHMENT);
|
|
expect(handler).toBeDefined();
|
|
|
|
const taskId = 'task-js';
|
|
const attachmentId = 'att-js';
|
|
const attachmentDir = path.join(getAppDataPath(), 'task-attachments', 'my-team', taskId);
|
|
await fs.promises.rm(attachmentDir, { recursive: true, force: true });
|
|
await fs.promises.mkdir(attachmentDir, { recursive: true });
|
|
await fs.promises.writeFile(
|
|
path.join(attachmentDir, `${attachmentId}--script.js`),
|
|
'const calculator = 1;\n'
|
|
);
|
|
|
|
try {
|
|
const result = (await handler!(
|
|
{} as never,
|
|
'my-team',
|
|
taskId,
|
|
attachmentId,
|
|
'text/javascript'
|
|
)) as { success: boolean; data?: string; error?: string };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(Buffer.from(result.data ?? '', 'base64').toString('utf8')).toBe(
|
|
'const calculator = 1;\n'
|
|
);
|
|
} finally {
|
|
await fs.promises.rm(attachmentDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('returns explicit exact task-log summaries for a task', async () => {
|
|
boardTaskExactLogsService.getTaskExactLogSummaries.mockResolvedValueOnce({
|
|
items: [
|
|
{
|
|
id: 'tool:/tmp/task.jsonl:tool-1',
|
|
timestamp: '2026-04-12T16:00:00.000Z',
|
|
actor: {
|
|
memberName: 'alice',
|
|
role: 'member',
|
|
sessionId: 'session-1',
|
|
agentId: 'agent-1',
|
|
isSidechain: true,
|
|
},
|
|
source: {
|
|
filePath: '/tmp/task.jsonl',
|
|
messageUuid: 'msg-1',
|
|
toolUseId: 'tool-1',
|
|
sourceOrder: 1,
|
|
},
|
|
anchorKind: 'tool',
|
|
actionLabel: 'Added a comment',
|
|
actionCategory: 'comment',
|
|
canonicalToolName: 'task_add_comment',
|
|
linkKinds: ['board_action'],
|
|
canLoadDetail: true,
|
|
sourceGeneration: 'gen-1',
|
|
},
|
|
],
|
|
});
|
|
|
|
const handler = handlers.get(TEAM_GET_TASK_EXACT_LOG_SUMMARIES);
|
|
expect(handler).toBeDefined();
|
|
|
|
const result = (await handler!(
|
|
{} as never,
|
|
'my-team',
|
|
'123e4567-e89b-12d3-a456-426614174000'
|
|
)) as {
|
|
success: boolean;
|
|
data?: BoardTaskExactLogSummariesResponse;
|
|
};
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.items).toHaveLength(1);
|
|
expect(boardTaskExactLogsService.getTaskExactLogSummaries).toHaveBeenCalledWith(
|
|
'my-team',
|
|
'123e4567-e89b-12d3-a456-426614174000'
|
|
);
|
|
});
|
|
|
|
it('returns one task log stream for a task', async () => {
|
|
boardTaskLogStreamService.getTaskLogStream.mockResolvedValueOnce({
|
|
participants: [
|
|
{
|
|
key: 'member:alice',
|
|
label: 'alice',
|
|
role: 'member',
|
|
isLead: false,
|
|
isSidechain: true,
|
|
},
|
|
],
|
|
defaultFilter: 'all',
|
|
segments: [],
|
|
});
|
|
|
|
const handler = handlers.get(TEAM_GET_TASK_LOG_STREAM);
|
|
expect(handler).toBeDefined();
|
|
|
|
const result = (await handler!(
|
|
{} as never,
|
|
'my-team',
|
|
'123e4567-e89b-12d3-a456-426614174000'
|
|
)) as {
|
|
success: boolean;
|
|
data?: BoardTaskLogStreamResponse;
|
|
};
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.participants).toHaveLength(1);
|
|
expect(boardTaskLogStreamService.getTaskLogStream).toHaveBeenCalledWith(
|
|
'my-team',
|
|
'123e4567-e89b-12d3-a456-426614174000'
|
|
);
|
|
});
|
|
|
|
it('returns exact task-log detail for a task bundle', async () => {
|
|
boardTaskExactLogDetailService.getTaskExactLogDetail.mockResolvedValueOnce({
|
|
status: 'ok',
|
|
detail: {
|
|
id: 'tool:/tmp/task.jsonl:tool-1',
|
|
chunks: [],
|
|
},
|
|
});
|
|
|
|
const handler = handlers.get(TEAM_GET_TASK_EXACT_LOG_DETAIL);
|
|
expect(handler).toBeDefined();
|
|
|
|
const result = (await handler!(
|
|
{} as never,
|
|
'my-team',
|
|
'123e4567-e89b-12d3-a456-426614174000',
|
|
'tool:/tmp/task.jsonl:tool-1',
|
|
'gen-1'
|
|
)) as {
|
|
success: boolean;
|
|
data?: BoardTaskExactLogDetailResult;
|
|
};
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.status).toBe('ok');
|
|
expect(boardTaskExactLogDetailService.getTaskExactLogDetail).toHaveBeenCalledWith(
|
|
'my-team',
|
|
'123e4567-e89b-12d3-a456-426614174000',
|
|
'tool:/tmp/task.jsonl:tool-1',
|
|
'gen-1'
|
|
);
|
|
});
|
|
|
|
it('returns exact task-log detail stale status without rewriting the service result', async () => {
|
|
boardTaskExactLogDetailService.getTaskExactLogDetail.mockResolvedValueOnce({
|
|
status: 'stale',
|
|
});
|
|
|
|
const handler = handlers.get(TEAM_GET_TASK_EXACT_LOG_DETAIL);
|
|
expect(handler).toBeDefined();
|
|
|
|
const result = (await handler!(
|
|
{} as never,
|
|
'my-team',
|
|
'123e4567-e89b-12d3-a456-426614174000',
|
|
'tool:/tmp/task.jsonl:tool-1',
|
|
'gen-2'
|
|
)) as {
|
|
success: boolean;
|
|
data?: BoardTaskExactLogDetailResult;
|
|
};
|
|
|
|
expect(result).toEqual({
|
|
success: true,
|
|
data: { status: 'stale' },
|
|
});
|
|
});
|
|
|
|
it('returns success false on invalid sendMessage args', async () => {
|
|
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
|
expect(sendHandler).toBeDefined();
|
|
const result = (await sendHandler!({} as never, '../bad', {
|
|
member: 'alice',
|
|
text: 'hi',
|
|
})) as { success: boolean };
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('uses Agent Teams MCP reply instructions for Codex user direct messages', async () => {
|
|
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('codex');
|
|
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
|
expect(sendHandler).toBeDefined();
|
|
|
|
const result = (await sendHandler!({} as never, 'my-team', {
|
|
member: 'jack',
|
|
from: ' User ',
|
|
text: 'Здесь?',
|
|
})) as { success: boolean };
|
|
|
|
expect(result.success).toBe(true);
|
|
const request = service.sendMessage.mock.calls.at(-1)?.[1] as
|
|
| { from?: string; text?: string; messageId?: string }
|
|
| undefined;
|
|
expect(request).toBeDefined();
|
|
expect(request?.from).toBe('user');
|
|
expect(request?.messageId).toEqual(expect.any(String));
|
|
expect(request?.text).toContain('agent-teams_message_send');
|
|
expect(request?.text).toContain('mcp__agent-teams__message_send');
|
|
expect(request?.text).toContain('teamName="my-team"');
|
|
expect(request?.text).toContain('to="user"');
|
|
expect(request?.text).toContain('from="jack"');
|
|
expect(request?.text).toContain('source="runtime_delivery"');
|
|
expect(request?.text).toContain(`relayOfMessageId="${request?.messageId}"`);
|
|
expect(request?.text).toContain('before any visible-message tool attempt');
|
|
expect(request?.text).not.toContain('tool call fails before sending');
|
|
expect(request?.text).not.toContain('Reply using the SendMessage tool');
|
|
});
|
|
|
|
it.each([['anthropic' as const], ['gemini' as const], [undefined]])(
|
|
'keeps SendMessage reply instructions for %s user direct messages',
|
|
async (providerId) => {
|
|
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce(providerId);
|
|
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
|
expect(sendHandler).toBeDefined();
|
|
|
|
const result = (await sendHandler!({} as never, 'my-team', {
|
|
member: 'alice',
|
|
text: 'Здесь?',
|
|
})) as { success: boolean };
|
|
|
|
expect(result.success).toBe(true);
|
|
const request = service.sendMessage.mock.calls.at(-1)?.[1] as
|
|
| { text?: string; messageId?: string }
|
|
| undefined;
|
|
expect(request).toBeDefined();
|
|
expect(request).not.toHaveProperty('messageId');
|
|
expect(request?.text).toContain('Reply using the SendMessage tool');
|
|
expect(request?.text).toContain('to="user"');
|
|
expect(request?.text).not.toContain('agent-teams_message_send');
|
|
}
|
|
);
|
|
|
|
it('stores base text and returns runtimeDelivery success for OpenCode teammate sends', async () => {
|
|
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode');
|
|
provisioningService.relayOpenCodeMemberInboxMessages.mockResolvedValueOnce({
|
|
relayed: 1,
|
|
attempted: 1,
|
|
delivered: 1,
|
|
failed: 0,
|
|
lastDelivery: { delivered: true },
|
|
});
|
|
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
|
expect(sendHandler).toBeDefined();
|
|
|
|
const result = (await sendHandler!({} as never, 'my-team', {
|
|
member: 'bob',
|
|
text: 'Can you check this?',
|
|
actionMode: 'ask',
|
|
taskRefs: [{ teamName: 'my-team', taskId: 'task-1', displayId: 'abcd1234' }],
|
|
})) as { success: boolean; data?: SendMessageResult };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(service.sendMessage).toHaveBeenCalledWith(
|
|
'my-team',
|
|
expect.objectContaining({
|
|
member: 'bob',
|
|
text: 'Can you check this?',
|
|
})
|
|
);
|
|
expect(service.sendMessage).not.toHaveBeenCalledWith(
|
|
'my-team',
|
|
expect.objectContaining({
|
|
text: expect.stringContaining('SendMessage'),
|
|
})
|
|
);
|
|
expect(provisioningService.relayOpenCodeMemberInboxMessages).toHaveBeenCalledWith(
|
|
'my-team',
|
|
'bob',
|
|
expect.objectContaining({
|
|
onlyMessageId: 'm1',
|
|
source: 'ui-send',
|
|
deliveryMetadata: expect.objectContaining({
|
|
replyRecipient: 'user',
|
|
actionMode: 'ask',
|
|
taskRefs: [{ teamName: 'my-team', taskId: 'task-1', displayId: 'abcd1234' }],
|
|
}),
|
|
})
|
|
);
|
|
expect(result.data?.runtimeDelivery).toMatchObject({
|
|
providerId: 'opencode',
|
|
attempted: true,
|
|
delivered: true,
|
|
});
|
|
});
|
|
|
|
it('returns runtimeDelivery failure without hiding the persisted OpenCode message', async () => {
|
|
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode');
|
|
provisioningService.relayOpenCodeMemberInboxMessages.mockResolvedValueOnce({
|
|
relayed: 0,
|
|
attempted: 1,
|
|
delivered: 0,
|
|
failed: 1,
|
|
lastDelivery: {
|
|
delivered: false,
|
|
reason: 'opencode_runtime_not_active',
|
|
diagnostics: ['opencode_runtime_not_active'],
|
|
},
|
|
});
|
|
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
|
expect(sendHandler).toBeDefined();
|
|
|
|
const result = (await sendHandler!({} as never, 'my-team', {
|
|
member: 'bob',
|
|
text: 'Ping bob',
|
|
})) as { success: boolean; data?: SendMessageResult };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.deliveredToInbox).toBe(true);
|
|
expect(result.data?.runtimeDelivery).toMatchObject({
|
|
providerId: 'opencode',
|
|
attempted: true,
|
|
delivered: false,
|
|
reason: 'opencode_runtime_not_active',
|
|
});
|
|
expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain(
|
|
'OpenCode runtime delivery after sendMessage failed for teammate "bob"'
|
|
);
|
|
vi.mocked(console.warn).mockClear();
|
|
});
|
|
|
|
it('returns runtimeDelivery acceptanceUnknown for OpenCode observe-pending timeout sends', async () => {
|
|
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode');
|
|
provisioningService.relayOpenCodeMemberInboxMessages.mockResolvedValueOnce({
|
|
relayed: 0,
|
|
attempted: 1,
|
|
delivered: 0,
|
|
failed: 0,
|
|
lastDelivery: {
|
|
delivered: true,
|
|
accepted: false,
|
|
responsePending: true,
|
|
acceptanceUnknown: true,
|
|
responseState: 'not_observed',
|
|
ledgerStatus: 'failed_retryable',
|
|
reason: 'opencode_prompt_acceptance_unknown_after_bridge_timeout',
|
|
diagnostics: ['opencode_prompt_acceptance_unknown_after_bridge_timeout'],
|
|
},
|
|
});
|
|
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
|
expect(sendHandler).toBeDefined();
|
|
|
|
const result = (await sendHandler!({} as never, 'my-team', {
|
|
member: 'bob',
|
|
text: 'Ping bob',
|
|
})) as { success: boolean; data?: SendMessageResult };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.runtimeDelivery).toMatchObject({
|
|
providerId: 'opencode',
|
|
attempted: true,
|
|
delivered: true,
|
|
responsePending: true,
|
|
acceptanceUnknown: true,
|
|
ledgerStatus: 'failed_retryable',
|
|
reason: 'opencode_prompt_acceptance_unknown_after_bridge_timeout',
|
|
});
|
|
});
|
|
|
|
it('maps OpenCode UI relay timeout to pending acceptance-unknown delivery', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode');
|
|
provisioningService.relayOpenCodeMemberInboxMessages.mockReturnValueOnce(
|
|
new Promise(() => undefined)
|
|
);
|
|
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
|
expect(sendHandler).toBeDefined();
|
|
|
|
const resultPromise = sendHandler!({} as never, 'my-team', {
|
|
member: 'bob',
|
|
text: 'Ping bob',
|
|
}) as Promise<{ success: boolean; data?: SendMessageResult }>;
|
|
|
|
await vi.advanceTimersByTimeAsync(12_000);
|
|
const result = await resultPromise;
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.runtimeDelivery).toMatchObject({
|
|
providerId: 'opencode',
|
|
attempted: true,
|
|
delivered: true,
|
|
responsePending: true,
|
|
acceptanceUnknown: true,
|
|
responseState: 'not_observed',
|
|
reason: 'opencode_runtime_delivery_ui_timeout_pending',
|
|
});
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('passes hidden ask-mode instructions to a live lead without exposing them in stored text', async () => {
|
|
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
|
expect(sendHandler).toBeDefined();
|
|
|
|
const result = (await sendHandler!({} as never, 'my-team', {
|
|
member: 'team-lead',
|
|
text: 'Can you review the approach?',
|
|
actionMode: 'ask',
|
|
})) as { success: boolean };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
|
'my-team',
|
|
expect.stringContaining('TURN ACTION MODE: ASK'),
|
|
undefined
|
|
);
|
|
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
|
'my-team',
|
|
expect.stringContaining(
|
|
'FORBIDDEN: editing files, changing code, changing task/board state, delegating work, launching Agent/subagents'
|
|
),
|
|
undefined
|
|
);
|
|
expect(service.sendDirectToLead).toHaveBeenCalledWith(
|
|
'my-team',
|
|
'team-lead',
|
|
'Can you review the approach?',
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
expect.any(String)
|
|
);
|
|
});
|
|
|
|
it('injects durable teammate roster context into the first live lead direct-message wrapper', async () => {
|
|
mockGetMembersMeta.mockResolvedValueOnce([
|
|
{ name: 'team-lead', role: 'lead' },
|
|
{ name: 'alice', role: 'reviewer' },
|
|
{ name: 'jack', role: 'developer' },
|
|
]);
|
|
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
|
expect(sendHandler).toBeDefined();
|
|
|
|
const result = (await sendHandler!({} as never, 'my-team', {
|
|
member: 'team-lead',
|
|
text: 'Who is on the team right now?',
|
|
})) as { success: boolean };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
|
'my-team',
|
|
expect.stringContaining('Current durable team context:'),
|
|
undefined
|
|
);
|
|
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
|
'my-team',
|
|
expect.stringContaining(
|
|
'Persistent teammates currently configured: alice (reviewer), jack (developer)'
|
|
),
|
|
undefined
|
|
);
|
|
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
|
'my-team',
|
|
expect.stringContaining('This team is NOT in solo mode'),
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('adds a visible-first acknowledgement contract for live lead delegate turns', async () => {
|
|
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
|
expect(sendHandler).toBeDefined();
|
|
|
|
const result = (await sendHandler!({} as never, 'my-team', {
|
|
member: 'team-lead',
|
|
text: 'Delegate this work',
|
|
actionMode: 'delegate',
|
|
})) as { success: boolean };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
|
'my-team',
|
|
expect.stringContaining('DELEGATE MODE USER ACK CONTRACT:'),
|
|
undefined
|
|
);
|
|
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
|
'my-team',
|
|
expect.stringContaining(
|
|
'Make the acknowledgement at least 40 characters so it is preserved in the Messages panel.'
|
|
),
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('omits roster context when durable teammate roster is empty', async () => {
|
|
mockGetMembersMeta.mockResolvedValueOnce([]);
|
|
service.getTeamData.mockResolvedValueOnce({
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team' },
|
|
tasks: [],
|
|
members: [],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
});
|
|
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
|
expect(sendHandler).toBeDefined();
|
|
|
|
const result = (await sendHandler!({} as never, 'my-team', {
|
|
member: 'team-lead',
|
|
text: 'Who is on the team right now?',
|
|
})) as { success: boolean };
|
|
|
|
expect(result.success).toBe(true);
|
|
const stdinCall = vi.mocked(provisioningService.sendMessageToTeam).mock.calls[0] as
|
|
| unknown[]
|
|
| undefined;
|
|
expect(String(stdinCall?.[1] ?? '')).not.toContain('Current durable team context:');
|
|
});
|
|
|
|
it('sends standalone slash commands to lead stdin without the UI routing wrapper', async () => {
|
|
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
|
expect(sendHandler).toBeDefined();
|
|
|
|
const result = (await sendHandler!({} as never, 'my-team', {
|
|
member: 'team-lead',
|
|
text: ' /COMPACT keep kanban ',
|
|
})) as { success: boolean };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
|
'my-team',
|
|
'/COMPACT keep kanban',
|
|
undefined
|
|
);
|
|
const compactCall = vi.mocked(provisioningService.sendMessageToTeam).mock.calls as unknown[][];
|
|
expect(String(compactCall[0]?.[1] ?? '')).not.toContain(
|
|
'You received a direct message from the user'
|
|
);
|
|
expect(String(compactCall[0]?.[1] ?? '')).not.toContain('Current durable team context:');
|
|
expect(service.sendDirectToLead).toHaveBeenCalledWith(
|
|
'my-team',
|
|
'team-lead',
|
|
'/COMPACT keep kanban',
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
expect.any(String)
|
|
);
|
|
});
|
|
|
|
it('routes unknown standalone slash commands through the same raw stdin path', async () => {
|
|
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
|
expect(sendHandler).toBeDefined();
|
|
|
|
const result = (await sendHandler!({} as never, 'my-team', {
|
|
member: 'team-lead',
|
|
text: ' /foo bar ',
|
|
})) as { success: boolean };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
|
'my-team',
|
|
'/foo bar',
|
|
undefined
|
|
);
|
|
const unknownSlashCall = vi.mocked(provisioningService.sendMessageToTeam).mock
|
|
.calls as unknown[][];
|
|
expect(String(unknownSlashCall[0]?.[1] ?? '')).not.toContain(
|
|
'You received a direct message from the user'
|
|
);
|
|
expect(String(unknownSlashCall[0]?.[1] ?? '')).not.toContain('Current durable team context:');
|
|
expect(service.sendDirectToLead).toHaveBeenCalledWith(
|
|
'my-team',
|
|
'team-lead',
|
|
'/foo bar',
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
expect.any(String)
|
|
);
|
|
});
|
|
|
|
it('does not route slash commands through raw stdin when attachments are present', async () => {
|
|
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
|
expect(sendHandler).toBeDefined();
|
|
vi.stubEnv('HOME', os.tmpdir());
|
|
try {
|
|
const result = (await sendHandler!({} as never, 'my-team', {
|
|
member: 'team-lead',
|
|
text: '/compact keep kanban',
|
|
attachments: [
|
|
{
|
|
id: 'att-1',
|
|
filename: 'note.txt',
|
|
mimeType: 'text/plain',
|
|
size: 4,
|
|
data: Buffer.from('test').toString('base64'),
|
|
},
|
|
],
|
|
})) as { success: boolean };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
|
'my-team',
|
|
expect.stringContaining('You received a direct message from the user'),
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: 'att-1',
|
|
filename: 'note.txt',
|
|
}),
|
|
])
|
|
);
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
}
|
|
});
|
|
|
|
it('rejects delegate mode when recipient is not the team lead', async () => {
|
|
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
|
expect(sendHandler).toBeDefined();
|
|
|
|
const result = (await sendHandler!({} as never, 'my-team', {
|
|
member: 'alice',
|
|
text: 'Take this on',
|
|
actionMode: 'delegate',
|
|
})) as { success: boolean; error?: string };
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('Delegate mode is only supported when messaging the team lead');
|
|
});
|
|
|
|
it('calls service and returns success on happy paths', async () => {
|
|
const listResult = (await handlers.get(TEAM_LIST)!({} as never)) as {
|
|
success: boolean;
|
|
data: unknown[];
|
|
};
|
|
expect(listResult.success).toBe(true);
|
|
expect(service.listTeams).toHaveBeenCalledTimes(1);
|
|
|
|
const createResult = (await handlers.get(TEAM_CREATE)!({ sender: { send: vi.fn() } } as never, {
|
|
teamName: 'my-team',
|
|
members: [{ name: 'alice' }],
|
|
cwd: os.tmpdir(),
|
|
})) as { success: boolean };
|
|
expect(createResult.success).toBe(true);
|
|
expect(provisioningService.createTeam).toHaveBeenCalledTimes(1);
|
|
|
|
const statusResult = (await handlers.get(TEAM_PROVISIONING_STATUS)!({} as never, 'run-1')) as {
|
|
success: boolean;
|
|
};
|
|
expect(statusResult.success).toBe(true);
|
|
expect(provisioningService.getProvisioningStatus).toHaveBeenCalledWith('run-1');
|
|
|
|
const cancelResult = (await handlers.get(TEAM_CANCEL_PROVISIONING)!({} as never, 'run-1')) as {
|
|
success: boolean;
|
|
};
|
|
expect(cancelResult.success).toBe(true);
|
|
expect(provisioningService.cancelProvisioning).toHaveBeenCalledWith('run-1');
|
|
|
|
const reviewResult = (await handlers.get(TEAM_REQUEST_REVIEW)!(
|
|
{} as never,
|
|
'my-team',
|
|
'12'
|
|
)) as {
|
|
success: boolean;
|
|
};
|
|
expect(reviewResult.success).toBe(true);
|
|
expect(service.requestReview).toHaveBeenCalledWith('my-team', '12');
|
|
|
|
const kanbanResult = (await handlers.get(TEAM_UPDATE_KANBAN)!({} as never, 'my-team', '12', {
|
|
op: 'set_column',
|
|
column: 'approved',
|
|
})) as { success: boolean };
|
|
expect(kanbanResult.success).toBe(true);
|
|
expect(service.updateKanban).toHaveBeenCalledWith('my-team', '12', {
|
|
op: 'set_column',
|
|
column: 'approved',
|
|
});
|
|
});
|
|
|
|
it('returns cached TEAM_LIST data under active launch pressure without starting another scan', async () => {
|
|
const first = (await handlers.get(TEAM_LIST)!({} as never)) as {
|
|
success: boolean;
|
|
data: { teamName: string }[];
|
|
};
|
|
expect(first.success).toBe(true);
|
|
expect(first.data).toEqual([{ teamName: 'my-team', displayName: 'My Team' }]);
|
|
|
|
service.listTeams.mockResolvedValueOnce([{ teamName: 'fresh-team', displayName: 'Fresh' }]);
|
|
launchIoGovernor.noteLaunchIntent('my-team', 'test');
|
|
|
|
const second = (await handlers.get(TEAM_LIST)!({} as never)) as {
|
|
success: boolean;
|
|
data: { teamName: string }[];
|
|
};
|
|
|
|
expect(second.success).toBe(true);
|
|
expect(second.data).toEqual([{ teamName: 'my-team', displayName: 'My Team' }]);
|
|
expect(service.listTeams).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('returns cached TEAM_GET_ALL_TASKS data under active launch pressure without starting another scan', async () => {
|
|
const first = (await handlers.get(TEAM_GET_ALL_TASKS)!({} as never)) as {
|
|
success: boolean;
|
|
data: { id: string }[];
|
|
};
|
|
expect(first.success).toBe(true);
|
|
expect(first.data).toEqual([{ id: 'task-1', teamName: 'my-team', subject: 'Task 1' }]);
|
|
|
|
service.getAllTasks.mockResolvedValueOnce([
|
|
{ id: 'task-2', teamName: 'my-team', subject: 'Task 2' },
|
|
]);
|
|
launchIoGovernor.noteLaunchIntent('my-team', 'test');
|
|
|
|
const second = (await handlers.get(TEAM_GET_ALL_TASKS)!({} as never)) as {
|
|
success: boolean;
|
|
data: { id: string }[];
|
|
};
|
|
|
|
expect(second.success).toBe(true);
|
|
expect(second.data).toEqual([{ id: 'task-1', teamName: 'my-team', subject: 'Task 1' }]);
|
|
expect(service.getAllTasks).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('keeps current fresh behavior for TEAM_LIST when launch pressure has no cached data', async () => {
|
|
launchIoGovernor.clearForTests();
|
|
launchIoGovernor.noteLaunchIntent('my-team', 'test');
|
|
|
|
const result = (await handlers.get(TEAM_LIST)!({} as never)) as {
|
|
success: boolean;
|
|
data: { teamName: string }[];
|
|
};
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data).toEqual([{ teamName: 'my-team', displayName: 'My Team' }]);
|
|
expect(service.listTeams).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('flushes TEAM_LIST once after terminal provisioning progress quiet window', async () => {
|
|
vi.useFakeTimers();
|
|
const first = (await handlers.get(TEAM_LIST)!({} as never)) as {
|
|
success: boolean;
|
|
};
|
|
expect(first.success).toBe(true);
|
|
|
|
service.listTeams.mockResolvedValue([{ teamName: 'fresh-team', displayName: 'Fresh' }]);
|
|
launchIoGovernor.noteLaunchIntent('my-team', 'test');
|
|
await handlers.get(TEAM_LIST)!({} as never);
|
|
launchIoGovernor.noteProvisioningProgress({
|
|
runId: 'run-1',
|
|
teamName: 'my-team',
|
|
state: 'ready',
|
|
message: 'ready',
|
|
startedAt: '2026-05-02T00:00:00.000Z',
|
|
updatedAt: '2026-05-02T00:00:00.000Z',
|
|
} as TeamProvisioningProgress);
|
|
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
await flushMicrotasks();
|
|
expect(service.listTeams).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('does not let provisioning status polling activate launch IO stale mode', async () => {
|
|
const first = (await handlers.get(TEAM_LIST)!({} as never)) as {
|
|
success: boolean;
|
|
data: { teamName: string }[];
|
|
};
|
|
expect(first.success).toBe(true);
|
|
|
|
service.listTeams.mockResolvedValueOnce([{ teamName: 'fresh-team', displayName: 'Fresh' }]);
|
|
const status = (await handlers.get(TEAM_PROVISIONING_STATUS)!({} as never, 'run-1')) as {
|
|
success: boolean;
|
|
};
|
|
expect(status.success).toBe(true);
|
|
|
|
const second = (await handlers.get(TEAM_LIST)!({} as never)) as {
|
|
success: boolean;
|
|
data: { teamName: string }[];
|
|
};
|
|
expect(second.success).toBe(true);
|
|
expect(second.data).toEqual([{ teamName: 'fresh-team', displayName: 'Fresh' }]);
|
|
expect(service.listTeams).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('clears launch IO pressure when create fails before first provisioning progress', async () => {
|
|
vi.useFakeTimers();
|
|
const first = (await handlers.get(TEAM_LIST)!({} as never)) as {
|
|
success: boolean;
|
|
};
|
|
expect(first.success).toBe(true);
|
|
provisioningService.createTeam.mockRejectedValueOnce(new Error('bootstrap failed early'));
|
|
service.listTeams
|
|
.mockResolvedValueOnce([{ teamName: 'background-fresh', displayName: 'Background Fresh' }])
|
|
.mockResolvedValueOnce([{ teamName: 'fresh-team', displayName: 'Fresh' }]);
|
|
|
|
const createResult = (await handlers.get(TEAM_CREATE)!({ sender: { send: vi.fn() } } as never, {
|
|
teamName: 'my-team',
|
|
members: [{ name: 'alice' }],
|
|
cwd: os.tmpdir(),
|
|
})) as { success: boolean };
|
|
expect(createResult.success).toBe(false);
|
|
vi.mocked(console.error).mockClear();
|
|
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
await flushMicrotasks();
|
|
const second = (await handlers.get(TEAM_LIST)!({} as never)) as {
|
|
success: boolean;
|
|
data: { teamName: string }[];
|
|
};
|
|
expect(second.success).toBe(true);
|
|
expect(second.data).toEqual([{ teamName: 'fresh-team', displayName: 'Fresh' }]);
|
|
expect(service.listTeams).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('does not route TEAM_GET_MESSAGES_PAGE through the launch IO governor', async () => {
|
|
launchIoGovernor.noteLaunchIntent('my-team', 'test');
|
|
|
|
const result = (await handlers.get(TEAM_GET_MESSAGES_PAGE)!({} as never, 'my-team', {
|
|
limit: 50,
|
|
})) as { success: boolean; data?: { feedRevision: string } };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.feedRevision).toBe('rev-1');
|
|
expect(service.getMessagesPage).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('keeps TEAM_GET_DATA structural and does not expose message transport', async () => {
|
|
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
|
{
|
|
from: 'team-lead',
|
|
text: 'Hello there',
|
|
timestamp: '2026-02-23T10:00:01.000Z',
|
|
read: true,
|
|
source: 'lead_process' as const,
|
|
messageId: 'live-1',
|
|
},
|
|
]);
|
|
|
|
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
|
const result = (await getDataHandler({} as never, 'my-team')) as {
|
|
success: boolean;
|
|
data: Record<string, unknown>;
|
|
};
|
|
expect(result.success).toBe(true);
|
|
expect(result.data.teamName).toBe('my-team');
|
|
expect(result.data).not.toHaveProperty('messages');
|
|
expect(service.getMessageFeed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('falls back TEAM_GET_DATA to the main thread in packaged runtime when worker is unavailable', async () => {
|
|
const electron = await import('electron');
|
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
|
(electron.app as { isPackaged: boolean }).isPackaged = true;
|
|
|
|
const handler = handlers.get(TEAM_GET_DATA)!;
|
|
const result = (await handler({} as never, 'my-team')) as {
|
|
success: boolean;
|
|
data?: { teamName: string };
|
|
};
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.teamName).toBe('my-team');
|
|
expect(service.getTeamData).toHaveBeenCalledWith('my-team');
|
|
vi.mocked(console.error).mockClear();
|
|
|
|
(electron.app as { isPackaged: boolean }).isPackaged = false;
|
|
});
|
|
|
|
it('forwards thin TEAM_GET_DATA options to the worker without changing full request shape', async () => {
|
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
|
|
mockTeamDataWorkerClient.getTeamData.mockResolvedValueOnce({
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team' },
|
|
tasks: [],
|
|
members: [],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
});
|
|
|
|
const handler = handlers.get(TEAM_GET_DATA)!;
|
|
const result = (await handler({} as never, 'my-team', {
|
|
includeMemberBranches: false,
|
|
})) as {
|
|
success: boolean;
|
|
data?: { teamName: string };
|
|
};
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.teamName).toBe('my-team');
|
|
expect(mockTeamDataWorkerClient.getTeamData).toHaveBeenCalledWith('my-team', {
|
|
includeMemberBranches: false,
|
|
});
|
|
expect(service.getTeamData).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('repairs stale task activity before reading TEAM_GET_DATA through the worker', async () => {
|
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
|
|
mockTeamDataWorkerClient.getTeamData.mockResolvedValueOnce({
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team' },
|
|
tasks: [],
|
|
members: [],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
});
|
|
|
|
const handler = handlers.get(TEAM_GET_DATA)!;
|
|
const result = (await handler({} as never, 'my-team')) as {
|
|
success: boolean;
|
|
data?: { teamName: string };
|
|
};
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(provisioningService.repairStaleTaskActivityIntervalsBeforeSnapshot).toHaveBeenCalledWith(
|
|
'my-team'
|
|
);
|
|
expect(
|
|
provisioningService.repairStaleTaskActivityIntervalsBeforeSnapshot.mock.invocationCallOrder[0]
|
|
).toBeLessThan(mockTeamDataWorkerClient.getTeamData.mock.invocationCallOrder[0]);
|
|
});
|
|
|
|
it('normalizes explicit full TEAM_GET_DATA options to the existing one-argument call shape', async () => {
|
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
|
|
mockTeamDataWorkerClient.getTeamData.mockResolvedValueOnce({
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team' },
|
|
tasks: [],
|
|
members: [],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
});
|
|
|
|
const handler = handlers.get(TEAM_GET_DATA)!;
|
|
const result = (await handler({} as never, 'my-team', {
|
|
includeMemberBranches: true,
|
|
})) as {
|
|
success: boolean;
|
|
data?: { teamName: string };
|
|
};
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(mockTeamDataWorkerClient.getTeamData).toHaveBeenCalledWith('my-team');
|
|
});
|
|
|
|
it('forwards thin TEAM_GET_DATA options through packaged main-thread fallback', async () => {
|
|
const electron = await import('electron');
|
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
|
(electron.app as { isPackaged: boolean }).isPackaged = true;
|
|
|
|
const handler = handlers.get(TEAM_GET_DATA)!;
|
|
const result = (await handler({} as never, 'my-team', {
|
|
includeMemberBranches: false,
|
|
})) as {
|
|
success: boolean;
|
|
data?: { teamName: string };
|
|
};
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(service.getTeamData).toHaveBeenCalledWith('my-team', {
|
|
includeMemberBranches: false,
|
|
});
|
|
vi.mocked(console.error).mockClear();
|
|
|
|
(electron.app as { isPackaged: boolean }).isPackaged = false;
|
|
});
|
|
|
|
it('rejects malformed TEAM_GET_DATA options before dispatching to service or worker', async () => {
|
|
const handler = handlers.get(TEAM_GET_DATA)!;
|
|
const result = (await handler({} as never, 'my-team', {
|
|
includeMemberBranches: 'false',
|
|
})) as {
|
|
success: boolean;
|
|
error?: string;
|
|
};
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('includeMemberBranches');
|
|
expect(mockTeamDataWorkerClient.getTeamData).not.toHaveBeenCalled();
|
|
expect(service.getTeamData).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
['null options', null, 'options must be an object'],
|
|
['array options', [], 'options must be an object'],
|
|
['unknown option key', { includeMemberBranches: false, thin: true }, 'Unknown getData option'],
|
|
])(
|
|
'rejects malformed TEAM_GET_DATA %s before dispatching to service or worker',
|
|
async (_label, rawOptions, expectedError) => {
|
|
const handler = handlers.get(TEAM_GET_DATA)!;
|
|
const result = (await handler({} as never, 'my-team', rawOptions)) as {
|
|
success: boolean;
|
|
error?: string;
|
|
};
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain(expectedError);
|
|
expect(mockTeamDataWorkerClient.getTeamData).not.toHaveBeenCalled();
|
|
expect(service.getTeamData).not.toHaveBeenCalled();
|
|
}
|
|
);
|
|
|
|
it('classifies draft teams before asking the team-data worker for a full snapshot', async () => {
|
|
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-draft-get-data-'));
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamDir = path.join(claudeRoot, 'teams', 'draft-team');
|
|
await fs.promises.mkdir(teamDir, { recursive: true });
|
|
await fs.promises.writeFile(
|
|
path.join(teamDir, 'team.meta.json'),
|
|
JSON.stringify({
|
|
version: 1,
|
|
cwd: '/tmp/draft-team',
|
|
createdAt: Date.now(),
|
|
})
|
|
);
|
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
|
|
|
|
try {
|
|
const handler = handlers.get(TEAM_GET_DATA)!;
|
|
const result = (await handler({} as never, 'draft-team')) as {
|
|
success: boolean;
|
|
error?: string;
|
|
};
|
|
|
|
expect(result).toEqual({ success: false, error: 'TEAM_DRAFT' });
|
|
expect(mockTeamDataWorkerClient.getTeamData).not.toHaveBeenCalled();
|
|
expect(service.getTeamData).not.toHaveBeenCalledWith('draft-team');
|
|
} finally {
|
|
await fs.promises.rm(claudeRoot, { recursive: true, force: true });
|
|
setClaudeBasePathOverride(null);
|
|
}
|
|
});
|
|
|
|
it('classifies draft teams before falling back to main-thread getTeamData', async () => {
|
|
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-draft-main-get-data-'));
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamDir = path.join(claudeRoot, 'teams', 'draft-team');
|
|
await fs.promises.mkdir(teamDir, { recursive: true });
|
|
await fs.promises.writeFile(
|
|
path.join(teamDir, 'team.meta.json'),
|
|
JSON.stringify({
|
|
version: 1,
|
|
cwd: '/tmp/draft-team',
|
|
createdAt: Date.now(),
|
|
})
|
|
);
|
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
|
|
|
try {
|
|
const handler = handlers.get(TEAM_GET_DATA)!;
|
|
const result = (await handler({} as never, 'draft-team')) as {
|
|
success: boolean;
|
|
error?: string;
|
|
};
|
|
|
|
expect(result).toEqual({ success: false, error: 'TEAM_DRAFT' });
|
|
expect(mockTeamDataWorkerClient.getTeamData).not.toHaveBeenCalled();
|
|
expect(service.getTeamData).not.toHaveBeenCalledWith('draft-team');
|
|
} finally {
|
|
await fs.promises.rm(claudeRoot, { recursive: true, force: true });
|
|
setClaudeBasePathOverride(null);
|
|
}
|
|
});
|
|
|
|
it('does not let slow draft metadata classification block normal getData fallback', async () => {
|
|
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-draft-slow-meta-'));
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamDir = path.join(claudeRoot, 'teams', 'slow-meta-team');
|
|
await fs.promises.mkdir(teamDir, { recursive: true });
|
|
const { TeamMetaStore } = await import('../../../src/main/services/team/TeamMetaStore');
|
|
const metaSpy = vi
|
|
.spyOn(TeamMetaStore.prototype, 'getMeta')
|
|
.mockImplementation(async () => new Promise(() => undefined));
|
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
|
|
mockTeamDataWorkerClient.getTeamData.mockResolvedValueOnce({
|
|
teamName: 'slow-meta-team',
|
|
config: { name: 'Slow Meta Team' },
|
|
tasks: [],
|
|
members: [],
|
|
kanbanState: { teamName: 'slow-meta-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
});
|
|
|
|
try {
|
|
const startedAt = Date.now();
|
|
const handler = handlers.get(TEAM_GET_DATA)!;
|
|
const result = (await handler({} as never, 'slow-meta-team')) as {
|
|
success: boolean;
|
|
data?: { teamName: string };
|
|
};
|
|
|
|
expect(Date.now() - startedAt).toBeLessThan(1500);
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.teamName).toBe('slow-meta-team');
|
|
expect(mockTeamDataWorkerClient.getTeamData).toHaveBeenCalledWith('slow-meta-team');
|
|
} finally {
|
|
metaSpy.mockRestore();
|
|
await fs.promises.rm(claudeRoot, { recursive: true, force: true });
|
|
setClaudeBasePathOverride(null);
|
|
}
|
|
});
|
|
|
|
it('does not let slow draft metadata classification block Team not found fallback', async () => {
|
|
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-draft-slow-missing-meta-'));
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamDir = path.join(claudeRoot, 'teams', 'slow-missing-team');
|
|
await fs.promises.mkdir(teamDir, { recursive: true });
|
|
const { TeamMetaStore } = await import('../../../src/main/services/team/TeamMetaStore');
|
|
const metaSpy = vi
|
|
.spyOn(TeamMetaStore.prototype, 'getMeta')
|
|
.mockImplementation(async () => new Promise(() => undefined));
|
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
|
service.getTeamData.mockRejectedValueOnce(new Error('Team not found: slow-missing-team'));
|
|
|
|
try {
|
|
const startedAt = Date.now();
|
|
const handler = handlers.get(TEAM_GET_DATA)!;
|
|
const result = (await handler({} as never, 'slow-missing-team')) as {
|
|
success: boolean;
|
|
error?: string;
|
|
};
|
|
|
|
expect(Date.now() - startedAt).toBeLessThan(1500);
|
|
expect(result).toEqual({ success: false, error: 'Team not found: slow-missing-team' });
|
|
expect(service.getTeamData).toHaveBeenCalledWith('slow-missing-team');
|
|
vi.mocked(console.error).mockClear();
|
|
} finally {
|
|
metaSpy.mockRestore();
|
|
await fs.promises.rm(claudeRoot, { recursive: true, force: true });
|
|
setClaudeBasePathOverride(null);
|
|
}
|
|
});
|
|
|
|
it('does not let a live duplicate of the same session rate-limit reply delay auto-resume', async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2026-04-17T12:00:30.000Z'));
|
|
const configManager = ConfigManager.getInstance();
|
|
const actualConfig = configManager.getConfig();
|
|
const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation(
|
|
() =>
|
|
({
|
|
...actualConfig,
|
|
notifications: {
|
|
...actualConfig.notifications,
|
|
autoResumeOnRateLimit: true,
|
|
},
|
|
}) as never
|
|
);
|
|
|
|
try {
|
|
provisioningService.isTeamAlive.mockReturnValue(true);
|
|
provisioningService.getCurrentLeadSessionId.mockReturnValue('sess-123');
|
|
provisioningService.sendMessageToTeam.mockResolvedValue(undefined);
|
|
service.getTeamData.mockResolvedValueOnce({
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team' },
|
|
tasks: [],
|
|
members: [],
|
|
messages: [
|
|
{
|
|
from: 'team-lead',
|
|
text: "You've hit your limit. Resets in 5 minutes.",
|
|
timestamp: '2026-04-17T12:00:00.000Z',
|
|
read: true,
|
|
source: 'lead_session' as const,
|
|
messageId: 'persisted-rate-limit-1',
|
|
leadSessionId: 'sess-123',
|
|
},
|
|
],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
});
|
|
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
|
{
|
|
from: 'team-lead',
|
|
text: "You've hit your limit. Resets in 5 minutes.",
|
|
timestamp: '2026-04-17T12:00:02.000Z',
|
|
read: true,
|
|
source: 'lead_process' as const,
|
|
messageId: 'live-rate-limit-1',
|
|
leadSessionId: 'sess-123',
|
|
},
|
|
]);
|
|
|
|
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
|
const result = (await getDataHandler({} as never, 'my-team')) as {
|
|
success: boolean;
|
|
data: { messages?: InboxMessage[] };
|
|
};
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data.messages).toEqual([
|
|
expect.objectContaining({
|
|
source: 'lead_session',
|
|
messageId: 'persisted-rate-limit-1',
|
|
}),
|
|
]);
|
|
|
|
await vi.advanceTimersByTimeAsync(4 * 60 * 1000 + 59 * 1000);
|
|
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
|
|
|
|
await vi.advanceTimersByTimeAsync(1100);
|
|
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
getConfigSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('uses the team-data worker for TEAM_GET_MESSAGES_PAGE when available', async () => {
|
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
|
|
mockTeamDataWorkerClient.getMessagesPage.mockResolvedValueOnce({
|
|
messages: [
|
|
{
|
|
from: 'team-lead',
|
|
text: 'Hello there',
|
|
timestamp: '2026-02-23T10:00:01.000Z',
|
|
read: true,
|
|
source: 'lead_session' as const,
|
|
messageId: 'msg-1',
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
hasMore: false,
|
|
feedRevision: 'rev-worker',
|
|
});
|
|
|
|
const handler = handlers.get(TEAM_GET_MESSAGES_PAGE)!;
|
|
const result = (await handler({} as never, 'my-team', {
|
|
limit: 50,
|
|
})) as { success: boolean; data: { feedRevision: string } };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data.feedRevision).toBe('rev-worker');
|
|
expect(mockTeamDataWorkerClient.getMessagesPage).toHaveBeenCalledWith('my-team', {
|
|
cursor: undefined,
|
|
limit: 50,
|
|
});
|
|
expect(service.getMessagesPage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('scans rate-limit notifications from message-page results without hydrating TEAM_GET_DATA feed', async () => {
|
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
|
|
mockTeamDataWorkerClient.getMessagesPage.mockResolvedValueOnce({
|
|
messages: [
|
|
{
|
|
from: 'team-lead',
|
|
text: "You've hit your limit. Please wait a bit before retrying.",
|
|
timestamp: '2026-02-23T10:00:01.000Z',
|
|
read: true,
|
|
source: 'lead_session' as const,
|
|
messageId: 'msg-rate-limit-1',
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
hasMore: false,
|
|
feedRevision: 'rev-worker',
|
|
});
|
|
|
|
const handler = handlers.get(TEAM_GET_MESSAGES_PAGE)!;
|
|
const result = (await handler({} as never, 'my-team', {
|
|
limit: 50,
|
|
})) as { success: boolean; data: { feedRevision: string } };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data.feedRevision).toBe('rev-worker');
|
|
await flushMicrotasks();
|
|
expect(mockAddTeamNotification).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
teamEventType: 'rate_limit',
|
|
teamName: 'my-team',
|
|
teamDisplayName: 'My Team',
|
|
from: 'team-lead',
|
|
dedupeKey: 'rate-limit:my-team:msg-rate-limit-1',
|
|
})
|
|
);
|
|
expect(service.getMessageFeed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not block TEAM_GET_MESSAGES_PAGE on notification context reads', async () => {
|
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
|
|
mockTeamDataWorkerClient.getMessagesPage.mockResolvedValueOnce({
|
|
messages: [
|
|
{
|
|
from: 'team-lead',
|
|
text: "You've hit your limit. Please wait a bit before retrying.",
|
|
timestamp: '2026-02-23T10:00:01.000Z',
|
|
read: true,
|
|
source: 'lead_session' as const,
|
|
messageId: 'msg-rate-limit-nonblocking',
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
hasMore: false,
|
|
feedRevision: 'rev-worker',
|
|
});
|
|
const context = createDeferred<{ displayName: string; projectPath: string }>();
|
|
service.getTeamNotificationContext.mockReturnValueOnce(context.promise);
|
|
|
|
const handler = handlers.get(TEAM_GET_MESSAGES_PAGE)!;
|
|
const result = (await handler({} as never, 'my-team', {
|
|
limit: 50,
|
|
})) as { success: boolean; data: { feedRevision: string } };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data.feedRevision).toBe('rev-worker');
|
|
expect(mockAddTeamNotification).not.toHaveBeenCalled();
|
|
|
|
context.resolve({ displayName: 'My Team', projectPath: '/tmp/project' });
|
|
await flushMicrotasks();
|
|
expect(mockAddTeamNotification).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
teamEventType: 'rate_limit',
|
|
teamName: 'my-team',
|
|
teamDisplayName: 'My Team',
|
|
dedupeKey: 'rate-limit:my-team:msg-rate-limit-nonblocking',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('falls back TEAM_GET_MESSAGES_PAGE to the main thread in packaged runtime when worker is unavailable', async () => {
|
|
const electron = await import('electron');
|
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
|
(electron.app as { isPackaged: boolean }).isPackaged = true;
|
|
|
|
const handler = handlers.get(TEAM_GET_MESSAGES_PAGE)!;
|
|
const result = (await handler({} as never, 'my-team', {
|
|
limit: 50,
|
|
})) as { success: boolean; data?: { feedRevision: string } };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.feedRevision).toBe('rev-1');
|
|
expect(service.getMessagesPage).toHaveBeenCalledWith('my-team', {
|
|
cursor: undefined,
|
|
limit: 50,
|
|
});
|
|
vi.mocked(console.error).mockClear();
|
|
|
|
(electron.app as { isPackaged: boolean }).isPackaged = false;
|
|
});
|
|
|
|
it('uses the team-data worker for TEAM_GET_MEMBER_ACTIVITY_META when available', async () => {
|
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
|
|
mockTeamDataWorkerClient.getMemberActivityMeta.mockResolvedValueOnce({
|
|
teamName: 'my-team',
|
|
computedAt: '2026-03-12T10:00:00.000Z',
|
|
members: {
|
|
alice: {
|
|
memberName: 'alice',
|
|
lastAuthoredMessageAt: '2026-03-12T10:00:00.000Z',
|
|
messageCountExact: 4,
|
|
latestAuthoredMessageSignalsTermination: false,
|
|
},
|
|
},
|
|
feedRevision: 'rev-worker',
|
|
});
|
|
|
|
const handler = handlers.get(TEAM_GET_MEMBER_ACTIVITY_META)!;
|
|
const result = (await handler({} as never, 'my-team')) as {
|
|
success: boolean;
|
|
data: { feedRevision: string };
|
|
};
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data.feedRevision).toBe('rev-worker');
|
|
expect(mockTeamDataWorkerClient.getMemberActivityMeta).toHaveBeenCalledWith('my-team');
|
|
expect(service.getMemberActivityMeta).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('falls back TEAM_GET_MEMBER_ACTIVITY_META to the main thread in packaged runtime when worker is unavailable', async () => {
|
|
const electron = await import('electron');
|
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
|
(electron.app as { isPackaged: boolean }).isPackaged = true;
|
|
|
|
const handler = handlers.get(TEAM_GET_MEMBER_ACTIVITY_META)!;
|
|
const result = (await handler({} as never, 'my-team')) as {
|
|
success: boolean;
|
|
data?: { feedRevision: string };
|
|
};
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.feedRevision).toBe('rev-1');
|
|
expect(service.getMemberActivityMeta).toHaveBeenCalledWith('my-team');
|
|
vi.mocked(console.error).mockClear();
|
|
|
|
(electron.app as { isPackaged: boolean }).isPackaged = false;
|
|
});
|
|
|
|
it('rebuilds only the remaining auto-resume delay from persisted rate-limit history', async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2026-04-17T12:02:00.000Z'));
|
|
const configManager = ConfigManager.getInstance();
|
|
const actualConfig = configManager.getConfig();
|
|
const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation(
|
|
() =>
|
|
({
|
|
...actualConfig,
|
|
notifications: {
|
|
...actualConfig.notifications,
|
|
autoResumeOnRateLimit: true,
|
|
},
|
|
}) as never
|
|
);
|
|
|
|
try {
|
|
provisioningService.isTeamAlive.mockReturnValue(true);
|
|
provisioningService.getCurrentLeadSessionId.mockReturnValue('sess-live');
|
|
provisioningService.sendMessageToTeam.mockResolvedValue(undefined);
|
|
service.getTeamData.mockResolvedValueOnce({
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team' },
|
|
tasks: [],
|
|
members: [],
|
|
messages: [
|
|
{
|
|
from: 'team-lead',
|
|
text: "You've hit your limit. Resets in 5 minutes.",
|
|
timestamp: '2026-04-17T12:00:00.000Z',
|
|
read: true,
|
|
source: 'lead_session' as const,
|
|
leadSessionId: 'sess-live',
|
|
messageId: 'rate-limit-1',
|
|
},
|
|
],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
});
|
|
|
|
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
|
const result = (await getDataHandler({} as never, 'my-team')) as {
|
|
success: boolean;
|
|
data: { messages: { source?: string; text: string }[] };
|
|
};
|
|
|
|
expect(result.success).toBe(true);
|
|
|
|
await vi.advanceTimersByTimeAsync(3 * 60 * 1000 + 29 * 1000);
|
|
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
|
|
|
|
await vi.advanceTimersByTimeAsync(1100);
|
|
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
getConfigSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('can schedule auto-resume when the setting is enabled after an earlier history scan', async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2026-04-17T12:02:00.000Z'));
|
|
const configManager = ConfigManager.getInstance();
|
|
const actualConfig = configManager.getConfig();
|
|
let autoResumeEnabled = false;
|
|
const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation(
|
|
() =>
|
|
({
|
|
...actualConfig,
|
|
notifications: {
|
|
...actualConfig.notifications,
|
|
autoResumeOnRateLimit: autoResumeEnabled,
|
|
},
|
|
}) as never
|
|
);
|
|
|
|
try {
|
|
provisioningService.isTeamAlive.mockReturnValue(true);
|
|
provisioningService.getCurrentLeadSessionId.mockReturnValue('sess-live');
|
|
provisioningService.sendMessageToTeam.mockResolvedValue(undefined);
|
|
service.getTeamData.mockResolvedValue({
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team' },
|
|
tasks: [],
|
|
members: [],
|
|
messages: [
|
|
{
|
|
from: 'team-lead',
|
|
text: "You've hit your limit. Resets in 5 minutes.",
|
|
timestamp: '2026-04-17T12:00:00.000Z',
|
|
read: true,
|
|
source: 'lead_session' as const,
|
|
leadSessionId: 'sess-live',
|
|
messageId: 'rate-limit-enable-later',
|
|
},
|
|
],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
});
|
|
|
|
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
|
|
|
const firstResult = (await getDataHandler({} as never, 'my-team')) as {
|
|
success: boolean;
|
|
};
|
|
expect(firstResult.success).toBe(true);
|
|
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
|
|
|
|
autoResumeEnabled = true;
|
|
|
|
const secondResult = (await getDataHandler({} as never, 'my-team')) as {
|
|
success: boolean;
|
|
};
|
|
expect(secondResult.success).toBe(true);
|
|
|
|
await vi.advanceTimersByTimeAsync(3 * 60 * 1000 + 29 * 1000);
|
|
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
|
|
|
|
await vi.advanceTimersByTimeAsync(1100);
|
|
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
getConfigSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('retries a previously over-ceiling history message once it becomes schedulable', async () => {
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2026-04-17T00:00:00.000Z'));
|
|
const configManager = ConfigManager.getInstance();
|
|
const actualConfig = configManager.getConfig();
|
|
const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation(
|
|
() =>
|
|
({
|
|
...actualConfig,
|
|
notifications: {
|
|
...actualConfig.notifications,
|
|
autoResumeOnRateLimit: true,
|
|
},
|
|
}) as never
|
|
);
|
|
|
|
try {
|
|
provisioningService.isTeamAlive.mockReturnValue(true);
|
|
provisioningService.getCurrentLeadSessionId.mockReturnValue('sess-live');
|
|
provisioningService.sendMessageToTeam.mockResolvedValue(undefined);
|
|
service.getTeamData.mockResolvedValue({
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team' },
|
|
tasks: [],
|
|
members: [],
|
|
messages: [
|
|
{
|
|
from: 'team-lead',
|
|
text: "You've hit your limit. Resets at 12:20 UTC.",
|
|
timestamp: '2026-04-17T00:00:00.000Z',
|
|
read: true,
|
|
source: 'lead_session' as const,
|
|
leadSessionId: 'sess-live',
|
|
messageId: 'rate-limit-over-ceiling',
|
|
},
|
|
],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
});
|
|
|
|
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
|
|
|
const firstResult = (await getDataHandler({} as never, 'my-team')) as {
|
|
success: boolean;
|
|
};
|
|
expect(firstResult.success).toBe(true);
|
|
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
|
|
|
|
vi.setSystemTime(new Date('2026-04-17T12:20:00.000Z'));
|
|
|
|
const secondResult = (await getDataHandler({} as never, 'my-team')) as {
|
|
success: boolean;
|
|
};
|
|
expect(secondResult.success).toBe(true);
|
|
|
|
await vi.advanceTimersByTimeAsync(29 * 1000);
|
|
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
|
|
|
|
await vi.advanceTimersByTimeAsync(1500);
|
|
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
warnSpy.mockRestore();
|
|
getConfigSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('does not rebuild auto-resume from persisted history while the team is offline', async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2026-04-17T12:02:00.000Z'));
|
|
const configManager = ConfigManager.getInstance();
|
|
const actualConfig = configManager.getConfig();
|
|
const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation(
|
|
() =>
|
|
({
|
|
...actualConfig,
|
|
notifications: {
|
|
...actualConfig.notifications,
|
|
autoResumeOnRateLimit: true,
|
|
},
|
|
}) as never
|
|
);
|
|
|
|
try {
|
|
provisioningService.isTeamAlive.mockReturnValue(false);
|
|
provisioningService.sendMessageToTeam.mockResolvedValue(undefined);
|
|
service.getTeamData.mockResolvedValue({
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team' },
|
|
tasks: [],
|
|
members: [],
|
|
messages: [
|
|
{
|
|
from: 'team-lead',
|
|
text: "You've hit your limit. Resets in 5 minutes.",
|
|
timestamp: '2026-04-17T12:00:00.000Z',
|
|
read: true,
|
|
source: 'lead_session' as const,
|
|
messageId: 'rate-limit-offline-history',
|
|
},
|
|
],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
});
|
|
|
|
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
|
const result = (await getDataHandler({} as never, 'my-team')) as {
|
|
success: boolean;
|
|
};
|
|
expect(result.success).toBe(true);
|
|
|
|
// Simulate the user manually starting a fresh run later; stale persisted history
|
|
// should not have armed an auto-resume timer while the team was offline.
|
|
provisioningService.isTeamAlive.mockReturnValue(true);
|
|
|
|
await vi.advanceTimersByTimeAsync(3 * 60 * 1000 + 31 * 1000);
|
|
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
|
|
} finally {
|
|
getConfigSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('does not rebuild auto-resume from an older lead session after the team was manually restarted', async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2026-04-17T12:02:00.000Z'));
|
|
const configManager = ConfigManager.getInstance();
|
|
const actualConfig = configManager.getConfig();
|
|
const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation(
|
|
() =>
|
|
({
|
|
...actualConfig,
|
|
notifications: {
|
|
...actualConfig.notifications,
|
|
autoResumeOnRateLimit: true,
|
|
},
|
|
}) as never
|
|
);
|
|
|
|
try {
|
|
provisioningService.isTeamAlive.mockReturnValue(true);
|
|
provisioningService.getCurrentLeadSessionId.mockReturnValue('sess-new');
|
|
provisioningService.sendMessageToTeam.mockResolvedValue(undefined);
|
|
service.getTeamData.mockResolvedValue({
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team' },
|
|
tasks: [],
|
|
members: [],
|
|
messages: [
|
|
{
|
|
from: 'team-lead',
|
|
text: "You've hit your limit. Resets in 5 minutes.",
|
|
timestamp: '2026-04-17T12:00:00.000Z',
|
|
read: true,
|
|
source: 'lead_session' as const,
|
|
leadSessionId: 'sess-old',
|
|
messageId: 'rate-limit-old-session',
|
|
},
|
|
],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
});
|
|
|
|
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
|
const result = (await getDataHandler({} as never, 'my-team')) as {
|
|
success: boolean;
|
|
};
|
|
expect(result.success).toBe(true);
|
|
|
|
await vi.advanceTimersByTimeAsync(3 * 60 * 1000 + 31 * 1000);
|
|
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
|
|
} finally {
|
|
getConfigSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('does not arm lead auto-resume from a teammate inbox rate-limit message', async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2026-04-17T12:02:00.000Z'));
|
|
const configManager = ConfigManager.getInstance();
|
|
const actualConfig = configManager.getConfig();
|
|
const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation(
|
|
() =>
|
|
({
|
|
...actualConfig,
|
|
notifications: {
|
|
...actualConfig.notifications,
|
|
autoResumeOnRateLimit: true,
|
|
},
|
|
}) as never
|
|
);
|
|
|
|
try {
|
|
provisioningService.isTeamAlive.mockReturnValue(true);
|
|
provisioningService.getCurrentLeadSessionId.mockReturnValue('sess-live');
|
|
provisioningService.sendMessageToTeam.mockResolvedValue(undefined);
|
|
service.getTeamData.mockResolvedValue({
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team' },
|
|
tasks: [],
|
|
members: [],
|
|
messages: [
|
|
{
|
|
from: 'alice',
|
|
to: 'team-lead',
|
|
text: "You've hit your limit. Resets in 5 minutes.",
|
|
timestamp: '2026-04-17T12:00:00.000Z',
|
|
read: false,
|
|
messageId: 'member-rate-limit-1',
|
|
},
|
|
],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
});
|
|
|
|
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
|
const result = (await getDataHandler({} as never, 'my-team')) as {
|
|
success: boolean;
|
|
};
|
|
expect(result.success).toBe(true);
|
|
|
|
await vi.advanceTimersByTimeAsync(3 * 60 * 1000 + 31 * 1000);
|
|
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
|
|
} finally {
|
|
getConfigSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('rebuilds capped newest messages through getMessagesPage so live duplicates do not leak back in', async () => {
|
|
service.getTeamData.mockResolvedValueOnce({
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team' },
|
|
tasks: [],
|
|
members: [],
|
|
messages: Array.from({ length: 50 }, (_, index) => ({
|
|
from: 'alice',
|
|
text: `filler-${index}`,
|
|
timestamp: `2026-02-23T10:${String(index).padStart(2, '0')}:00.000Z`,
|
|
read: true,
|
|
source: 'inbox' as const,
|
|
messageId: `durable-${index}`,
|
|
})),
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
});
|
|
service.getMessagesPage.mockResolvedValueOnce({
|
|
messages: [
|
|
{
|
|
from: 'alice',
|
|
text: 'filler-0',
|
|
timestamp: '2026-02-23T10:00:00.000Z',
|
|
read: true,
|
|
source: 'inbox' as const,
|
|
messageId: 'durable-0',
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
hasMore: false,
|
|
feedRevision: 'rev-1',
|
|
});
|
|
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
|
{
|
|
from: 'team-lead',
|
|
text: 'Already persisted thought',
|
|
timestamp: '2026-02-23T11:00:00.000Z',
|
|
read: true,
|
|
source: 'lead_process' as const,
|
|
messageId: 'live-dup',
|
|
leadSessionId: 'lead-1',
|
|
},
|
|
]);
|
|
|
|
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
|
const result = (await getDataHandler({} as never, 'my-team')) as {
|
|
success: boolean;
|
|
data: { messages?: InboxMessage[] };
|
|
};
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(service.getMessagesPage).toHaveBeenCalledWith('my-team', {
|
|
limit: 50,
|
|
liveMessages: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
messageId: 'live-dup',
|
|
source: 'lead_process',
|
|
}),
|
|
]),
|
|
});
|
|
expect(result.data.messages).toHaveLength(50);
|
|
});
|
|
|
|
it('overlays live lead_process messages onto the newest messages page', async () => {
|
|
service.getMessagesPage.mockImplementationOnce(async (...args: unknown[]) => {
|
|
const { liveMessages = [] } = (args[1] ?? {}) as { liveMessages?: InboxMessage[] };
|
|
return {
|
|
messages: [
|
|
{
|
|
from: 'user',
|
|
text: 'Ping',
|
|
timestamp: '2026-02-23T10:00:00.000Z',
|
|
read: true,
|
|
source: 'user_sent' as const,
|
|
messageId: 'durable-1',
|
|
},
|
|
...liveMessages,
|
|
].sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp)),
|
|
nextCursor: '2026-02-23T10:00:00.000Z|durable-1',
|
|
hasMore: true,
|
|
feedRevision: 'rev-1',
|
|
} satisfies MessagesPage;
|
|
});
|
|
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
|
{
|
|
from: 'team-lead',
|
|
text: 'Команда поднята, приступаю к раздаче задач.',
|
|
timestamp: '2026-02-23T10:00:01.000Z',
|
|
read: true,
|
|
source: 'lead_process' as const,
|
|
messageId: 'live-1',
|
|
},
|
|
]);
|
|
|
|
const result = (await handlers.get(TEAM_GET_MESSAGES_PAGE)!({} as never, 'my-team', {
|
|
limit: 20,
|
|
})) as {
|
|
success: boolean;
|
|
data: { messages: InboxMessage[]; nextCursor: string | null; hasMore: boolean };
|
|
};
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data.messages).toHaveLength(2);
|
|
expect(result.data.messages[0]?.source).toBe('lead_process');
|
|
expect(result.data.messages[0]?.text).toBe('Команда поднята, приступаю к раздаче задач.');
|
|
expect(result.data.nextCursor).toBe('2026-02-23T10:00:00.000Z|durable-1');
|
|
expect(result.data.hasMore).toBe(true);
|
|
expect(service.getMessagesPage).toHaveBeenCalledWith('my-team', {
|
|
limit: 20,
|
|
cursor: undefined,
|
|
liveMessages: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
source: 'lead_process',
|
|
messageId: 'live-1',
|
|
}),
|
|
]),
|
|
});
|
|
});
|
|
|
|
it('dedups live lead thoughts on the newest messages page when durable lead_session already exists', async () => {
|
|
service.getMessagesPage.mockImplementationOnce(async (...args: unknown[]) => {
|
|
const { liveMessages = [] } = (args[1] ?? {}) as { liveMessages?: InboxMessage[] };
|
|
expect(liveMessages).toHaveLength(1);
|
|
return {
|
|
messages: [
|
|
{
|
|
from: 'team-lead',
|
|
text: 'Hello there',
|
|
timestamp: '2026-02-23T10:00:00.000Z',
|
|
read: true,
|
|
source: 'lead_session' as const,
|
|
leadSessionId: 'lead-1',
|
|
messageId: 'durable-1',
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
hasMore: false,
|
|
feedRevision: 'rev-1',
|
|
} satisfies MessagesPage;
|
|
});
|
|
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
|
{
|
|
from: 'team-lead',
|
|
text: 'Hello there',
|
|
timestamp: '2026-02-23T10:00:01.000Z',
|
|
read: true,
|
|
source: 'lead_process' as const,
|
|
leadSessionId: 'lead-1',
|
|
messageId: 'live-1',
|
|
},
|
|
]);
|
|
|
|
const result = (await handlers.get(TEAM_GET_MESSAGES_PAGE)!({} as never, 'my-team', {
|
|
limit: 20,
|
|
})) as {
|
|
success: boolean;
|
|
data: { messages: InboxMessage[] };
|
|
};
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data.messages).toHaveLength(1);
|
|
expect(result.data.messages[0]?.source).toBe('lead_session');
|
|
});
|
|
|
|
it('does not overlay live lead_process messages onto older paginated pages', async () => {
|
|
service.getMessagesPage.mockResolvedValueOnce({
|
|
messages: [
|
|
{
|
|
from: 'user',
|
|
text: 'Older durable message',
|
|
timestamp: '2026-02-23T09:59:00.000Z',
|
|
read: true,
|
|
source: 'user_sent' as const,
|
|
messageId: 'durable-older-1',
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
hasMore: false,
|
|
feedRevision: 'rev-1',
|
|
});
|
|
|
|
const result = (await handlers.get(TEAM_GET_MESSAGES_PAGE)!({} as never, 'my-team', {
|
|
limit: 20,
|
|
cursor: '2026-02-23T10:00:00.000Z|cursor',
|
|
})) as {
|
|
success: boolean;
|
|
data: { messages: InboxMessage[] };
|
|
};
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(provisioningService.getLiveLeadProcessMessages).not.toHaveBeenCalled();
|
|
expect(result.data.messages).toHaveLength(1);
|
|
expect(result.data.messages[0]?.messageId).toBe('durable-older-1');
|
|
});
|
|
|
|
it('keeps TEAM_GET_DATA read-only and never triggers reconcile side effects', async () => {
|
|
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
|
const result = (await getDataHandler({} as never, 'my-team')) as {
|
|
success: boolean;
|
|
data: { teamName: string };
|
|
};
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data.teamName).toBe('my-team');
|
|
expect(service.getTeamData).toHaveBeenCalledWith('my-team');
|
|
expect(service.reconcileTeamArtifacts).not.toHaveBeenCalled();
|
|
});
|
|
|
|
describe('createTask prompt validation', () => {
|
|
it('accepts valid prompt string', async () => {
|
|
const handler = handlers.get(TEAM_CREATE_TASK)!;
|
|
const result = (await handler({} as never, 'my-team', {
|
|
subject: 'Do something',
|
|
prompt: 'Custom instructions here',
|
|
})) as { success: boolean };
|
|
expect(result.success).toBe(true);
|
|
expect(service.createTask).toHaveBeenCalledWith('my-team', {
|
|
subject: 'Do something',
|
|
description: undefined,
|
|
owner: undefined,
|
|
blockedBy: undefined,
|
|
prompt: 'Custom instructions here',
|
|
startImmediately: undefined,
|
|
});
|
|
});
|
|
|
|
it('rejects non-string prompt', async () => {
|
|
const handler = handlers.get(TEAM_CREATE_TASK)!;
|
|
const result = (await handler({} as never, 'my-team', {
|
|
subject: 'Do something',
|
|
prompt: 42,
|
|
})) as { success: boolean; error: string };
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('prompt must be a string');
|
|
});
|
|
|
|
it('rejects prompt exceeding max length', async () => {
|
|
const handler = handlers.get(TEAM_CREATE_TASK)!;
|
|
const result = (await handler({} as never, 'my-team', {
|
|
subject: 'Do something',
|
|
prompt: 'x'.repeat(5001),
|
|
})) as { success: boolean; error: string };
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('prompt exceeds max length');
|
|
});
|
|
|
|
it('passes undefined prompt when not provided', async () => {
|
|
const handler = handlers.get(TEAM_CREATE_TASK)!;
|
|
const result = (await handler({} as never, 'my-team', {
|
|
subject: 'Do something',
|
|
})) as { success: boolean };
|
|
expect(result.success).toBe(true);
|
|
expect(service.createTask).toHaveBeenCalledWith('my-team', {
|
|
subject: 'Do something',
|
|
description: undefined,
|
|
owner: undefined,
|
|
blockedBy: undefined,
|
|
prompt: undefined,
|
|
startImmediately: undefined,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('addMember', () => {
|
|
it('calls service on valid input', async () => {
|
|
const handler = handlers.get(TEAM_ADD_MEMBER)!;
|
|
const result = (await handler({} as never, 'my-team', {
|
|
name: 'alice',
|
|
role: 'developer',
|
|
})) as { success: boolean };
|
|
expect(result.success).toBe(true);
|
|
expect(service.addMember).toHaveBeenCalledWith('my-team', {
|
|
name: 'alice',
|
|
role: 'developer',
|
|
});
|
|
});
|
|
|
|
it('notifies a live lead to use member_briefing bootstrap for the new teammate', async () => {
|
|
const handler = handlers.get(TEAM_ADD_MEMBER)!;
|
|
const result = (await handler({} as never, 'my-team', {
|
|
name: 'alice',
|
|
role: 'developer',
|
|
workflow: 'Focus on frontend polish',
|
|
})) as { success: boolean };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
|
'my-team',
|
|
expect.stringContaining('and the exact prompt below:')
|
|
);
|
|
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
|
'my-team',
|
|
expect.stringContaining('Your FIRST action: call MCP tool member_briefing')
|
|
);
|
|
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
|
'my-team',
|
|
expect.stringContaining(
|
|
'Do NOT start work, claim tasks, or improvise workflow/task/process rules'
|
|
)
|
|
);
|
|
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
|
'my-team',
|
|
expect.stringContaining('You are alice, a developer on team "My Team" (my-team).')
|
|
);
|
|
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
|
'my-team',
|
|
expect.stringContaining('Their workflow: Focus on frontend polish')
|
|
);
|
|
});
|
|
|
|
it('rejects invalid team name', async () => {
|
|
const handler = handlers.get(TEAM_ADD_MEMBER)!;
|
|
const result = (await handler({} as never, '../bad', {
|
|
name: 'alice',
|
|
})) as { success: boolean };
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('rejects invalid member name', async () => {
|
|
const handler = handlers.get(TEAM_ADD_MEMBER)!;
|
|
const result = (await handler({} as never, 'my-team', {
|
|
name: '../bad',
|
|
})) as { success: boolean };
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('rejects missing payload', async () => {
|
|
const handler = handlers.get(TEAM_ADD_MEMBER)!;
|
|
const result = (await handler({} as never, 'my-team', null)) as { success: boolean };
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('blocks live addMember for a running OpenCode-led team before metadata is written', async () => {
|
|
const handler = handlers.get(TEAM_ADD_MEMBER)!;
|
|
service.getTeamData.mockResolvedValueOnce({
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team' },
|
|
tasks: [],
|
|
members: [
|
|
{
|
|
name: 'team-lead',
|
|
providerId: 'opencode',
|
|
role: 'Team Lead',
|
|
currentTaskId: null,
|
|
taskCount: 0,
|
|
},
|
|
],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
});
|
|
|
|
const result = (await handler({} as never, 'my-team', {
|
|
name: 'alice',
|
|
role: 'developer',
|
|
providerId: 'opencode',
|
|
})) as { success: boolean; error?: string };
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('running OpenCode-led team');
|
|
expect(service.addMember).not.toHaveBeenCalled();
|
|
expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
|
|
vi.mocked(console.error).mockClear();
|
|
});
|
|
|
|
it('rolls back live OpenCode addMember metadata when controlled reattach fails', async () => {
|
|
const handler = handlers.get(TEAM_ADD_MEMBER)!;
|
|
mockGetMembersMetaFile.mockResolvedValueOnce({
|
|
version: 1,
|
|
providerBackendId: 'codex-native',
|
|
members: [
|
|
{
|
|
name: 'team-lead',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
role: 'Team Lead',
|
|
agentType: 'team-lead',
|
|
},
|
|
{
|
|
name: 'bob',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
role: 'Developer',
|
|
agentType: 'general-purpose',
|
|
agentId: 'agent-bob',
|
|
},
|
|
],
|
|
});
|
|
service.getTeamData.mockResolvedValueOnce({
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team' },
|
|
tasks: [],
|
|
members: [
|
|
{
|
|
name: 'team-lead',
|
|
providerId: 'codex',
|
|
role: 'Team Lead',
|
|
currentTaskId: null,
|
|
taskCount: 0,
|
|
},
|
|
{
|
|
name: 'bob',
|
|
providerId: 'codex',
|
|
role: 'Developer',
|
|
currentTaskId: null,
|
|
taskCount: 0,
|
|
},
|
|
],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
});
|
|
provisioningService.reattachOpenCodeOwnedMemberLane.mockRejectedValueOnce(
|
|
new Error('reattach failed')
|
|
);
|
|
|
|
const result = (await handler({} as never, 'my-team', {
|
|
name: 'alice',
|
|
role: 'developer',
|
|
providerId: 'opencode',
|
|
model: 'minimax-m2.5-free',
|
|
})) as { success: boolean; error?: string };
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('reattach failed');
|
|
expect(service.addMember).toHaveBeenCalledWith('my-team', {
|
|
name: 'alice',
|
|
role: 'developer',
|
|
workflow: undefined,
|
|
isolation: undefined,
|
|
providerId: 'opencode',
|
|
model: 'minimax-m2.5-free',
|
|
effort: undefined,
|
|
});
|
|
expect(service.replaceMembers).not.toHaveBeenCalled();
|
|
expect(mockWriteMembersMeta).toHaveBeenCalledWith(
|
|
'my-team',
|
|
[
|
|
{
|
|
name: 'team-lead',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
role: 'Team Lead',
|
|
agentType: 'team-lead',
|
|
},
|
|
{
|
|
name: 'bob',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
role: 'Developer',
|
|
agentType: 'general-purpose',
|
|
agentId: 'agent-bob',
|
|
},
|
|
],
|
|
{ providerBackendId: 'codex-native' }
|
|
);
|
|
expect(provisioningService.detachOpenCodeOwnedMemberLane).toHaveBeenCalledWith(
|
|
'my-team',
|
|
'alice'
|
|
);
|
|
vi.mocked(console.error).mockClear();
|
|
});
|
|
});
|
|
|
|
describe('updateConfig', () => {
|
|
it('notifies a live lead only when the team name actually changes', async () => {
|
|
const handler = handlers.get(TEAM_UPDATE_CONFIG)!;
|
|
service.getTeamDisplayName.mockResolvedValueOnce('My Team');
|
|
provisioningService.isTeamAlive = vi.fn(() => true);
|
|
|
|
const result = (await handler({} as never, 'my-team', {
|
|
name: 'Renamed Team',
|
|
})) as { success: boolean };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(service.updateConfig).toHaveBeenCalledWith('my-team', {
|
|
name: 'Renamed Team',
|
|
description: undefined,
|
|
color: undefined,
|
|
});
|
|
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team');
|
|
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
|
'my-team',
|
|
'The team has been renamed to "Renamed Team". Please use this name when referring to the team going forward.'
|
|
);
|
|
});
|
|
|
|
it('does not notify the lead when the submitted team name is unchanged', async () => {
|
|
const handler = handlers.get(TEAM_UPDATE_CONFIG)!;
|
|
service.getTeamDisplayName.mockResolvedValueOnce('My Team');
|
|
provisioningService.isTeamAlive = vi.fn(() => true);
|
|
|
|
const result = (await handler({} as never, 'my-team', {
|
|
name: 'My Team',
|
|
})) as { success: boolean };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(service.updateConfig).toHaveBeenCalledWith('my-team', {
|
|
name: 'My Team',
|
|
description: undefined,
|
|
color: undefined,
|
|
});
|
|
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('team mutation cache invalidation', () => {
|
|
it('invalidates worker config cache after delete, restore, and permanent delete', async () => {
|
|
const deleteHandler = handlers.get(TEAM_DELETE_TEAM)!;
|
|
const restoreHandler = handlers.get(TEAM_RESTORE)!;
|
|
const permanentlyDeleteHandler = handlers.get(TEAM_PERMANENTLY_DELETE)!;
|
|
|
|
let result = (await deleteHandler({} as never, 'my-team')) as { success: boolean };
|
|
expect(result.success).toBe(true);
|
|
expect(service.deleteTeam).toHaveBeenCalledWith('my-team');
|
|
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team');
|
|
|
|
mockTeamDataWorkerClient.invalidateTeamConfig.mockClear();
|
|
|
|
result = (await restoreHandler({} as never, 'my-team')) as { success: boolean };
|
|
expect(result.success).toBe(true);
|
|
expect(service.restoreTeam).toHaveBeenCalledWith('my-team');
|
|
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team');
|
|
|
|
mockTeamDataWorkerClient.invalidateTeamConfig.mockClear();
|
|
|
|
result = (await permanentlyDeleteHandler({} as never, 'my-team')) as { success: boolean };
|
|
expect(result.success).toBe(true);
|
|
expect(service.permanentlyDeleteTeam).toHaveBeenCalledWith('my-team');
|
|
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team');
|
|
});
|
|
|
|
it('invalidates worker config cache after roster metadata mutations', async () => {
|
|
const addHandler = handlers.get(TEAM_ADD_MEMBER)!;
|
|
const removeHandler = handlers.get(TEAM_REMOVE_MEMBER)!;
|
|
const replaceHandler = handlers.get(TEAM_REPLACE_MEMBERS)!;
|
|
const updateRoleHandler = handlers.get(TEAM_UPDATE_MEMBER_ROLE)!;
|
|
|
|
let result = (await addHandler({} as never, 'my-team', {
|
|
name: 'alice',
|
|
role: 'developer',
|
|
})) as { success: boolean };
|
|
expect(result.success).toBe(true);
|
|
expect(service.addMember).toHaveBeenCalledWith('my-team', {
|
|
name: 'alice',
|
|
role: 'developer',
|
|
});
|
|
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team');
|
|
expect(mockTeamDataWorkerClient.invalidateMemberRuntimeAdvisory).toHaveBeenCalledWith(
|
|
'my-team'
|
|
);
|
|
|
|
mockTeamDataWorkerClient.invalidateTeamConfig.mockClear();
|
|
mockTeamDataWorkerClient.invalidateMemberRuntimeAdvisory.mockClear();
|
|
|
|
result = (await removeHandler({} as never, 'my-team', 'alice')) as { success: boolean };
|
|
expect(result.success).toBe(true);
|
|
expect(service.removeMember).toHaveBeenCalledWith('my-team', 'alice');
|
|
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team');
|
|
expect(mockTeamDataWorkerClient.invalidateMemberRuntimeAdvisory).toHaveBeenCalledWith(
|
|
'my-team'
|
|
);
|
|
|
|
mockTeamDataWorkerClient.invalidateTeamConfig.mockClear();
|
|
mockTeamDataWorkerClient.invalidateMemberRuntimeAdvisory.mockClear();
|
|
|
|
result = (await replaceHandler({} as never, 'my-team', {
|
|
members: [{ name: 'bob', role: 'developer' }],
|
|
})) as { success: boolean };
|
|
expect(result.success).toBe(true);
|
|
expect(service.replaceMembers).toHaveBeenCalledWith('my-team', {
|
|
members: [
|
|
{
|
|
name: 'bob',
|
|
role: 'developer',
|
|
workflow: undefined,
|
|
isolation: undefined,
|
|
providerId: undefined,
|
|
providerBackendId: undefined,
|
|
model: undefined,
|
|
effort: undefined,
|
|
fastMode: undefined,
|
|
},
|
|
],
|
|
});
|
|
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team');
|
|
expect(mockTeamDataWorkerClient.invalidateMemberRuntimeAdvisory).toHaveBeenCalledWith(
|
|
'my-team'
|
|
);
|
|
|
|
mockTeamDataWorkerClient.invalidateTeamConfig.mockClear();
|
|
mockTeamDataWorkerClient.invalidateMemberRuntimeAdvisory.mockClear();
|
|
|
|
result = (await updateRoleHandler({} as never, 'my-team', 'bob', 'reviewer')) as {
|
|
success: boolean;
|
|
};
|
|
expect(result.success).toBe(true);
|
|
expect(service.updateMemberRole).toHaveBeenCalledWith('my-team', 'bob', 'reviewer');
|
|
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team');
|
|
expect(mockTeamDataWorkerClient.invalidateMemberRuntimeAdvisory).toHaveBeenCalledWith(
|
|
'my-team'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('removeMember', () => {
|
|
it('calls service on valid input', async () => {
|
|
const handler = handlers.get(TEAM_REMOVE_MEMBER)!;
|
|
const result = (await handler({} as never, 'my-team', 'alice')) as { success: boolean };
|
|
expect(result.success).toBe(true);
|
|
expect(service.removeMember).toHaveBeenCalledWith('my-team', 'alice');
|
|
});
|
|
|
|
it('rejects invalid team name', async () => {
|
|
const handler = handlers.get(TEAM_REMOVE_MEMBER)!;
|
|
const result = (await handler({} as never, '../bad', 'alice')) as { success: boolean };
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('rejects invalid member name', async () => {
|
|
const handler = handlers.get(TEAM_REMOVE_MEMBER)!;
|
|
const result = (await handler({} as never, 'my-team', '../bad')) as { success: boolean };
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('blocks live removeMember for a running OpenCode-led team before metadata is changed', async () => {
|
|
const handler = handlers.get(TEAM_REMOVE_MEMBER)!;
|
|
service.getTeamData.mockResolvedValueOnce({
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team' },
|
|
tasks: [],
|
|
members: [
|
|
{
|
|
name: 'team-lead',
|
|
providerId: 'opencode',
|
|
role: 'Team Lead',
|
|
currentTaskId: null,
|
|
taskCount: 0,
|
|
},
|
|
{
|
|
name: 'alice',
|
|
providerId: 'opencode',
|
|
role: 'Developer',
|
|
currentTaskId: null,
|
|
taskCount: 0,
|
|
},
|
|
],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
});
|
|
|
|
const result = (await handler({} as never, 'my-team', 'alice')) as {
|
|
success: boolean;
|
|
error?: string;
|
|
};
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('running OpenCode-led team');
|
|
expect(service.removeMember).not.toHaveBeenCalled();
|
|
expect(provisioningService.detachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
|
|
vi.mocked(console.error).mockClear();
|
|
});
|
|
|
|
it('rolls back live OpenCode removeMember metadata when lane detach fails', async () => {
|
|
const handler = handlers.get(TEAM_REMOVE_MEMBER)!;
|
|
mockGetMembersMetaFile.mockResolvedValueOnce({
|
|
version: 1,
|
|
providerBackendId: undefined,
|
|
members: [
|
|
{
|
|
name: 'team-lead',
|
|
providerId: 'codex',
|
|
role: 'Team Lead',
|
|
agentType: 'team-lead',
|
|
},
|
|
{
|
|
name: 'alice',
|
|
providerId: 'opencode',
|
|
model: 'nemotron-3-super-free',
|
|
role: 'Developer',
|
|
agentType: 'general-purpose',
|
|
agentId: 'agent-alice',
|
|
},
|
|
],
|
|
});
|
|
service.getTeamData.mockResolvedValueOnce({
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team' },
|
|
tasks: [],
|
|
members: [
|
|
{
|
|
name: 'team-lead',
|
|
providerId: 'codex',
|
|
role: 'Team Lead',
|
|
currentTaskId: null,
|
|
taskCount: 0,
|
|
},
|
|
{
|
|
name: 'alice',
|
|
providerId: 'opencode',
|
|
model: 'nemotron-3-super-free',
|
|
role: 'Developer',
|
|
currentTaskId: null,
|
|
taskCount: 0,
|
|
},
|
|
],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
});
|
|
provisioningService.detachOpenCodeOwnedMemberLane.mockRejectedValueOnce(
|
|
new Error('detach failed')
|
|
);
|
|
|
|
const result = (await handler({} as never, 'my-team', 'alice')) as {
|
|
success: boolean;
|
|
error?: string;
|
|
};
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('detach failed');
|
|
expect(service.removeMember).toHaveBeenCalledWith('my-team', 'alice');
|
|
expect(service.replaceMembers).not.toHaveBeenCalled();
|
|
expect(mockWriteMembersMeta).toHaveBeenCalledWith(
|
|
'my-team',
|
|
[
|
|
{
|
|
name: 'team-lead',
|
|
providerId: 'codex',
|
|
role: 'Team Lead',
|
|
agentType: 'team-lead',
|
|
},
|
|
{
|
|
name: 'alice',
|
|
providerId: 'opencode',
|
|
model: 'nemotron-3-super-free',
|
|
role: 'Developer',
|
|
agentType: 'general-purpose',
|
|
agentId: 'agent-alice',
|
|
},
|
|
],
|
|
{ providerBackendId: undefined }
|
|
);
|
|
expect(provisioningService.reattachOpenCodeOwnedMemberLane).toHaveBeenCalledWith(
|
|
'my-team',
|
|
'alice',
|
|
{ reason: 'member_updated' }
|
|
);
|
|
vi.mocked(console.error).mockClear();
|
|
});
|
|
});
|
|
|
|
describe('replaceMembers', () => {
|
|
it('blocks live replaceMembers for a running OpenCode-led team before metadata is changed', async () => {
|
|
const handler = handlers.get(TEAM_REPLACE_MEMBERS)!;
|
|
service.getTeamData.mockResolvedValueOnce({
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team' },
|
|
tasks: [],
|
|
members: [
|
|
{
|
|
name: 'team-lead',
|
|
providerId: 'opencode',
|
|
role: 'Team Lead',
|
|
currentTaskId: null,
|
|
taskCount: 0,
|
|
},
|
|
{
|
|
name: 'alice',
|
|
providerId: 'opencode',
|
|
role: 'Developer',
|
|
currentTaskId: null,
|
|
taskCount: 0,
|
|
},
|
|
],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
});
|
|
|
|
const result = (await handler({} as never, 'my-team', {
|
|
members: [{ name: 'alice', role: 'Developer', providerId: 'opencode' }],
|
|
})) as { success: boolean; error?: string };
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('running OpenCode-led team');
|
|
expect(service.replaceMembers).not.toHaveBeenCalled();
|
|
expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
|
|
expect(provisioningService.detachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
|
|
vi.mocked(console.error).mockClear();
|
|
});
|
|
|
|
it('rolls back live OpenCode replaceMembers metadata when lane reattach fails', async () => {
|
|
const handler = handlers.get(TEAM_REPLACE_MEMBERS)!;
|
|
mockGetMembersMetaFile.mockResolvedValueOnce({
|
|
version: 1,
|
|
providerBackendId: 'codex-native',
|
|
members: [
|
|
{
|
|
name: 'team-lead',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
role: 'Team Lead',
|
|
agentType: 'team-lead',
|
|
},
|
|
{
|
|
name: 'alice',
|
|
providerId: 'opencode',
|
|
model: 'nemotron-3-super-free',
|
|
role: 'Developer',
|
|
agentType: 'general-purpose',
|
|
agentId: 'agent-alice',
|
|
},
|
|
{
|
|
name: 'bob',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
role: 'Developer',
|
|
agentType: 'general-purpose',
|
|
agentId: 'agent-bob',
|
|
},
|
|
],
|
|
});
|
|
service.getTeamData.mockResolvedValueOnce({
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team' },
|
|
tasks: [],
|
|
members: [
|
|
{
|
|
name: 'team-lead',
|
|
providerId: 'codex',
|
|
role: 'Team Lead',
|
|
currentTaskId: null,
|
|
taskCount: 0,
|
|
},
|
|
{
|
|
name: 'alice',
|
|
providerId: 'opencode',
|
|
model: 'nemotron-3-super-free',
|
|
role: 'Developer',
|
|
currentTaskId: null,
|
|
taskCount: 0,
|
|
},
|
|
{
|
|
name: 'bob',
|
|
providerId: 'codex',
|
|
role: 'Developer',
|
|
currentTaskId: null,
|
|
taskCount: 0,
|
|
},
|
|
],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
});
|
|
provisioningService.reattachOpenCodeOwnedMemberLane.mockRejectedValueOnce(
|
|
new Error('reattach failed')
|
|
);
|
|
|
|
const result = (await handler({} as never, 'my-team', {
|
|
members: [
|
|
{
|
|
name: 'alice',
|
|
role: 'Developer',
|
|
providerId: 'opencode',
|
|
model: 'minimax-m2.5-free',
|
|
},
|
|
{
|
|
name: 'bob',
|
|
role: 'Developer',
|
|
providerId: 'codex',
|
|
},
|
|
],
|
|
})) as { success: boolean; error?: string };
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('reattach failed');
|
|
expect(service.replaceMembers).toHaveBeenNthCalledWith(1, 'my-team', {
|
|
members: [
|
|
{
|
|
name: 'alice',
|
|
role: 'Developer',
|
|
workflow: undefined,
|
|
isolation: undefined,
|
|
providerId: 'opencode',
|
|
providerBackendId: undefined,
|
|
model: 'minimax-m2.5-free',
|
|
effort: undefined,
|
|
fastMode: undefined,
|
|
},
|
|
{
|
|
name: 'bob',
|
|
role: 'Developer',
|
|
workflow: undefined,
|
|
isolation: undefined,
|
|
providerId: 'codex',
|
|
providerBackendId: undefined,
|
|
model: undefined,
|
|
effort: undefined,
|
|
fastMode: undefined,
|
|
},
|
|
],
|
|
});
|
|
expect(service.replaceMembers).toHaveBeenCalledTimes(1);
|
|
expect(mockWriteMembersMeta).toHaveBeenCalledWith(
|
|
'my-team',
|
|
[
|
|
{
|
|
name: 'team-lead',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
role: 'Team Lead',
|
|
agentType: 'team-lead',
|
|
},
|
|
{
|
|
name: 'alice',
|
|
providerId: 'opencode',
|
|
model: 'nemotron-3-super-free',
|
|
role: 'Developer',
|
|
agentType: 'general-purpose',
|
|
agentId: 'agent-alice',
|
|
},
|
|
{
|
|
name: 'bob',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
role: 'Developer',
|
|
agentType: 'general-purpose',
|
|
agentId: 'agent-bob',
|
|
},
|
|
],
|
|
{ providerBackendId: 'codex-native' }
|
|
);
|
|
expect(provisioningService.reattachOpenCodeOwnedMemberLane).toHaveBeenNthCalledWith(
|
|
1,
|
|
'my-team',
|
|
'alice',
|
|
{ reason: 'member_updated' }
|
|
);
|
|
expect(provisioningService.reattachOpenCodeOwnedMemberLane).toHaveBeenNthCalledWith(
|
|
2,
|
|
'my-team',
|
|
'alice',
|
|
{ reason: 'member_updated' }
|
|
);
|
|
vi.mocked(console.error).mockClear();
|
|
});
|
|
|
|
it('blocks live replaceMembers when a member migrates from primary runtime ownership to OpenCode', async () => {
|
|
const handler = handlers.get(TEAM_REPLACE_MEMBERS)!;
|
|
service.getTeamData.mockResolvedValueOnce({
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team' },
|
|
tasks: [],
|
|
members: [
|
|
{
|
|
name: 'team-lead',
|
|
providerId: 'codex',
|
|
role: 'Team Lead',
|
|
currentTaskId: null,
|
|
taskCount: 0,
|
|
},
|
|
{
|
|
name: 'alice',
|
|
providerId: 'codex',
|
|
role: 'Developer',
|
|
currentTaskId: null,
|
|
taskCount: 0,
|
|
},
|
|
],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
});
|
|
|
|
const result = (await handler({} as never, 'my-team', {
|
|
members: [
|
|
{
|
|
name: 'alice',
|
|
role: 'Developer',
|
|
providerId: 'opencode',
|
|
model: 'minimax-m2.5-free',
|
|
},
|
|
],
|
|
})) as { success: boolean; error?: string };
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain(
|
|
'Live member migration between OpenCode and the primary runtime owner'
|
|
);
|
|
expect(result.error).toContain('alice');
|
|
expect(service.replaceMembers).not.toHaveBeenCalled();
|
|
expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
|
|
expect(provisioningService.detachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
|
|
vi.mocked(console.error).mockClear();
|
|
});
|
|
|
|
it('blocks live replaceMembers when a member migrates from OpenCode to primary runtime ownership', async () => {
|
|
const handler = handlers.get(TEAM_REPLACE_MEMBERS)!;
|
|
service.getTeamData.mockResolvedValueOnce({
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team' },
|
|
tasks: [],
|
|
members: [
|
|
{
|
|
name: 'team-lead',
|
|
providerId: 'codex',
|
|
role: 'Team Lead',
|
|
currentTaskId: null,
|
|
taskCount: 0,
|
|
},
|
|
{
|
|
name: 'alice',
|
|
providerId: 'opencode',
|
|
model: 'nemotron-3-super-free',
|
|
role: 'Developer',
|
|
currentTaskId: null,
|
|
taskCount: 0,
|
|
},
|
|
],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
});
|
|
|
|
const result = (await handler({} as never, 'my-team', {
|
|
members: [
|
|
{
|
|
name: 'alice',
|
|
role: 'Developer',
|
|
providerId: 'codex',
|
|
},
|
|
],
|
|
})) as { success: boolean; error?: string };
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain(
|
|
'Live member migration between OpenCode and the primary runtime owner'
|
|
);
|
|
expect(result.error).toContain('alice');
|
|
expect(service.replaceMembers).not.toHaveBeenCalled();
|
|
expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
|
|
expect(provisioningService.detachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
|
|
vi.mocked(console.error).mockClear();
|
|
});
|
|
});
|
|
|
|
describe('updateMemberRole', () => {
|
|
it('calls service on valid input', async () => {
|
|
const handler = handlers.get(TEAM_UPDATE_MEMBER_ROLE)!;
|
|
const result = (await handler({} as never, 'my-team', 'alice', 'developer')) as {
|
|
success: boolean;
|
|
};
|
|
expect(result.success).toBe(true);
|
|
expect(service.updateMemberRole).toHaveBeenCalledWith('my-team', 'alice', 'developer');
|
|
});
|
|
|
|
it('normalizes null role to undefined', async () => {
|
|
const handler = handlers.get(TEAM_UPDATE_MEMBER_ROLE)!;
|
|
const result = (await handler({} as never, 'my-team', 'alice', null)) as {
|
|
success: boolean;
|
|
};
|
|
expect(result.success).toBe(true);
|
|
expect(service.updateMemberRole).toHaveBeenCalledWith('my-team', 'alice', undefined);
|
|
});
|
|
|
|
it('rejects invalid team name', async () => {
|
|
const handler = handlers.get(TEAM_UPDATE_MEMBER_ROLE)!;
|
|
const result = (await handler({} as never, '../bad', 'alice', 'dev')) as {
|
|
success: boolean;
|
|
};
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('rejects invalid member name', async () => {
|
|
const handler = handlers.get(TEAM_UPDATE_MEMBER_ROLE)!;
|
|
const result = (await handler({} as never, 'my-team', '../bad', 'dev')) as {
|
|
success: boolean;
|
|
};
|
|
expect(result.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('createTeam prompt validation', () => {
|
|
it('accepts valid prompt in team create request', async () => {
|
|
const handler = handlers.get(TEAM_CREATE)!;
|
|
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
|
teamName: 'test-team',
|
|
members: [{ name: 'alice' }],
|
|
cwd: os.tmpdir(),
|
|
prompt: 'Build a web app',
|
|
})) as { success: boolean };
|
|
expect(result.success).toBe(true);
|
|
const callArg = provisioningService.createTeam.mock.calls[0][0];
|
|
expect(callArg.prompt).toBe('Build a web app');
|
|
});
|
|
|
|
it('rejects non-string prompt in team create request', async () => {
|
|
const handler = handlers.get(TEAM_CREATE)!;
|
|
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
|
teamName: 'test-team',
|
|
members: [{ name: 'alice' }],
|
|
cwd: os.tmpdir(),
|
|
prompt: 123,
|
|
})) as { success: boolean; error: string };
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('prompt must be a string');
|
|
});
|
|
});
|
|
|
|
it('removes handlers', () => {
|
|
removeTeamHandlers(ipcMain as never);
|
|
expect(handlers.has(TEAM_LIST)).toBe(false);
|
|
expect(handlers.has(TEAM_GET_DATA)).toBe(false);
|
|
expect(handlers.has(TEAM_DELETE_TEAM)).toBe(false);
|
|
expect(handlers.has(TEAM_PREPARE_PROVISIONING)).toBe(false);
|
|
expect(handlers.has(TEAM_CREATE)).toBe(false);
|
|
expect(handlers.has(TEAM_LAUNCH)).toBe(false);
|
|
expect(handlers.has(TEAM_CREATE_TASK)).toBe(false);
|
|
expect(handlers.has(TEAM_PROVISIONING_STATUS)).toBe(false);
|
|
expect(handlers.has(TEAM_CANCEL_PROVISIONING)).toBe(false);
|
|
expect(handlers.has(TEAM_SEND_MESSAGE)).toBe(false);
|
|
expect(handlers.has(TEAM_REQUEST_REVIEW)).toBe(false);
|
|
expect(handlers.has(TEAM_UPDATE_KANBAN)).toBe(false);
|
|
expect(handlers.has(TEAM_UPDATE_KANBAN_COLUMN_ORDER)).toBe(false);
|
|
expect(handlers.has(TEAM_UPDATE_TASK_STATUS)).toBe(false);
|
|
expect(handlers.has(TEAM_START_TASK)).toBe(false);
|
|
expect(handlers.has(TEAM_PROCESS_SEND)).toBe(false);
|
|
expect(handlers.has(TEAM_PROCESS_ALIVE)).toBe(false);
|
|
expect(handlers.has(TEAM_ALIVE_LIST)).toBe(false);
|
|
expect(handlers.has(TEAM_STOP)).toBe(false);
|
|
expect(handlers.has(TEAM_CREATE_CONFIG)).toBe(false);
|
|
expect(handlers.has(TEAM_GET_MEMBER_LOGS)).toBe(false);
|
|
expect(handlers.has(TEAM_GET_LOGS_FOR_TASK)).toBe(false);
|
|
expect(handlers.has(TEAM_GET_TASK_ACTIVITY)).toBe(false);
|
|
expect(handlers.has(TEAM_GET_TASK_LOG_STREAM)).toBe(false);
|
|
expect(handlers.has(TEAM_GET_MEMBER_STATS)).toBe(false);
|
|
expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(false);
|
|
expect(handlers.has(TEAM_GET_ALL_TASKS)).toBe(false);
|
|
expect(handlers.has(TEAM_ADD_TASK_COMMENT)).toBe(false);
|
|
expect(handlers.has(TEAM_ADD_MEMBER)).toBe(false);
|
|
expect(handlers.has(TEAM_REMOVE_MEMBER)).toBe(false);
|
|
expect(handlers.has(TEAM_UPDATE_MEMBER_ROLE)).toBe(false);
|
|
expect(handlers.has(TEAM_GET_PROJECT_BRANCH)).toBe(false);
|
|
expect(handlers.has(TEAM_GET_ATTACHMENTS)).toBe(false);
|
|
expect(handlers.has(TEAM_KILL_PROCESS)).toBe(false);
|
|
expect(handlers.has(TEAM_LEAD_ACTIVITY)).toBe(false);
|
|
expect(handlers.has(TEAM_SOFT_DELETE_TASK)).toBe(false);
|
|
expect(handlers.has(TEAM_GET_DELETED_TASKS)).toBe(false);
|
|
expect(handlers.has(TEAM_SET_TASK_CLARIFICATION)).toBe(false);
|
|
expect(handlers.has(TEAM_RESTORE)).toBe(false);
|
|
expect(handlers.has(TEAM_PERMANENTLY_DELETE)).toBe(false);
|
|
expect(handlers.has(TEAM_ADD_TASK_RELATIONSHIP)).toBe(false);
|
|
expect(handlers.has(TEAM_REMOVE_TASK_RELATIONSHIP)).toBe(false);
|
|
expect(handlers.has(TEAM_UPDATE_TASK_OWNER)).toBe(false);
|
|
expect(handlers.has(TEAM_UPDATE_TASK_FIELDS)).toBe(false);
|
|
expect(handlers.has(TEAM_REPLACE_MEMBERS)).toBe(false);
|
|
expect(handlers.has(TEAM_LEAD_CONTEXT)).toBe(false);
|
|
expect(handlers.has(TEAM_RESTORE_TASK)).toBe(false);
|
|
expect(handlers.has(TEAM_SHOW_MESSAGE_NOTIFICATION)).toBe(false);
|
|
expect(handlers.has(TEAM_SAVE_TASK_ATTACHMENT)).toBe(false);
|
|
expect(handlers.has(TEAM_GET_TASK_ATTACHMENT)).toBe(false);
|
|
expect(handlers.has(TEAM_DELETE_TASK_ATTACHMENT)).toBe(false);
|
|
});
|
|
|
|
it('returns explicit task activity rows', async () => {
|
|
const handler = handlers.get(TEAM_GET_TASK_ACTIVITY);
|
|
expect(handler).toBeDefined();
|
|
|
|
const activityRows: BoardTaskActivityEntry[] = [
|
|
{
|
|
id: 'activity-1',
|
|
timestamp: '2026-04-12T10:00:00.000Z',
|
|
task: {
|
|
locator: { ref: 'abcd1234', refKind: 'display' },
|
|
resolution: 'resolved',
|
|
},
|
|
linkKind: 'lifecycle',
|
|
targetRole: 'subject',
|
|
actor: {
|
|
role: 'lead',
|
|
sessionId: 'session-1',
|
|
isSidechain: false,
|
|
},
|
|
actorContext: {
|
|
relation: 'idle',
|
|
},
|
|
source: {
|
|
messageUuid: 'message-1',
|
|
filePath: '/tmp/transcript.jsonl',
|
|
sourceOrder: 1,
|
|
},
|
|
},
|
|
];
|
|
boardTaskActivityService.getTaskActivity.mockResolvedValueOnce(activityRows);
|
|
|
|
const result = (await handler!({} as never, 'my-team', 'task-1')) as {
|
|
success: boolean;
|
|
data: typeof activityRows;
|
|
};
|
|
|
|
expect(result).toEqual({ success: true, data: activityRows });
|
|
expect(boardTaskActivityService.getTaskActivity).toHaveBeenCalledWith('my-team', 'task-1');
|
|
});
|
|
|
|
it('returns focused task activity detail for one row', async () => {
|
|
const handler = handlers.get(TEAM_GET_TASK_ACTIVITY_DETAIL);
|
|
expect(handler).toBeDefined();
|
|
|
|
boardTaskActivityDetailService.getTaskActivityDetail.mockResolvedValueOnce({
|
|
status: 'ok',
|
|
detail: {
|
|
entryId: 'activity-1',
|
|
summaryLabel: 'Added a comment',
|
|
actorLabel: 'bob',
|
|
timestamp: '2026-04-13T10:35:00.000Z',
|
|
contextLines: ['while working on #peer12345'],
|
|
metadataRows: [{ label: 'Comment', value: '42' }],
|
|
},
|
|
});
|
|
|
|
const result = (await handler!({} as never, 'my-team', 'task-1', 'activity-1')) as {
|
|
success: boolean;
|
|
data?: BoardTaskActivityDetailResult;
|
|
};
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.status).toBe('ok');
|
|
expect(boardTaskActivityDetailService.getTaskActivityDetail).toHaveBeenCalledWith(
|
|
'my-team',
|
|
'task-1',
|
|
'activity-1'
|
|
);
|
|
});
|
|
|
|
describe('addTaskRelationship', () => {
|
|
it('calls service on valid input', async () => {
|
|
const handler = handlers.get(TEAM_ADD_TASK_RELATIONSHIP)!;
|
|
const result = (await handler({} as never, 'my-team', '1', '2', 'blockedBy')) as {
|
|
success: boolean;
|
|
};
|
|
expect(result.success).toBe(true);
|
|
expect(service.addTaskRelationship).toHaveBeenCalledWith('my-team', '1', '2', 'blockedBy');
|
|
});
|
|
|
|
it('rejects invalid team name', async () => {
|
|
const handler = handlers.get(TEAM_ADD_TASK_RELATIONSHIP)!;
|
|
const result = (await handler({} as never, '../bad', '1', '2', 'blockedBy')) as {
|
|
success: boolean;
|
|
};
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('rejects invalid task id', async () => {
|
|
const handler = handlers.get(TEAM_ADD_TASK_RELATIONSHIP)!;
|
|
const result = (await handler({} as never, 'my-team', 'bad/id', '2', 'blockedBy')) as {
|
|
success: boolean;
|
|
};
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('rejects invalid target id', async () => {
|
|
const handler = handlers.get(TEAM_ADD_TASK_RELATIONSHIP)!;
|
|
const result = (await handler({} as never, 'my-team', '1', '', 'blockedBy')) as {
|
|
success: boolean;
|
|
};
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('rejects invalid relationship type', async () => {
|
|
const handler = handlers.get(TEAM_ADD_TASK_RELATIONSHIP)!;
|
|
const result = (await handler({} as never, 'my-team', '1', '2', 'invalid')) as {
|
|
success: boolean;
|
|
};
|
|
expect(result.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('removeTaskRelationship', () => {
|
|
it('calls service on valid input', async () => {
|
|
const handler = handlers.get(TEAM_REMOVE_TASK_RELATIONSHIP)!;
|
|
const result = (await handler({} as never, 'my-team', '1', '2', 'related')) as {
|
|
success: boolean;
|
|
};
|
|
expect(result.success).toBe(true);
|
|
expect(service.removeTaskRelationship).toHaveBeenCalledWith('my-team', '1', '2', 'related');
|
|
});
|
|
|
|
it('rejects invalid team name', async () => {
|
|
const handler = handlers.get(TEAM_REMOVE_TASK_RELATIONSHIP)!;
|
|
const result = (await handler({} as never, '../bad', '1', '2', 'related')) as {
|
|
success: boolean;
|
|
};
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('rejects invalid relationship type', async () => {
|
|
const handler = handlers.get(TEAM_REMOVE_TASK_RELATIONSHIP)!;
|
|
const result = (await handler({} as never, 'my-team', '1', '2', 'unknown')) as {
|
|
success: boolean;
|
|
};
|
|
expect(result.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('solo team (zero members)', () => {
|
|
it('createTeam accepts members: [] (provisioning validation)', async () => {
|
|
const handler = handlers.get(TEAM_CREATE)!;
|
|
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
|
teamName: 'solo-team',
|
|
members: [],
|
|
cwd: os.tmpdir(),
|
|
})) as { success: boolean };
|
|
expect(result.success).toBe(true);
|
|
expect(provisioningService.createTeam).toHaveBeenCalledTimes(1);
|
|
const callArg = provisioningService.createTeam.mock.calls[0][0];
|
|
expect(callArg.members).toEqual([]);
|
|
});
|
|
|
|
it('createTeam preserves teammate backend and fast mode metadata', async () => {
|
|
const handler = handlers.get(TEAM_CREATE)!;
|
|
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
|
teamName: 'runtime-team',
|
|
members: [
|
|
{
|
|
name: 'builder',
|
|
role: 'Engineer',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: 'gpt-5.4',
|
|
effort: 'high',
|
|
fastMode: 'on',
|
|
},
|
|
],
|
|
cwd: os.tmpdir(),
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
})) as { success: boolean };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(provisioningService.createTeam.mock.calls[0][0].members).toEqual([
|
|
{
|
|
name: 'builder',
|
|
role: 'Engineer',
|
|
workflow: undefined,
|
|
isolation: undefined,
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: 'gpt-5.4',
|
|
effort: 'high',
|
|
fastMode: 'on',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('createTeam validates teammate runtime fields against inherited team provider metadata', async () => {
|
|
const handler = handlers.get(TEAM_CREATE)!;
|
|
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
|
teamName: 'inherited-backend-team',
|
|
members: [
|
|
{
|
|
name: 'builder',
|
|
providerBackendId: 'codex-native',
|
|
effort: 'xhigh',
|
|
},
|
|
],
|
|
cwd: os.tmpdir(),
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
})) as { success: boolean };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(provisioningService.createTeam.mock.calls[0][0].members).toEqual([
|
|
{
|
|
name: 'builder',
|
|
role: undefined,
|
|
workflow: undefined,
|
|
isolation: undefined,
|
|
providerId: undefined,
|
|
providerBackendId: 'codex-native',
|
|
model: undefined,
|
|
effort: 'xhigh',
|
|
fastMode: undefined,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('createTeam preserves top-level OpenCode provider and inherited teammate backend', async () => {
|
|
const handler = handlers.get(TEAM_CREATE)!;
|
|
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
|
teamName: 'opencode-runtime-team',
|
|
members: [
|
|
{
|
|
name: 'builder',
|
|
providerBackendId: 'opencode-cli',
|
|
},
|
|
],
|
|
cwd: os.tmpdir(),
|
|
providerId: 'opencode',
|
|
providerBackendId: 'opencode-cli',
|
|
})) as { success: boolean };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(provisioningService.createTeam).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
teamName: 'opencode-runtime-team',
|
|
providerId: 'opencode',
|
|
providerBackendId: 'opencode-cli',
|
|
members: [
|
|
expect.objectContaining({
|
|
name: 'builder',
|
|
providerId: undefined,
|
|
providerBackendId: 'opencode-cli',
|
|
}),
|
|
],
|
|
}),
|
|
expect.any(Function)
|
|
);
|
|
});
|
|
|
|
it('handleCreateConfig accepts members: []', async () => {
|
|
const handler = handlers.get(TEAM_CREATE_CONFIG)!;
|
|
const result = (await handler({} as never, {
|
|
teamName: 'solo-team',
|
|
members: [],
|
|
cwd: os.tmpdir(),
|
|
})) as { success: boolean };
|
|
expect(result.success).toBe(true);
|
|
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('solo-team');
|
|
});
|
|
|
|
it('handleCreateConfig preserves draft launch metadata', async () => {
|
|
const handler = handlers.get(TEAM_CREATE_CONFIG)!;
|
|
const result = (await handler({} as never, {
|
|
teamName: 'draft-team',
|
|
displayName: ' Draft Team ',
|
|
description: ' Saved draft ',
|
|
color: '#3366ff',
|
|
members: [
|
|
{
|
|
name: 'builder',
|
|
role: ' Engineer ',
|
|
workflow: ' Ship focused patches ',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: ' gpt-5.2 ',
|
|
effort: 'high',
|
|
fastMode: 'on',
|
|
},
|
|
],
|
|
cwd: '/Users/test/project',
|
|
prompt: ' Saved prompt ',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: ' gpt-5.2 ',
|
|
effort: 'high',
|
|
fastMode: 'on',
|
|
limitContext: true,
|
|
skipPermissions: false,
|
|
worktree: 'feature-x',
|
|
extraCliArgs: '--max-turns 5',
|
|
})) as { success: boolean };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(service.createTeamConfig).toHaveBeenCalledWith({
|
|
teamName: 'draft-team',
|
|
displayName: 'Draft Team',
|
|
description: 'Saved draft',
|
|
color: '#3366ff',
|
|
members: [
|
|
{
|
|
name: 'builder',
|
|
role: 'Engineer',
|
|
workflow: 'Ship focused patches',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: 'gpt-5.2',
|
|
effort: 'high',
|
|
fastMode: 'on',
|
|
},
|
|
],
|
|
cwd: '/Users/test/project',
|
|
prompt: 'Saved prompt',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: 'gpt-5.2',
|
|
effort: 'high',
|
|
fastMode: 'on',
|
|
limitContext: true,
|
|
skipPermissions: false,
|
|
worktree: 'feature-x',
|
|
extraCliArgs: '--max-turns 5',
|
|
});
|
|
});
|
|
|
|
it('handleCreateConfig validates teammate runtime fields against inherited team provider metadata', async () => {
|
|
const handler = handlers.get(TEAM_CREATE_CONFIG)!;
|
|
const result = (await handler({} as never, {
|
|
teamName: 'draft-inherited-runtime',
|
|
members: [
|
|
{
|
|
name: 'builder',
|
|
providerBackendId: 'codex-native',
|
|
effort: 'xhigh',
|
|
},
|
|
],
|
|
cwd: os.tmpdir(),
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
})) as { success: boolean };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(service.createTeamConfig).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
teamName: 'draft-inherited-runtime',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
members: [
|
|
expect.objectContaining({
|
|
name: 'builder',
|
|
providerId: undefined,
|
|
providerBackendId: 'codex-native',
|
|
effort: 'xhigh',
|
|
}),
|
|
],
|
|
})
|
|
);
|
|
});
|
|
|
|
it('handleCreateConfig rejects stale inherited teammate backends for the selected team provider', async () => {
|
|
const handler = handlers.get(TEAM_CREATE_CONFIG)!;
|
|
const result = (await handler({} as never, {
|
|
teamName: 'draft-stale-runtime',
|
|
members: [
|
|
{
|
|
name: 'builder',
|
|
providerBackendId: 'codex-native',
|
|
},
|
|
],
|
|
cwd: os.tmpdir(),
|
|
providerId: 'anthropic',
|
|
})) as { success: boolean; error?: string };
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('providerBackendId must be valid');
|
|
expect(service.createTeamConfig).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('handleCreateConfig drops known stale top-level backend when provider is omitted', async () => {
|
|
const handler = handlers.get(TEAM_CREATE_CONFIG)!;
|
|
const result = (await handler({} as never, {
|
|
teamName: 'draft-stale-top-level-runtime',
|
|
members: [{ name: 'builder' }],
|
|
cwd: os.tmpdir(),
|
|
providerBackendId: 'codex-native',
|
|
})) as { success: boolean };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(service.createTeamConfig).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
teamName: 'draft-stale-top-level-runtime',
|
|
providerId: undefined,
|
|
providerBackendId: undefined,
|
|
})
|
|
);
|
|
});
|
|
|
|
it('handleCreateConfig validates teammate effort against default Anthropic provider metadata', async () => {
|
|
const handler = handlers.get(TEAM_CREATE_CONFIG)!;
|
|
const result = (await handler({} as never, {
|
|
teamName: 'draft-default-anthropic-runtime',
|
|
members: [
|
|
{
|
|
name: 'builder',
|
|
effort: 'max',
|
|
},
|
|
],
|
|
cwd: os.tmpdir(),
|
|
})) as { success: boolean };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(service.createTeamConfig).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
teamName: 'draft-default-anthropic-runtime',
|
|
members: [
|
|
expect.objectContaining({
|
|
name: 'builder',
|
|
effort: 'max',
|
|
}),
|
|
],
|
|
})
|
|
);
|
|
});
|
|
|
|
it('handleCreateConfig validates top-level effort against default Anthropic provider metadata', async () => {
|
|
const handler = handlers.get(TEAM_CREATE_CONFIG)!;
|
|
const result = (await handler({} as never, {
|
|
teamName: 'draft-default-anthropic-effort',
|
|
members: [{ name: 'builder' }],
|
|
cwd: os.tmpdir(),
|
|
effort: 'max',
|
|
})) as { success: boolean };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(service.createTeamConfig).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
teamName: 'draft-default-anthropic-effort',
|
|
providerId: undefined,
|
|
effort: 'max',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('launches draft team through saved request without dropping Electron draft metadata', async () => {
|
|
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-draft-launch-'));
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
try {
|
|
const teamDir = path.join(claudeRoot, 'teams', 'draft-team');
|
|
fs.mkdirSync(teamDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(teamDir, 'team.meta.json'),
|
|
JSON.stringify({
|
|
version: 1,
|
|
displayName: 'Draft Team',
|
|
cwd: '/Users/test/project',
|
|
createdAt: Date.now(),
|
|
})
|
|
);
|
|
service.getSavedRequest.mockResolvedValueOnce({
|
|
teamName: 'draft-team',
|
|
displayName: 'Draft Team',
|
|
description: 'Saved draft',
|
|
color: '#3366ff',
|
|
cwd: '/Users/test/project',
|
|
prompt: 'Saved prompt',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: 'gpt-5.2',
|
|
effort: 'medium',
|
|
fastMode: 'on',
|
|
limitContext: true,
|
|
skipPermissions: false,
|
|
worktree: 'feature-x',
|
|
extraCliArgs: '--max-turns 5',
|
|
members: [
|
|
{
|
|
name: 'builder',
|
|
role: 'Engineer',
|
|
workflow: 'Ship focused patches',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: 'gpt-5.2',
|
|
effort: 'high',
|
|
fastMode: 'on',
|
|
},
|
|
],
|
|
});
|
|
|
|
const handler = handlers.get(TEAM_LAUNCH)!;
|
|
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
|
teamName: 'draft-team',
|
|
cwd: os.tmpdir(),
|
|
effort: 'high',
|
|
})) as { success: boolean };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(provisioningService.launchTeam).not.toHaveBeenCalled();
|
|
expect(provisioningService.createTeam).toHaveBeenCalledWith(
|
|
{
|
|
teamName: 'draft-team',
|
|
displayName: 'Draft Team',
|
|
description: 'Saved draft',
|
|
color: '#3366ff',
|
|
members: [
|
|
{
|
|
name: 'builder',
|
|
role: 'Engineer',
|
|
workflow: 'Ship focused patches',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: 'gpt-5.2',
|
|
effort: 'high',
|
|
fastMode: 'on',
|
|
},
|
|
],
|
|
cwd: os.tmpdir(),
|
|
prompt: 'Saved prompt',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: 'gpt-5.2',
|
|
effort: 'high',
|
|
fastMode: 'on',
|
|
limitContext: true,
|
|
skipPermissions: false,
|
|
worktree: 'feature-x',
|
|
extraCliArgs: '--max-turns 5',
|
|
},
|
|
expect.any(Function)
|
|
);
|
|
} finally {
|
|
fs.rmSync(claudeRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('treats explicit default effort in launch payload as clearing persisted lead effort', async () => {
|
|
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-launch-default-effort-'));
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
try {
|
|
const teamDir = path.join(claudeRoot, 'teams', 'anthropic-team');
|
|
fs.mkdirSync(teamDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(teamDir, 'config.json'),
|
|
JSON.stringify({ teamName: 'anthropic-team' })
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(teamDir, 'team.meta.json'),
|
|
JSON.stringify({
|
|
version: 1,
|
|
displayName: 'Anthropic Team',
|
|
cwd: '/Users/test/project',
|
|
providerId: 'anthropic',
|
|
model: 'claude-opus-4-6[1m]',
|
|
effort: 'low',
|
|
fastMode: 'on',
|
|
launchIdentity: {
|
|
selectedModel: 'claude-opus-4-6[1m]',
|
|
selectedEffort: 'low',
|
|
selectedFastMode: 'on',
|
|
},
|
|
createdAt: Date.now(),
|
|
})
|
|
);
|
|
|
|
const handler = handlers.get(TEAM_LAUNCH)!;
|
|
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
|
teamName: 'anthropic-team',
|
|
cwd: os.tmpdir(),
|
|
providerId: 'anthropic',
|
|
model: 'claude-opus-4-6[1m]',
|
|
effort: undefined,
|
|
fastMode: 'inherit',
|
|
})) as { success: boolean };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(provisioningService.launchTeam).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
teamName: 'anthropic-team',
|
|
providerId: 'anthropic',
|
|
model: 'claude-opus-4-6[1m]',
|
|
effort: undefined,
|
|
fastMode: 'inherit',
|
|
}),
|
|
expect.any(Function)
|
|
);
|
|
} finally {
|
|
fs.rmSync(claudeRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('prefers Anthropic launch identity over stale root Codex backend during launch', async () => {
|
|
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-launch-provider-identity-'));
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
try {
|
|
const teamDir = path.join(claudeRoot, 'teams', 'anthropic-team');
|
|
fs.mkdirSync(teamDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(teamDir, 'config.json'),
|
|
JSON.stringify({ teamName: 'anthropic-team' })
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(teamDir, 'team.meta.json'),
|
|
JSON.stringify({
|
|
version: 1,
|
|
displayName: 'Anthropic Team',
|
|
cwd: '/Users/test/project',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: 'gpt-5.4',
|
|
effort: 'medium',
|
|
launchIdentity: {
|
|
providerId: 'anthropic',
|
|
providerBackendId: null,
|
|
selectedModel: 'opus[1m]',
|
|
selectedModelKind: 'explicit',
|
|
resolvedLaunchModel: 'opus[1m]',
|
|
catalogId: 'opus',
|
|
catalogSource: 'runtime',
|
|
catalogFetchedAt: null,
|
|
selectedEffort: 'low',
|
|
resolvedEffort: 'low',
|
|
selectedFastMode: 'inherit',
|
|
resolvedFastMode: null,
|
|
fastResolutionReason: null,
|
|
},
|
|
createdAt: Date.now(),
|
|
})
|
|
);
|
|
|
|
const handler = handlers.get(TEAM_LAUNCH)!;
|
|
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
|
teamName: 'anthropic-team',
|
|
cwd: os.tmpdir(),
|
|
})) as { success: boolean };
|
|
|
|
expect(result).toMatchObject({ success: true });
|
|
expect(provisioningService.launchTeam).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
teamName: 'anthropic-team',
|
|
providerId: 'anthropic',
|
|
providerBackendId: undefined,
|
|
model: 'opus[1m]',
|
|
effort: 'low',
|
|
fastMode: 'inherit',
|
|
}),
|
|
expect.any(Function)
|
|
);
|
|
} finally {
|
|
fs.rmSync(claudeRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('lets an explicit relaunch payload override stale persisted provider and model metadata', async () => {
|
|
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-relaunch-provider-change-'));
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
try {
|
|
const teamDir = path.join(claudeRoot, 'teams', 'runtime-change-team');
|
|
fs.mkdirSync(teamDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(teamDir, 'config.json'),
|
|
JSON.stringify({ teamName: 'runtime-change-team' })
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(teamDir, 'team.meta.json'),
|
|
JSON.stringify({
|
|
version: 1,
|
|
displayName: 'Runtime Change Team',
|
|
cwd: '/Users/test/project',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: 'gpt-5.5',
|
|
effort: 'medium',
|
|
launchIdentity: {
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
selectedModel: 'gpt-5.5',
|
|
selectedModelKind: 'explicit',
|
|
resolvedLaunchModel: 'gpt-5.5',
|
|
catalogId: 'gpt-5.5',
|
|
catalogSource: 'runtime',
|
|
catalogFetchedAt: null,
|
|
selectedEffort: 'medium',
|
|
resolvedEffort: 'medium',
|
|
selectedFastMode: 'inherit',
|
|
resolvedFastMode: null,
|
|
fastResolutionReason: null,
|
|
},
|
|
createdAt: Date.now(),
|
|
})
|
|
);
|
|
|
|
const handler = handlers.get(TEAM_LAUNCH)!;
|
|
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
|
teamName: 'runtime-change-team',
|
|
cwd: os.tmpdir(),
|
|
providerId: 'anthropic',
|
|
model: 'sonnet',
|
|
effort: 'low',
|
|
fastMode: 'inherit',
|
|
})) as { success: boolean };
|
|
|
|
expect(result).toMatchObject({ success: true });
|
|
expect(provisioningService.launchTeam).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
teamName: 'runtime-change-team',
|
|
providerId: 'anthropic',
|
|
providerBackendId: undefined,
|
|
model: 'sonnet',
|
|
effort: 'low',
|
|
fastMode: 'inherit',
|
|
}),
|
|
expect.any(Function)
|
|
);
|
|
} finally {
|
|
fs.rmSync(claudeRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('does not reuse a persisted model when an explicit relaunch changes provider without a model', async () => {
|
|
const claudeRoot = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'ipc-relaunch-provider-change-default-model-')
|
|
);
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
try {
|
|
const teamDir = path.join(claudeRoot, 'teams', 'runtime-default-change-team');
|
|
fs.mkdirSync(teamDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(teamDir, 'config.json'),
|
|
JSON.stringify({ teamName: 'runtime-default-change-team' })
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(teamDir, 'team.meta.json'),
|
|
JSON.stringify({
|
|
version: 1,
|
|
displayName: 'Runtime Default Change Team',
|
|
cwd: '/Users/test/project',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: 'gpt-5.5',
|
|
effort: 'medium',
|
|
fastMode: 'on',
|
|
limitContext: true,
|
|
launchIdentity: {
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
selectedModel: 'gpt-5.5',
|
|
selectedModelKind: 'explicit',
|
|
resolvedLaunchModel: 'gpt-5.5',
|
|
catalogId: 'gpt-5.5',
|
|
catalogSource: 'runtime',
|
|
catalogFetchedAt: null,
|
|
selectedEffort: 'medium',
|
|
resolvedEffort: 'medium',
|
|
selectedFastMode: 'on',
|
|
resolvedFastMode: true,
|
|
fastResolutionReason: null,
|
|
},
|
|
createdAt: Date.now(),
|
|
})
|
|
);
|
|
|
|
const handler = handlers.get(TEAM_LAUNCH)!;
|
|
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
|
teamName: 'runtime-default-change-team',
|
|
cwd: os.tmpdir(),
|
|
providerId: 'anthropic',
|
|
})) as { success: boolean };
|
|
|
|
expect(result).toMatchObject({ success: true });
|
|
const [request] = provisioningService.launchTeam.mock.calls.at(
|
|
-1
|
|
) as unknown as [TeamLaunchRequest, (progress: TeamProvisioningProgress) => void];
|
|
expect(request).toMatchObject({
|
|
teamName: 'runtime-default-change-team',
|
|
providerId: 'anthropic',
|
|
providerBackendId: undefined,
|
|
});
|
|
expect(request.model).toBeUndefined();
|
|
expect(request.effort).toBeUndefined();
|
|
expect(request.fastMode).toBeUndefined();
|
|
expect(request.limitContext).toBeUndefined();
|
|
} finally {
|
|
fs.rmSync(claudeRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('keeps persisted backend when an explicit relaunch repeats the same provider without backend', async () => {
|
|
const claudeRoot = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'ipc-relaunch-same-provider-backend-')
|
|
);
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
try {
|
|
const teamDir = path.join(claudeRoot, 'teams', 'gemini-backend-team');
|
|
fs.mkdirSync(teamDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(teamDir, 'config.json'),
|
|
JSON.stringify({ teamName: 'gemini-backend-team' })
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(teamDir, 'team.meta.json'),
|
|
JSON.stringify({
|
|
version: 1,
|
|
displayName: 'Gemini Backend Team',
|
|
cwd: '/Users/test/project',
|
|
providerId: 'gemini',
|
|
providerBackendId: 'api',
|
|
model: 'gemini-3-pro',
|
|
createdAt: Date.now(),
|
|
})
|
|
);
|
|
|
|
const handler = handlers.get(TEAM_LAUNCH)!;
|
|
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
|
teamName: 'gemini-backend-team',
|
|
cwd: os.tmpdir(),
|
|
providerId: 'gemini',
|
|
})) as { success: boolean };
|
|
|
|
expect(result).toMatchObject({ success: true });
|
|
expect(provisioningService.launchTeam).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
teamName: 'gemini-backend-team',
|
|
providerId: 'gemini',
|
|
providerBackendId: 'api',
|
|
model: 'gemini-3-pro',
|
|
}),
|
|
expect.any(Function)
|
|
);
|
|
} finally {
|
|
fs.rmSync(claudeRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('clears a persisted model when an explicit relaunch repeats the provider with default model', async () => {
|
|
const claudeRoot = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'ipc-relaunch-same-provider-default-model-')
|
|
);
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
try {
|
|
const teamDir = path.join(claudeRoot, 'teams', 'codex-default-model-team');
|
|
fs.mkdirSync(teamDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(teamDir, 'config.json'),
|
|
JSON.stringify({ teamName: 'codex-default-model-team' })
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(teamDir, 'team.meta.json'),
|
|
JSON.stringify({
|
|
version: 1,
|
|
displayName: 'Codex Default Model Team',
|
|
cwd: '/Users/test/project',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: 'gpt-5.5',
|
|
effort: 'medium',
|
|
launchIdentity: {
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
selectedModel: 'gpt-5.5',
|
|
selectedModelKind: 'explicit',
|
|
resolvedLaunchModel: 'gpt-5.5',
|
|
catalogId: 'gpt-5.5',
|
|
catalogSource: 'runtime',
|
|
catalogFetchedAt: null,
|
|
selectedEffort: 'medium',
|
|
resolvedEffort: 'medium',
|
|
selectedFastMode: 'inherit',
|
|
resolvedFastMode: null,
|
|
fastResolutionReason: null,
|
|
},
|
|
createdAt: Date.now(),
|
|
})
|
|
);
|
|
|
|
const handler = handlers.get(TEAM_LAUNCH)!;
|
|
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
|
teamName: 'codex-default-model-team',
|
|
cwd: os.tmpdir(),
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: undefined,
|
|
effort: 'low',
|
|
})) as { success: boolean };
|
|
|
|
expect(result).toMatchObject({ success: true });
|
|
expect(provisioningService.launchTeam).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
teamName: 'codex-default-model-team',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: undefined,
|
|
effort: 'low',
|
|
}),
|
|
expect.any(Function)
|
|
);
|
|
} finally {
|
|
fs.rmSync(claudeRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('drops a known stale providerBackendId from explicit Anthropic relaunch payloads', async () => {
|
|
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-relaunch-stale-backend-'));
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
try {
|
|
const teamDir = path.join(claudeRoot, 'teams', 'runtime-backend-change-team');
|
|
fs.mkdirSync(teamDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(teamDir, 'config.json'),
|
|
JSON.stringify({ teamName: 'runtime-backend-change-team' })
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(teamDir, 'team.meta.json'),
|
|
JSON.stringify({
|
|
version: 1,
|
|
displayName: 'Runtime Backend Change Team',
|
|
cwd: '/Users/test/project',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: 'gpt-5.5',
|
|
effort: 'medium',
|
|
createdAt: Date.now(),
|
|
})
|
|
);
|
|
|
|
const handler = handlers.get(TEAM_LAUNCH)!;
|
|
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
|
teamName: 'runtime-backend-change-team',
|
|
cwd: os.tmpdir(),
|
|
providerId: 'anthropic',
|
|
providerBackendId: 'codex-native',
|
|
model: 'sonnet',
|
|
effort: 'low',
|
|
fastMode: 'inherit',
|
|
})) as { success: boolean };
|
|
|
|
expect(result).toMatchObject({ success: true });
|
|
expect(provisioningService.launchTeam).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
teamName: 'runtime-backend-change-team',
|
|
providerId: 'anthropic',
|
|
providerBackendId: undefined,
|
|
model: 'sonnet',
|
|
effort: 'low',
|
|
fastMode: 'inherit',
|
|
}),
|
|
expect.any(Function)
|
|
);
|
|
} finally {
|
|
fs.rmSync(claudeRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('still rejects unknown providerBackendId values during launch', async () => {
|
|
const handler = handlers.get(TEAM_LAUNCH)!;
|
|
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
|
teamName: 'my-team',
|
|
cwd: os.tmpdir(),
|
|
providerId: 'anthropic',
|
|
providerBackendId: 'not-a-backend',
|
|
model: 'sonnet',
|
|
})) as { success: boolean; error?: string };
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('providerBackendId must be valid');
|
|
expect(provisioningService.launchTeam).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('launchTeam preserves top-level OpenCode provider and backend', async () => {
|
|
const handler = handlers.get(TEAM_LAUNCH)!;
|
|
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
|
teamName: 'opencode-runtime-team',
|
|
cwd: os.tmpdir(),
|
|
providerId: 'opencode',
|
|
providerBackendId: 'opencode-cli',
|
|
model: 'opencode/minimax-m2.5-free',
|
|
effort: 'medium',
|
|
})) as { success: boolean };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(provisioningService.launchTeam).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
teamName: 'opencode-runtime-team',
|
|
providerId: 'opencode',
|
|
providerBackendId: 'opencode-cli',
|
|
model: 'opencode/minimax-m2.5-free',
|
|
effort: 'medium',
|
|
}),
|
|
expect.any(Function)
|
|
);
|
|
});
|
|
|
|
it('handleReplaceMembers accepts members: []', async () => {
|
|
const handler = handlers.get(TEAM_REPLACE_MEMBERS)!;
|
|
const result = (await handler({} as never, 'my-team', {
|
|
members: [],
|
|
})) as { success: boolean };
|
|
expect(result.success).toBe(true);
|
|
expect(service.replaceMembers).toHaveBeenCalledWith('my-team', { members: [] });
|
|
});
|
|
|
|
it('still rejects members as non-array in createTeam', async () => {
|
|
const handler = handlers.get(TEAM_CREATE)!;
|
|
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
|
teamName: 'solo-team',
|
|
members: 'not-array',
|
|
cwd: os.tmpdir(),
|
|
})) as { success: boolean; error: string };
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('members must be an array');
|
|
});
|
|
|
|
it('still rejects members as non-array in handleCreateConfig', async () => {
|
|
const handler = handlers.get(TEAM_CREATE_CONFIG)!;
|
|
const result = (await handler({} as never, {
|
|
teamName: 'solo-team',
|
|
members: 'not-array',
|
|
})) as { success: boolean; error: string };
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('members must be an array');
|
|
});
|
|
|
|
it('still rejects members as non-array in handleReplaceMembers', async () => {
|
|
const handler = handlers.get(TEAM_REPLACE_MEMBERS)!;
|
|
const result = (await handler({} as never, 'my-team', {
|
|
members: 'not-array',
|
|
})) as { success: boolean; error: string };
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('members must be an array');
|
|
});
|
|
});
|
|
|
|
describe('showMessageNotification', () => {
|
|
it('returns success on valid notification data', async () => {
|
|
const handler = handlers.get(TEAM_SHOW_MESSAGE_NOTIFICATION)!;
|
|
const result = (await handler({} as never, {
|
|
teamDisplayName: 'My Team',
|
|
from: 'alice',
|
|
body: 'Hello!',
|
|
teamName: 'my-team',
|
|
teamEventType: 'task_clarification',
|
|
dedupeKey: 'clarification:my-team:42',
|
|
})) as { success: boolean };
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('rejects when missing required fields', async () => {
|
|
const handler = handlers.get(TEAM_SHOW_MESSAGE_NOTIFICATION)!;
|
|
const result = (await handler({} as never, {
|
|
teamDisplayName: 'My Team',
|
|
// missing from and body
|
|
})) as { success: boolean; error: string };
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('Missing required fields');
|
|
});
|
|
|
|
it('rejects null data', async () => {
|
|
const handler = handlers.get(TEAM_SHOW_MESSAGE_NOTIFICATION)!;
|
|
const result = (await handler({} as never, null)) as { success: boolean };
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('generates fallback dedupeKey when not provided', async () => {
|
|
const handler = handlers.get(TEAM_SHOW_MESSAGE_NOTIFICATION)!;
|
|
const result = (await handler({} as never, {
|
|
teamDisplayName: 'My Team',
|
|
teamName: 'my-team',
|
|
from: 'bob',
|
|
body: 'Some message',
|
|
})) as { success: boolean };
|
|
// Should succeed even without explicit dedupeKey (fallback is generated)
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('rejects when teamName is missing', async () => {
|
|
const handler = handlers.get(TEAM_SHOW_MESSAGE_NOTIFICATION)!;
|
|
const result = (await handler({} as never, {
|
|
teamDisplayName: 'My Team',
|
|
from: 'alice',
|
|
body: 'Hello!',
|
|
// teamName intentionally omitted
|
|
})) as { success: boolean; error: string };
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('teamName');
|
|
});
|
|
});
|
|
|
|
describe('reserved teammate names', () => {
|
|
it('rejects teammate name "user" in createTeam', async () => {
|
|
const handler = handlers.get(TEAM_CREATE)!;
|
|
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
|
teamName: 'solo-team',
|
|
members: [{ name: 'user' }],
|
|
cwd: os.tmpdir(),
|
|
})) as { success: boolean; error: string };
|
|
expect(result.success).toBe(false);
|
|
expect(result.error.toLowerCase()).toContain('reserved');
|
|
});
|
|
|
|
it('rejects teammate name "team-lead" in createTeam', async () => {
|
|
const handler = handlers.get(TEAM_CREATE)!;
|
|
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
|
teamName: 'solo-team',
|
|
members: [{ name: 'team-lead' }],
|
|
cwd: os.tmpdir(),
|
|
})) as { success: boolean; error: string };
|
|
expect(result.success).toBe(false);
|
|
expect(result.error.toLowerCase()).toContain('reserved');
|
|
});
|
|
|
|
it('rejects addMember name "user"', async () => {
|
|
const handler = handlers.get(TEAM_ADD_MEMBER)!;
|
|
const result = (await handler({} as never, 'my-team', {
|
|
name: 'user',
|
|
})) as { success: boolean; error: string };
|
|
expect(result.success).toBe(false);
|
|
expect(result.error.toLowerCase()).toContain('reserved');
|
|
});
|
|
|
|
it('rejects addMember name "team-lead"', async () => {
|
|
const handler = handlers.get(TEAM_ADD_MEMBER)!;
|
|
const result = (await handler({} as never, 'my-team', {
|
|
name: 'team-lead',
|
|
})) as { success: boolean; error: string };
|
|
expect(result.success).toBe(false);
|
|
expect(result.error.toLowerCase()).toContain('reserved');
|
|
});
|
|
});
|
|
});
|