import { setClaudeBasePathOverride } from '@main/utils/pathDecoder'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { BoardTaskActivityDetailResult, BoardTaskActivityEntry, BoardTaskExactLogDetailResult, BoardTaskExactLogSummariesResponse, BoardTaskLogStreamResponse, InboxMessage, MessagesPage, SendMessageResult, TeamCreateRequest, TeamLaunchRequest, TeamProviderId, TeamProvisioningProgress, TeamViewSnapshot, } 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(); 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(): { promise: Promise; resolve: (value: T) => void; reject: (error: unknown) => void; } { let resolve!: (value: T) => void; let reject!: (error: unknown) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } async function flushMicrotasks(): Promise { 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 { initializeTeamHandlers, registerTeamHandlers, removeTeamHandlers, } from '../../../src/main/ipc/teams'; import { ConfigManager } from '../../../src/main/services/infrastructure/ConfigManager'; import { computeTeamWatchScope, resetTeamWatchScopeForTests, } from '../../../src/main/services/infrastructure/teamWatchScope'; import { LaunchIoGovernor } from '../../../src/main/services/team/LaunchIoGovernor'; import { getAppDataPath } from '../../../src/main/utils/pathDecoder'; import { TEAM_ADD_MEMBER, TEAM_ADD_TASK_COMMENT, TEAM_ADD_TASK_RELATIONSHIP, TEAM_ALIVE_LIST, TEAM_CANCEL_PROVISIONING, TEAM_CREATE, TEAM_CREATE_CONFIG, TEAM_CREATE_TASK, TEAM_DELETE_TASK_ATTACHMENT, TEAM_DELETE_TEAM, TEAM_GET_ALL_TASKS, TEAM_GET_ATTACHMENTS, TEAM_GET_DATA, TEAM_GET_DELETED_TASKS, TEAM_GET_LOGS_FOR_TASK, TEAM_GET_MEMBER_ACTIVITY_META, TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, TEAM_GET_MESSAGES_PAGE, TEAM_GET_PROJECT_BRANCH, TEAM_GET_TASK_ACTIVITY, TEAM_GET_TASK_ACTIVITY_DETAIL, TEAM_GET_TASK_ATTACHMENT, TEAM_GET_TASK_CHANGE_PRESENCE, TEAM_GET_TASK_EXACT_LOG_DETAIL, TEAM_GET_TASK_EXACT_LOG_SUMMARIES, TEAM_GET_TASK_LOG_STREAM, TEAM_KILL_PROCESS, TEAM_LAUNCH, TEAM_LEAD_ACTIVITY, TEAM_LEAD_CONTEXT, TEAM_LIST, TEAM_PERMANENTLY_DELETE, TEAM_PREPARE_PROVISIONING, TEAM_PROCESS_ALIVE, TEAM_PROCESS_SEND, TEAM_PROVISIONING_STATUS, TEAM_REMOVE_MEMBER, TEAM_REMOVE_TASK_RELATIONSHIP, TEAM_REPLACE_MEMBERS, TEAM_REQUEST_REVIEW, TEAM_RESTORE, TEAM_RESTORE_MEMBER, TEAM_RESTORE_TASK, TEAM_SAVE_TASK_ATTACHMENT, TEAM_SEND_MESSAGE, TEAM_SET_CHANGE_PRESENCE_TRACKING, TEAM_SET_TASK_CLARIFICATION, TEAM_SHOW_MESSAGE_NOTIFICATION, TEAM_SOFT_DELETE_TASK, TEAM_START_TASK, TEAM_STOP, TEAM_UPDATE_CONFIG, TEAM_UPDATE_KANBAN, TEAM_UPDATE_KANBAN_COLUMN_ORDER, TEAM_UPDATE_MEMBER_ROLE, TEAM_UPDATE_TASK_FIELDS, TEAM_UPDATE_TASK_OWNER, TEAM_UPDATE_TASK_STATUS, } from '../../../src/preload/constants/ipcChannels'; describe('ipc teams handlers', () => { const handlers = new Map Promise>(); const ipcMain = { handle: vi.fn((channel: string, fn: (...args: unknown[]) => Promise) => { 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 => ({ 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 => ({ 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), restoreMember: vi.fn(async () => ({ name: 'alice', role: 'Developer', providerId: 'codex' as TeamProviderId, })), 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 => 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), prepareLiveMemberMcpLaunchConfig: vi.fn(async () => null), discardLiveMemberMcpLaunchConfig: 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 => undefined ) as ReturnType< typeof vi.fn<(teamName: string, memberName: string) => Promise> >, 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['responseState']; ledgerStatus?: NonNullable['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>(async () => []), }; const boardTaskActivityDetailService = { getTaskActivityDetail: vi.fn<() => Promise>(async () => ({ status: 'missing', })), }; const boardTaskLogStreamService = { getTaskLogStream: vi.fn<() => Promise>(async () => ({ participants: [], defaultFilter: 'all', segments: [], })), }; const boardTaskExactLogsService = { getTaskExactLogSummaries: vi.fn<() => Promise>( async () => ({ items: [] }) ), }; const boardTaskExactLogDetailService = { getTaskExactLogDetail: vi.fn<() => Promise>(async () => ({ status: 'missing', })), }; beforeEach(() => { resetTeamWatchScopeForTests(); handlers.clear(); vi.clearAllMocks(); service.listTeams.mockReset(); service.getTeamData.mockReset(); service.getAllTasks.mockReset(); service.restoreMember.mockReset(); service.listTeams.mockResolvedValue([{ teamName: 'my-team', displayName: 'My Team' }]); service.getTeamData.mockResolvedValue({ teamName: 'my-team', config: { name: 'My Team' }, tasks: [], members: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }); service.getAllTasks.mockResolvedValue([ { id: 'task-1', teamName: 'my-team', subject: 'Task 1' }, ]); service.restoreMember.mockResolvedValue({ name: 'alice', role: 'Developer', providerId: 'codex' as TeamProviderId, }); 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.sendMessageToTeam.mockReset(); provisioningService.sendMessageToTeam.mockResolvedValue(undefined); provisioningService.resolveRuntimeRecipientProviderId.mockReset(); provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValue(undefined); provisioningService.prepareLiveMemberMcpLaunchConfig.mockReset(); provisioningService.prepareLiveMemberMcpLaunchConfig.mockResolvedValue(null); provisioningService.discardLiveMemberMcpLaunchConfig.mockReset(); provisioningService.discardLiveMemberMcpLaunchConfig.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(() => { resetTeamWatchScopeForTests(); 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_RESTORE_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('forwards selected model checks with effort to prepareProvisioning', async () => { const handler = handlers.get(TEAM_PREPARE_PROVISIONING)!; const result = (await handler( { sender: { send: vi.fn() } } as never, os.tmpdir(), 'anthropic', ['anthropic'], ['claude-opus-4-6[1m]'], false, 'compatibility', [ { providerId: 'anthropic', model: 'claude-opus-4-6[1m]', effort: 'medium', }, ] )) as { success: boolean }; expect(result.success).toBe(true); expect(provisioningService.prepareForProvisioning).toHaveBeenCalledWith(os.tmpdir(), { providerId: 'anthropic', providerIds: ['anthropic'], modelIds: ['claude-opus-4-6[1m]'], limitContext: false, modelVerificationMode: 'compatibility', modelChecks: [ { providerId: 'anthropic', model: 'claude-opus-4-6[1m]', effort: 'medium', }, ], }); }); it('rejects invalid selected model check effort for the provider', async () => { const handler = handlers.get(TEAM_PREPARE_PROVISIONING)!; const result = (await handler( { sender: { send: vi.fn() } } as never, os.tmpdir(), 'anthropic', ['anthropic'], ['claude-opus-4-6[1m]'], false, 'compatibility', [ { providerId: 'anthropic', model: 'claude-opus-4-6[1m]', effort: 'xhigh', }, ] )) as { success: boolean; error: string }; expect(result.success).toBe(false); expect(result.error).toContain('selectedModelChecks effort must be one of'); }); 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; }; 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('preserves attachment delivery errors when the lead process is still alive', async () => { const sendHandler = handlers.get(TEAM_SEND_MESSAGE); expect(sendHandler).toBeDefined(); provisioningService.isTeamAlive.mockReturnValue(true); provisioningService.sendMessageToTeam.mockRejectedValueOnce( new Error('Claude attachment MIME unsupported: image/avif') ); const result = (await sendHandler!({} as never, 'my-team', { member: 'team-lead', text: 'see this', attachments: [ { id: 'att-1', filename: 'screenshot.png', mimeType: 'image/png', size: 4, data: Buffer.from('test').toString('base64'), }, ], })) as { success: boolean; error?: string }; expect(result.success).toBe(false); expect(result.error).toBe( 'Failed to deliver message with attachments: Claude attachment MIME unsupported: image/avif' ); expect(result.error).not.toContain('team process became unavailable'); expect(service.sendDirectToLead).not.toHaveBeenCalled(); vi.mocked(console.error).mockClear(); }); it('reports attachment delivery as unavailable only when liveness confirms it', async () => { const sendHandler = handlers.get(TEAM_SEND_MESSAGE); expect(sendHandler).toBeDefined(); provisioningService.isTeamAlive.mockReturnValueOnce(true).mockReturnValueOnce(false); provisioningService.sendMessageToTeam.mockRejectedValueOnce(new Error('write EPIPE')); const result = (await sendHandler!({} as never, 'my-team', { member: 'team-lead', text: 'see this', attachments: [ { id: 'att-1', filename: 'screenshot.png', mimeType: 'image/png', size: 4, data: Buffer.from('test').toString('base64'), }, ], })) as { success: boolean; error?: string }; expect(result.success).toBe(false); expect(result.error).toBe( 'Failed to deliver message with attachments: team process became unavailable' ); expect(service.sendDirectToLead).not.toHaveBeenCalled(); vi.mocked(console.error).mockClear(); }); 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('marks created teams engaged before provisioning writes startup artifacts', async () => { const createTeam = 'created-watch-scope'; provisioningService.createTeam.mockImplementationOnce(async () => { expect(computeTeamWatchScope()?.has(createTeam)).toBe(true); return { runId: 'run-created-watch-scope' }; }); const result = (await handlers.get(TEAM_CREATE)!({ sender: { send: vi.fn() } } as never, { teamName: createTeam, members: [{ name: 'alice' }], cwd: os.tmpdir(), })) as { success: boolean }; expect(result.success).toBe(true); expect(computeTeamWatchScope()?.has(createTeam)).toBe(true); }); 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; }; 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('keeps live message overlay on TEAM_GET_MESSAGES_PAGE in worker path', async () => { mockTeamDataWorkerClient.isAvailable.mockReturnValue(true); const liveMessage: InboxMessage = { from: 'team-lead', text: 'Команда поднята, приступаю к раздаче задач.', timestamp: '2026-02-23T10:00:01.000Z', read: true, source: 'lead_process' as const, messageId: 'live-1', }; mockTeamDataWorkerClient.getMessagesPage.mockResolvedValueOnce({ messages: [ liveMessage, { from: 'user', text: 'Ping', timestamp: '2026-02-23T10:00:00.000Z', read: true, source: 'user_sent' as const, messageId: 'durable-1', }, ], nextCursor: null, hasMore: false, feedRevision: 'rev-worker', }); provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([liveMessage]); const handler = handlers.get(TEAM_GET_MESSAGES_PAGE)!; const result = (await handler({} as never, 'my-team', { limit: 20, })) as { success: boolean; data: MessagesPage }; expect(result.success).toBe(true); expect(result.data.messages.map((message) => message.messageId)).toEqual([ 'live-1', 'durable-1', ]); expect(result.data.feedRevision).toBe('rev-worker'); expect(mockTeamDataWorkerClient.getMessagesPage).toHaveBeenCalledWith('my-team', { cursor: undefined, limit: 20, liveMessages: [liveMessage], }); 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('rebuilds capped TEAM_GET_DATA live overlay through worker when available', async () => { mockTeamDataWorkerClient.isAvailable.mockReturnValue(true); const liveMessage: InboxMessage = { from: 'team-lead', text: 'Live thought', timestamp: '2026-02-23T11:00:00.000Z', read: true, source: 'lead_process' as const, messageId: 'live-1', }; mockTeamDataWorkerClient.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: [], }); mockTeamDataWorkerClient.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-worker', }); provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([liveMessage]); 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(mockTeamDataWorkerClient.getMessagesPage).toHaveBeenCalledWith('my-team', { limit: 50, liveMessages: [liveMessage], }); expect(service.getMessagesPage).not.toHaveBeenCalled(); 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', expect.objectContaining({ 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('passes Agent Teams MCP only launch overrides into live add-member Agent prompt', async () => { const projectPath = path.join(os.tmpdir(), 'codex live add project with spaces'); service.getTeamData.mockResolvedValueOnce({ teamName: 'my-team', config: { name: 'My Team', projectPath }, tasks: [], members: [ { name: 'team-lead', providerId: 'codex', role: 'Team Lead', currentTaskId: null, taskCount: 0, }, ], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }); provisioningService.prepareLiveMemberMcpLaunchConfig.mockResolvedValueOnce({ mcpConfigPath: '/tmp/codex live add/alice-app-only.json', mcpSettingSources: 'user,project,local', strictMcpConfig: true, } as never); const handler = handlers.get(TEAM_ADD_MEMBER)!; const result = (await handler({} as never, 'my-team', { name: 'alice', role: 'developer', providerId: 'codex', mcpPolicy: { mode: 'appOnly' }, })) as { success: boolean }; expect(result.success).toBe(true); expect(provisioningService.prepareLiveMemberMcpLaunchConfig).toHaveBeenCalledWith({ teamName: 'my-team', cwd: projectPath, mcpPolicy: { mode: 'appOnly' }, }); expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith( 'my-team', expect.stringContaining( 'mcp_config="/tmp/codex live add/alice-app-only.json", mcp_setting_sources="user,project,local", strict_mcp_config=true' ) ); }); it('discards live add-member MCP config if lead notification fails after config creation', async () => { const mcpLaunchConfig = { mcpConfigPath: '/tmp/codex live add/alice-orphan-risk.json', mcpSettingSources: 'user,project,local', strictMcpConfig: true, }; provisioningService.prepareLiveMemberMcpLaunchConfig.mockResolvedValueOnce( mcpLaunchConfig as never ); provisioningService.sendMessageToTeam.mockRejectedValueOnce(new Error('lead offline')); const handler = handlers.get(TEAM_ADD_MEMBER)!; const result = (await handler({} as never, 'my-team', { name: 'alice', role: 'developer', providerId: 'codex', mcpPolicy: { mode: 'appOnly' }, })) as { success: boolean }; expect(result.success).toBe(true); expect(provisioningService.discardLiveMemberMcpLaunchConfig).toHaveBeenCalledWith({ teamName: 'my-team', mcpLaunchConfig, }); vi.mocked(console.warn).mockClear(); }); 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, mcpPolicy: 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 restoreMemberHandler = handlers.get(TEAM_RESTORE_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', expect.objectContaining({ 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 restoreMemberHandler({} as never, 'my-team', 'alice')) as { success: boolean; }; expect(result.success).toBe(true); expect(service.restoreMember).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('restoreMember', () => { it('calls service on valid input', async () => { const handler = handlers.get(TEAM_RESTORE_MEMBER)!; const result = (await handler({} as never, 'my-team', 'alice')) as { success: boolean }; expect(result.success).toBe(true); expect(service.restoreMember).toHaveBeenCalledWith('my-team', 'alice'); }); it('rejects invalid team name', async () => { const handler = handlers.get(TEAM_RESTORE_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_RESTORE_MEMBER)!; const result = (await handler({} as never, 'my-team', '../bad')) as { success: boolean }; expect(result.success).toBe(false); }); it('passes Agent Teams MCP only launch overrides into live restore-member Agent prompt', async () => { const projectPath = path.join(os.tmpdir(), 'codex live restore project with spaces'); service.getTeamData.mockResolvedValueOnce({ teamName: 'my-team', config: { name: 'My Team', projectPath }, tasks: [], members: [ { name: 'team-lead', providerId: 'codex', role: 'Team Lead', currentTaskId: null, taskCount: 0, }, { name: 'alice', providerId: 'codex', role: 'Developer', removedAt: Date.now(), currentTaskId: null, taskCount: 0, }, ], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }); service.restoreMember.mockResolvedValueOnce({ name: 'alice', role: 'Developer', providerId: 'codex', mcpPolicy: { mode: 'appOnly' }, } as never); provisioningService.prepareLiveMemberMcpLaunchConfig.mockResolvedValueOnce({ mcpConfigPath: '/tmp/codex live restore/alice-app-only.json', mcpSettingSources: 'user,project,local', strictMcpConfig: true, } as never); const handler = handlers.get(TEAM_RESTORE_MEMBER)!; const result = (await handler({} as never, 'my-team', 'alice')) as { success: boolean }; expect(result.success).toBe(true); expect(provisioningService.prepareLiveMemberMcpLaunchConfig).toHaveBeenCalledWith({ teamName: 'my-team', cwd: projectPath, mcpPolicy: { mode: 'appOnly' }, }); expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith( 'my-team', expect.stringContaining( 'mcp_config="/tmp/codex live restore/alice-app-only.json", mcp_setting_sources="user,project,local", strict_mcp_config=true' ) ); }); it('reattaches a restored OpenCode teammate on a live mixed team', async () => { const handler = handlers.get(TEAM_RESTORE_MEMBER)!; service.restoreMember.mockResolvedValueOnce({ name: 'alice', providerId: 'opencode', role: 'Developer', }); 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', role: 'Developer', removedAt: Date.now(), currentTaskId: null, taskCount: 0, }, ], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }); const result = (await handler({} as never, 'my-team', 'alice')) as { success: boolean }; expect(result.success).toBe(true); expect(provisioningService.reattachOpenCodeOwnedMemberLane).toHaveBeenCalledWith( 'my-team', 'alice', { reason: 'member_added' } ); expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled(); }); it('blocks live restoreMember for a running OpenCode-led team before metadata is changed', async () => { const handler = handlers.get(TEAM_RESTORE_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', removedAt: Date.now(), 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.restoreMember).not.toHaveBeenCalled(); expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled(); vi.mocked(console.error).mockClear(); }); }); describe('replaceMembers', () => { it('passes Agent Teams MCP only launch overrides into live replace-members added teammate prompt', async () => { const projectPath = path.join(os.tmpdir(), 'codex live replace project with spaces'); service.getTeamData.mockResolvedValueOnce({ teamName: 'my-team', config: { name: 'My Team', projectPath }, tasks: [], members: [ { name: 'team-lead', providerId: 'codex', role: 'Team Lead', currentTaskId: null, taskCount: 0, }, ], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }); provisioningService.prepareLiveMemberMcpLaunchConfig.mockResolvedValueOnce({ mcpConfigPath: '/tmp/codex live replace/alice-app-only.json', mcpSettingSources: 'user,project,local', strictMcpConfig: true, } as never); const handler = handlers.get(TEAM_REPLACE_MEMBERS)!; const result = (await handler({} as never, 'my-team', { members: [ { name: 'alice', role: 'Developer', providerId: 'codex', mcpPolicy: { mode: 'appOnly' }, }, ], })) as { success: boolean }; expect(result.success).toBe(true); expect(provisioningService.prepareLiveMemberMcpLaunchConfig).toHaveBeenCalledWith({ teamName: 'my-team', cwd: projectPath, mcpPolicy: { mode: 'appOnly' }, }); expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith( 'my-team', expect.stringContaining( 'mcp_config="/tmp/codex live replace/alice-app-only.json", mcp_setting_sources="user,project,local", strict_mcp_config=true' ) ); }); it('reports existing teammate MCP policy changes in live replace-members summary', async () => { 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 handler = handlers.get(TEAM_REPLACE_MEMBERS)!; const result = (await handler({} as never, 'my-team', { members: [ { name: 'alice', role: 'Developer', providerId: 'codex', mcpPolicy: { mode: 'appOnly' }, }, ], })) as { success: boolean }; expect(result.success).toBe(true); expect(provisioningService.prepareLiveMemberMcpLaunchConfig).not.toHaveBeenCalled(); expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith( 'my-team', expect.stringContaining('MCP access policy changed - restart required') ); }); 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_RESTORE_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(computeTeamWatchScope()?.has('draft-team')).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'); }); }); });