agent-ecosystem/test/main/ipc/teams.test.ts

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');
});
});
});