import { beforeEach, describe, expect, it, vi } from 'vitest'; const hoisted = vi.hoisted(() => { const files = new Map(); const norm = (p: string): string => p.replace(/\\/g, '/'); const stat = vi.fn(async (filePath: string) => { const data = files.get(norm(filePath)); if (data === undefined) { const error = new Error('ENOENT') as NodeJS.ErrnoException; error.code = 'ENOENT'; throw error; } return { isFile: () => true, size: Buffer.byteLength(data, 'utf8') }; }); const readFile = vi.fn(async (filePath: string) => { const data = files.get(norm(filePath)); if (data === undefined) { const error = new Error('ENOENT') as NodeJS.ErrnoException; error.code = 'ENOENT'; throw error; } return data; }); const atomicWrite = vi.fn(async (filePath: string, data: string) => { files.set(norm(filePath), data); }); return { files, stat, readFile, atomicWrite, appendSentMessage: vi.fn((teamName: string, message: Record) => { const p = `/mock/teams/${teamName}/sentMessages.json`; const current = files.get(p); const rows = current ? (JSON.parse(current) as unknown[]) : []; rows.push(message); files.set(p, JSON.stringify(rows)); return message; }), sendInboxMessage: vi.fn( (teamName: string, message: Record) => { const member = typeof message.member === 'string' ? message.member : typeof message.to === 'string' ? message.to : 'unknown'; const p = `/mock/teams/${teamName}/inboxes/${member}.json`; const current = files.get(p); const rows = current ? (JSON.parse(current) as unknown[]) : []; rows.push(message); files.set(p, JSON.stringify(rows)); return { deliveredToInbox: true, messageId: 'mock-id', message }; } ), }; }); vi.mock('fs', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, promises: { ...actual.promises, stat: hoisted.stat, readFile: hoisted.readFile }, }; }); vi.mock('../../../../src/main/services/team/atomicWrite', () => ({ atomicWriteAsync: hoisted.atomicWrite, })); vi.mock('../../../../src/main/services/team/fileLock', () => ({ withFileLock: async (_filePath: string, fn: () => Promise) => await fn(), withFileLockSync: (_filePath: string, fn: () => unknown) => fn(), })); vi.mock('../../../../src/main/services/team/inboxLock', () => ({ withInboxLock: async (_filePath: string, fn: () => Promise) => await fn(), })); vi.mock('../../../../src/main/utils/pathDecoder', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getTeamsBasePath: () => '/mock/teams' }; }); vi.mock('../../../../src/main/utils/fsRead', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, readFileUtf8WithTimeout: hoisted.readFile, }; }); vi.mock('agent-teams-controller', () => ({ AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES: [] as readonly string[], AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES: [] as readonly string[], AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES: [] as readonly string[], createController: ({ teamName }: { teamName: string }) => ({ messages: { appendSentMessage: (message: Record) => hoisted.appendSentMessage(teamName, message), sendMessage: (message: Record) => hoisted.sendInboxMessage(teamName, message), }, }), protocols: { buildActionModeProtocolText: (delegate: string) => `ACTION MODE PROTOCOL (mock, delegate: ${delegate})`, buildProcessProtocolText: (teamName: string) => `BACKGROUND PROCESS REGISTRATION (mock for ${teamName})`, }, })); import { ConfigManager } from '../../../../src/main/services/infrastructure/ConfigManager'; import { clearAutoResumeService, getAutoResumeService, initializeAutoResumeService, } from '../../../../src/main/services/team/AutoResumeService'; import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService'; import type { TeamChangeEvent } from '@shared/types/team'; function seedConfig(teamName: string): void { hoisted.files.set( `/mock/teams/${teamName}/config.json`, JSON.stringify({ name: 'My Team', members: [{ name: 'team-lead', agentType: 'team-lead' }], }) ); } function seedLeadInbox(teamName: string, messages: unknown[]): void { hoisted.files.set(`/mock/teams/${teamName}/inboxes/team-lead.json`, JSON.stringify(messages)); } interface RunLike { runId: string; teamName: string; progress: { runId: string; teamName: string; state: 'spawning' | 'running'; message: string; startedAt: string; updatedAt: string; error?: string; }; provisioningComplete: boolean; detectedSessionId?: string | null; leadMsgSeq: number; pendingToolCalls: { name: string; preview: string }[]; activeToolCalls: Map; pendingDirectCrossTeamSendRefresh: boolean; lastLeadTextEmitMs: number; leadRelayCapture: null; silentUserDmForward: | null | { target: string; startedAt: string; mode: 'user_dm' | 'member_inbox_relay' }; suppressPostCompactReminderOutput?: boolean; child: Record | null; processKilled: boolean; cancelRequested: boolean; provisioningOutputParts: string[]; provisioningOutputIndexByMessageId: Map; request: { members: { name: string; role?: string }[] }; activeCrossTeamReplyHints?: Array<{ toTeam: string; conversationId: string }>; pendingInboxRelayCandidates?: unknown[]; liveLeadTextBuffer?: unknown; memberSpawnStatuses: Map; pendingApprovals: Map; } /** * Attach a run to the service internals. `provisioningComplete` defaults to false * (pre-ready) to test the early message pipeline. */ function attachRun( service: TeamProvisioningService, teamName: string, opts?: { provisioningComplete?: boolean; runId?: string; detectedSessionId?: string | null } ): RunLike { const runId = opts?.runId ?? 'run-1'; const now = new Date().toISOString(); const provisioningComplete = opts?.provisioningComplete ?? false; const run: RunLike = { runId, teamName, progress: { runId, teamName, state: provisioningComplete ? 'running' : 'spawning', message: provisioningComplete ? 'Running' : 'Starting', startedAt: now, updatedAt: now, }, provisioningComplete, detectedSessionId: opts?.detectedSessionId ?? null, leadMsgSeq: 0, pendingToolCalls: [], activeToolCalls: new Map(), pendingDirectCrossTeamSendRefresh: false, lastLeadTextEmitMs: 0, leadRelayCapture: null, silentUserDmForward: null, pendingInboxRelayCandidates: [], child: { stdin: { writable: true, write: vi.fn(), end: vi.fn() } }, processKilled: false, cancelRequested: false, provisioningOutputParts: [], provisioningOutputIndexByMessageId: new Map(), request: { members: [{ name: 'team-lead', role: 'Team Lead' }] }, activeCrossTeamReplyHints: [], liveLeadTextBuffer: null, memberSpawnStatuses: new Map(), pendingApprovals: new Map(), }; (service as unknown as { aliveRunByTeam: Map }).aliveRunByTeam.set( teamName, runId ); (service as unknown as { runs: Map }).runs.set(runId, run); return run; } function callHandleStreamJsonMessage( service: TeamProvisioningService, run: RunLike, msg: Record ): void { (service as unknown as { handleStreamJsonMessage: (r: unknown, m: unknown) => void }) .handleStreamJsonMessage(run, msg); } describe('TeamProvisioningService pre-ready live messages', () => { beforeEach(() => { hoisted.files.clear(); hoisted.appendSentMessage.mockClear(); hoisted.sendInboxMessage.mockClear(); }); it('pre-ready assistant text is added to liveLeadProcessMessages', () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const run = attachRun(service, 'my-team', { provisioningComplete: false }); callHandleStreamJsonMessage(service, run, { type: 'assistant', content: [{ type: 'text', text: 'Команда создана. Запускаю всех тиммейтов параллельно.' }], }); const live = service.getLiveLeadProcessMessages('my-team'); expect(live).toHaveLength(1); expect(live[0].text).toBe('Команда создана. Запускаю всех тиммейтов параллельно.'); expect(live[0].source).toBe('lead_process'); expect(live[0].messageId).toMatch(/^lead-turn-run-1-1$/); // Also still in provisioningOutputParts for the banner expect(run.provisioningOutputParts).toHaveLength(1); }); it('coalesces Codex synthetic chunks through stream handling without dropping spaces', () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const run = attachRun(service, 'my-team', { provisioningComplete: false }); const emitSyntheticText = (text: string, timestamp: string): void => { callHandleStreamJsonMessage(service, run, { type: 'assistant', timestamp, message: { id: `msg-${timestamp}`, model: '', type: 'message', content: [{ type: 'text', text }], }, }); }; emitSyntheticText('Пр', '2026-04-17T12:00:00.000Z'); emitSyntheticText('ин', '2026-04-17T12:00:00.010Z'); emitSyntheticText('ял', '2026-04-17T12:00:00.020Z'); emitSyntheticText(':', '2026-04-17T12:00:00.030Z'); emitSyntheticText(' раз', '2026-04-17T12:00:00.040Z'); emitSyntheticText('лож', '2026-04-17T12:00:00.050Z'); emitSyntheticText('у', '2026-04-17T12:00:00.060Z'); const live = service.getLiveLeadProcessMessages('my-team'); expect(live).toHaveLength(1); expect(live[0].text).toBe('Принял: разложу'); expect(live[0].messageId).toBe('lead-turn-run-1-1'); }); it('attaches leadSessionId to a live message when the same assistant payload carries session_id', () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const run = attachRun(service, 'my-team', { provisioningComplete: false }); callHandleStreamJsonMessage(service, run, { type: 'assistant', session_id: 'sess-123', content: [{ type: 'text', text: 'Команда создана. Запускаю всех тиммейтов параллельно.' }], }); const live = service.getLiveLeadProcessMessages('my-team'); expect(live).toHaveLength(1); expect(live[0].leadSessionId).toBe('sess-123'); }); it('makes leadSessionId visible to synchronous lead-message listeners in the same turn', () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const seenSessionIds: Array = []; service.setTeamChangeEmitter((event) => { if (event.type === 'lead-message') { seenSessionIds.push(service.getLiveLeadProcessMessages('my-team')[0]?.leadSessionId); } }); const run = attachRun(service, 'my-team', { provisioningComplete: false }); callHandleStreamJsonMessage(service, run, { type: 'assistant', session_id: 'sess-sync', content: [{ type: 'text', text: 'Команда создана. Запускаю всех тиммейтов параллельно.' }], }); expect(seenSessionIds).toEqual(['sess-sync']); }); it('retrofits leadSessionId onto earlier live messages after session detection', () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const run = attachRun(service, 'my-team', { provisioningComplete: false }); callHandleStreamJsonMessage(service, run, { type: 'assistant', content: [{ type: 'text', text: 'Команда создана. Запускаю всех тиммейтов параллельно.' }], }); expect(service.getLiveLeadProcessMessages('my-team')[0]?.leadSessionId).toBeUndefined(); callHandleStreamJsonMessage(service, run, { type: 'assistant', session_id: 'sess-456', content: [], }); const live = service.getLiveLeadProcessMessages('my-team'); expect(live).toHaveLength(1); expect(live[0].leadSessionId).toBe('sess-456'); }); it('emits lead-message event type (not inbox)', () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const emitter = vi.fn<(event: TeamChangeEvent) => void>(); service.setTeamChangeEmitter(emitter); const run = attachRun(service, 'my-team', { provisioningComplete: false }); callHandleStreamJsonMessage(service, run, { type: 'assistant', content: [{ type: 'text', text: 'Launching teammates now.' }], }); expect(emitter).toHaveBeenCalledTimes(1); expect(emitter).toHaveBeenCalledWith( expect.objectContaining({ type: 'lead-message', teamName: 'my-team' }) ); }); it('coalesces rapid emissions via LEAD_TEXT_EMIT_THROTTLE_MS', () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const emitter = vi.fn<(event: TeamChangeEvent) => void>(); service.setTeamChangeEmitter(emitter); const run = attachRun(service, 'my-team', { provisioningComplete: false }); // First message: should emit callHandleStreamJsonMessage(service, run, { type: 'assistant', content: [{ type: 'text', text: 'Message 1' }], }); expect(emitter).toHaveBeenCalledTimes(1); // Second message immediately after: should be coalesced (not emitted again) callHandleStreamJsonMessage(service, run, { type: 'assistant', content: [{ type: 'text', text: 'Message 2' }], }); expect(emitter).toHaveBeenCalledTimes(1); // Still 1 // Messages are still cached though const live = service.getLiveLeadProcessMessages('my-team'); expect(live).toHaveLength(2); }); it('early live messages carry toolCalls and toolSummary', () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const run = attachRun(service, 'my-team', { provisioningComplete: false }); // First: tool_use message (no text) callHandleStreamJsonMessage(service, run, { type: 'assistant', content: [ { type: 'tool_use', name: 'TeamCreate', input: { team_name: 'super-team', description: 'test' }, }, ], }); // Then: text message — should pick up pending tool calls callHandleStreamJsonMessage(service, run, { type: 'assistant', content: [{ type: 'text', text: 'Team created successfully.' }], }); const live = service.getLiveLeadProcessMessages('my-team'); expect(live).toHaveLength(1); expect(live[0].toolCalls).toBeDefined(); expect(live[0].toolCalls).toHaveLength(1); expect(live[0].toolCalls![0].name).toBe('TeamCreate'); expect(live[0].toolSummary).toBeDefined(); }); it('provisioning-time SendMessage(to:user) is captured and persisted', () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const run = attachRun(service, 'my-team', { provisioningComplete: false }); callHandleStreamJsonMessage(service, run, { type: 'assistant', content: [ { type: 'tool_use', name: 'SendMessage', input: { type: 'message', recipient: 'user', content: 'All teammates online!', summary: 'Team ready', }, }, ], }); const live = service.getLiveLeadProcessMessages('my-team'); expect(live).toHaveLength(1); expect(live[0].to).toBe('user'); expect(live[0].text).toBe('All teammates online!'); expect(live[0].source).toBe('lead_process'); // Also persisted to sentMessages.json expect(hoisted.appendSentMessage).toHaveBeenCalledTimes(1); }); it('suppresses duplicate assistant thought text when SendMessage targets a teammate', () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const run = attachRun(service, 'my-team', { provisioningComplete: true }); callHandleStreamJsonMessage(service, run, { type: 'assistant', content: [ { type: 'text', text: 'Forwarding the clarification request now.' }, { type: 'tool_use', name: 'SendMessage', input: { type: 'message', recipient: 'team-lead', content: 'Need clarification on #abcd1234', summary: 'Clarification request', }, }, ], }); const live = service.getLiveLeadProcessMessages('my-team'); expect(live).toHaveLength(1); expect(live[0].to).toBe('team-lead'); expect(live[0].text).toBe('Need clarification on #abcd1234'); expect(live[0].source).toBe('lead_process'); // Non-user recipient → delivered to inbox, not sentMessages expect(hoisted.sendInboxMessage).toHaveBeenCalledTimes(1); expect(hoisted.appendSentMessage).not.toHaveBeenCalled(); }); it('suppresses duplicate assistant thought text when SendMessage targets user', () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const run = attachRun(service, 'my-team', { provisioningComplete: true }); callHandleStreamJsonMessage(service, run, { type: 'assistant', content: [ { type: 'text', text: 'Task completed. Sending the summary now.' }, { type: 'tool_use', name: 'SendMessage', input: { type: 'message', recipient: 'user', content: 'Task completed successfully.', summary: 'Done', }, }, ], }); const live = service.getLiveLeadProcessMessages('my-team'); expect(live).toHaveLength(1); expect(live[0].to).toBe('user'); expect(live[0].text).toBe('Task completed successfully.'); expect(live[0].source).toBe('lead_process'); expect(hoisted.appendSentMessage).toHaveBeenCalledTimes(1); }); it('suppresses duplicate assistant thought text when Agent Teams message_send is already visible', () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const run = attachRun(service, 'my-team', { provisioningComplete: true }); callHandleStreamJsonMessage(service, run, { type: 'assistant', content: [ { type: 'text', text: 'Sending this through the Agent Teams MCP tool now.' }, { type: 'tool_use', name: 'mcp__agent-teams__message_send', input: { teamName: 'my-team', to: 'user', text: 'Task completed through MCP.', from: 'team-lead', summary: 'Done', }, }, ], }); // The MCP controller owns persistence for agent-teams_message_send. The stream // capture path must not show the assistant narration as a second "thought". expect(service.getLiveLeadProcessMessages('my-team')).toHaveLength(0); expect(hoisted.appendSentMessage).not.toHaveBeenCalled(); }); it('keeps assistant thought text when Agent Teams message_send payload is incomplete', () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const run = attachRun(service, 'my-team', { provisioningComplete: true }); callHandleStreamJsonMessage(service, run, { type: 'assistant', content: [ { type: 'text', text: 'I need to retry this because the tool input is incomplete.' }, { type: 'tool_use', name: 'mcp__agent-teams__message_send', input: { teamName: 'my-team', to: 'user', from: 'team-lead', summary: 'Incomplete', }, }, ], }); const live = service.getLiveLeadProcessMessages('my-team'); expect(live).toHaveLength(1); expect(live[0].text).toBe('I need to retry this because the tool input is incomplete.'); expect(live[0].source).toBe('lead_process'); }); it('post-ready path also uses the unified helper', () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const emitter = vi.fn<(event: TeamChangeEvent) => void>(); service.setTeamChangeEmitter(emitter); const run = attachRun(service, 'my-team', { provisioningComplete: true }); callHandleStreamJsonMessage(service, run, { type: 'assistant', content: [{ type: 'text', text: 'Assigning tasks now.' }], }); const live = service.getLiveLeadProcessMessages('my-team'); expect(live).toHaveLength(1); expect(live[0].source).toBe('lead_process'); // Post-ready also emits lead-message expect(emitter).toHaveBeenCalledWith( expect.objectContaining({ type: 'lead-message', teamName: 'my-team' }) ); }); it('suppresses bare transcript speaker placeholders from lead thought output', () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const emitter = vi.fn<(event: TeamChangeEvent) => void>(); service.setTeamChangeEmitter(emitter); const run = attachRun(service, 'my-team', { provisioningComplete: true }); callHandleStreamJsonMessage(service, run, { type: 'assistant', content: [{ type: 'text', text: 'Human: ' }], }); expect(service.getLiveLeadProcessMessages('my-team')).toHaveLength(0); expect(emitter).not.toHaveBeenCalled(); }); it('SendMessage(to:teammate) creates inbox row and emits inbox detail for recipient', () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const emitter = vi.fn<(event: TeamChangeEvent) => void>(); service.setTeamChangeEmitter(emitter); const run = attachRun(service, 'my-team', { provisioningComplete: true }); callHandleStreamJsonMessage(service, run, { type: 'assistant', content: [ { type: 'tool_use', name: 'SendMessage', input: { type: 'message', recipient: 'alice', content: 'Please check the migration.', summary: 'Migration check', }, }, ], }); // Delivered to recipient inbox, not sentMessages expect(hoisted.sendInboxMessage).toHaveBeenCalledTimes(1); expect(hoisted.sendInboxMessage).toHaveBeenCalledWith( 'my-team', expect.objectContaining({ member: 'alice' }) ); expect(hoisted.appendSentMessage).not.toHaveBeenCalled(); // Emits inbox event for the specific recipient expect(emitter).toHaveBeenCalledWith( expect.objectContaining({ type: 'inbox', teamName: 'my-team', detail: 'inboxes/alice.json', }) ); }); it('SendMessage(to:user) still persists to sentMessages.json', () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const emitter = vi.fn<(event: TeamChangeEvent) => void>(); service.setTeamChangeEmitter(emitter); const run = attachRun(service, 'my-team', { provisioningComplete: true }); callHandleStreamJsonMessage(service, run, { type: 'assistant', content: [ { type: 'tool_use', name: 'SendMessage', input: { type: 'message', recipient: 'user', content: 'Task completed!', summary: 'Done', }, }, ], }); expect(hoisted.appendSentMessage).toHaveBeenCalledTimes(1); expect(hoisted.sendInboxMessage).not.toHaveBeenCalled(); expect(emitter).toHaveBeenCalledWith( expect.objectContaining({ type: 'inbox', teamName: 'my-team', detail: 'sentMessages.json', }) ); }); it('canonicalizes capitalized SendMessage user recipients before persistence', () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const run = attachRun(service, 'my-team', { provisioningComplete: true }); callHandleStreamJsonMessage(service, run, { type: 'assistant', content: [ { type: 'tool_use', name: 'SendMessage', input: { type: 'message', recipient: 'User', content: 'Task completed!', summary: 'Done', }, }, ], }); expect(hoisted.appendSentMessage).toHaveBeenCalledWith( 'my-team', expect.objectContaining({ to: 'user' }) ); expect(hoisted.sendInboxMessage).not.toHaveBeenCalled(); }); it('upgrades qualified SendMessage recipients into cross-team sends', async () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const emitter = vi.fn<(event: TeamChangeEvent) => void>(); const crossTeamSender = vi.fn(async () => ({ deliveredToInbox: true, messageId: 'cross-1' })); service.setTeamChangeEmitter(emitter); service.setCrossTeamSender(crossTeamSender); const run = attachRun(service, 'my-team', { provisioningComplete: true }); run.activeCrossTeamReplyHints = [{ toTeam: 'team-best', conversationId: 'conv-123' }]; callHandleStreamJsonMessage(service, run, { type: 'assistant', content: [ { type: 'tool_use', name: 'SendMessage', input: { type: 'message', recipient: 'team-best.user', content: 'Привет!', summary: 'Ответ', }, }, ], }); await vi.waitFor(() => { expect(crossTeamSender).toHaveBeenCalledTimes(1); }); expect(crossTeamSender).toHaveBeenCalledWith( expect.objectContaining({ fromTeam: 'my-team', fromMember: 'team-lead', toTeam: 'team-best', text: 'Привет!', conversationId: 'conv-123', replyToConversationId: 'conv-123', }) ); const live = service.getLiveLeadProcessMessages('my-team'); expect(live).toHaveLength(1); expect(live[0].from).toBe('team-lead'); expect(live[0].source).toBe('cross_team_sent'); expect(live[0].to).toBe('team-best.user'); expect(live[0].text).toBe('Привет!'); expect(hoisted.sendInboxMessage).not.toHaveBeenCalled(); expect(hoisted.appendSentMessage).not.toHaveBeenCalled(); }); it('ignores stale cross-team send completions from an older run after a new run starts', async () => { const service = new TeamProvisioningService(); seedConfig('my-team'); let resolveSend: ((value: { deliveredToInbox: boolean; messageId: string }) => void) | null = null; const crossTeamSender = vi.fn( () => new Promise<{ deliveredToInbox: boolean; messageId: string }>((resolve) => { resolveSend = resolve; }) ); service.setCrossTeamSender(crossTeamSender); const oldRun = attachRun(service, 'my-team', { provisioningComplete: true, runId: 'run-old', detectedSessionId: 'sess-old', }); oldRun.activeCrossTeamReplyHints = [{ toTeam: 'team-best', conversationId: 'conv-old' }]; callHandleStreamJsonMessage(service, oldRun, { type: 'assistant', content: [ { type: 'tool_use', name: 'SendMessage', input: { type: 'message', recipient: 'team-best.user', content: 'Old run cross-team reply.', summary: 'Old run reply', }, }, ], }); await vi.waitFor(() => { expect(crossTeamSender).toHaveBeenCalledTimes(1); }); const newRun = attachRun(service, 'my-team', { provisioningComplete: true, runId: 'run-new', detectedSessionId: 'sess-new', }); service.pushLiveLeadProcessMessage('my-team', { from: 'team-lead', text: 'Current run is active.', timestamp: '2026-04-17T12:00:10.000Z', read: true, source: 'lead_process', messageId: 'lead-turn-run-new-1', leadSessionId: 'sess-new', }); expect(resolveSend).not.toBeNull(); const finishSend = resolveSend as unknown as (( value: { deliveredToInbox: boolean; messageId: string } ) => void); finishSend({ deliveredToInbox: true, messageId: 'cross-stale-old-run' }); await Promise.resolve(); await Promise.resolve(); expect(service.getLiveLeadProcessMessages('my-team')).toEqual([ expect.objectContaining({ text: 'Current run is active.', messageId: 'lead-turn-run-new-1', leadSessionId: 'sess-new', }), ]); (service as unknown as { cleanupRun: (runLike: unknown) => void }).cleanupRun(oldRun); (service as unknown as { cleanupRun: (runLike: unknown) => void }).cleanupRun(newRun); }); it('upgrades pseudo cross-team recipients into cross-team sends', async () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const crossTeamSender = vi.fn(async () => ({ deliveredToInbox: true, messageId: 'cross-2' })); service.setCrossTeamSender(crossTeamSender); const run = attachRun(service, 'my-team', { provisioningComplete: true }); callHandleStreamJsonMessage(service, run, { type: 'assistant', content: [ { type: 'tool_use', name: 'SendMessage', input: { type: 'message', recipient: 'cross-team:team-best', content: 'Привет команде!', summary: 'Приветствие', }, }, ], }); await vi.waitFor(() => { expect(crossTeamSender).toHaveBeenCalledTimes(1); }); expect(crossTeamSender).toHaveBeenCalledWith( expect.objectContaining({ fromTeam: 'my-team', fromMember: 'team-lead', toTeam: 'team-best', text: 'Привет команде!', }) ); const live = service.getLiveLeadProcessMessages('my-team'); expect(live).toHaveLength(1); expect(live[0].from).toBe('team-lead'); expect(live[0].source).toBe('cross_team_sent'); expect(live[0].to).toBe('cross-team:team-best'); expect(hoisted.sendInboxMessage).not.toHaveBeenCalled(); }); it('upgrades MCP message_send pseudo recipients into cross-team sends', async () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const crossTeamSender = vi.fn(async () => ({ deliveredToInbox: true, messageId: 'cross-mcp-1' })); service.setCrossTeamSender(crossTeamSender); const run = attachRun(service, 'my-team', { provisioningComplete: true }); run.activeCrossTeamReplyHints = [{ toTeam: 'team-best', conversationId: 'conv-mcp-1' }]; const taskRefs = [{ taskId: 'task-1', displayId: 'abcd1234', teamName: 'my-team' }]; callHandleStreamJsonMessage(service, run, { type: 'assistant', content: [ { type: 'tool_use', name: 'mcp__agent-teams__message_send', input: { teamName: 'my-team', to: 'cross-team:team-best', text: 'Ответ через MCP.', from: 'team-lead', summary: 'MCP reply', taskRefs, }, }, ], }); await vi.waitFor(() => { expect(crossTeamSender).toHaveBeenCalledTimes(1); }); expect(crossTeamSender).toHaveBeenCalledWith( expect.objectContaining({ fromTeam: 'my-team', fromMember: 'team-lead', toTeam: 'team-best', text: 'Ответ через MCP.', conversationId: 'conv-mcp-1', replyToConversationId: 'conv-mcp-1', taskRefs, }) ); const live = service.getLiveLeadProcessMessages('my-team'); expect(live).toHaveLength(1); expect(live[0].from).toBe('team-lead'); expect(live[0].source).toBe('cross_team_sent'); expect(live[0].to).toBe('cross-team:team-best'); expect(live[0].taskRefs).toEqual(taskRefs); expect(hoisted.sendInboxMessage).not.toHaveBeenCalled(); }); it('refreshes sentMessages history after direct MCP cross_team_send succeeds', () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const emitter = vi.fn<(event: TeamChangeEvent) => void>(); service.setTeamChangeEmitter(emitter); const run = attachRun(service, 'my-team', { provisioningComplete: true }); callHandleStreamJsonMessage(service, run, { type: 'assistant', content: [ { type: 'tool_use', name: 'mcp__agent-teams__cross_team_send', input: { teamName: 'my-team', toTeam: 'team-best', text: 'Прямой вызов MCP.', summary: 'Direct MCP send', }, }, ], }); expect(run.pendingDirectCrossTeamSendRefresh).toBe(true); callHandleStreamJsonMessage(service, run, { type: 'result', subtype: 'success', }); expect(run.pendingDirectCrossTeamSendRefresh).toBe(false); expect(emitter).toHaveBeenCalledWith( expect.objectContaining({ type: 'inbox', teamName: 'my-team', detail: 'sentMessages.json', }) ); }); it('marks native cross-team teammate-message deliveries as read and restores reply hints', async () => { const service = new TeamProvisioningService(); seedConfig('my-team'); seedLeadInbox('my-team', [ { from: 'other-team.team-lead', to: 'team-lead', text: '\nНативная доставка.', timestamp: '2026-02-23T10:01:00.000Z', read: false, source: 'cross_team', messageId: 'm-native-cross-team-1', conversationId: 'conv-native-1', replyToConversationId: 'conv-native-1', }, ]); const run = attachRun(service, 'my-team', { provisioningComplete: true }); callHandleStreamJsonMessage(service, run, { type: 'user', message: { role: 'user', content: '\nНативная доставка.', }, }); await vi.waitFor(() => { const updatedInbox = JSON.parse( hoisted.files.get('/mock/teams/my-team/inboxes/team-lead.json') ?? '[]' ) as Array<{ read?: boolean }>; expect(updatedInbox[0]?.read).toBe(true); }); expect(run.activeCrossTeamReplyHints).toEqual([ { toTeam: 'other-team', conversationId: 'conv-native-1' }, ]); }); it('suppresses native duplicate cross-team teammate-message after recent relay delivery', async () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const content = '\nПовторная доставка.'; seedLeadInbox('my-team', [ { from: 'other-team.team-lead', to: 'team-lead', text: content, timestamp: '2026-03-10T21:43:00.000Z', read: false, source: 'cross_team', messageId: 'm-native-cross-team-dup', conversationId: 'conv-native-dup', replyToConversationId: 'conv-native-dup', }, ]); const run = attachRun(service, 'my-team', { provisioningComplete: true }); ( service as unknown as { rememberRecentCrossTeamLeadDeliveryMessageIds: ( teamName: string, messageIds: string[] ) => void; } ).rememberRecentCrossTeamLeadDeliveryMessageIds('my-team', ['m-native-cross-team-dup']); callHandleStreamJsonMessage(service, run, { type: 'user', message: { role: 'user', content: `${content}`, }, }); await vi.waitFor(() => { const updatedInbox = JSON.parse( hoisted.files.get('/mock/teams/my-team/inboxes/team-lead.json') ?? '[]' ) as Array<{ read?: boolean }>; expect(updatedInbox[0]?.read).toBe(true); }); expect(run.activeCrossTeamReplyHints).toEqual([]); }); it('rescues mistaken cross_team_send recipients into actual cross-team replies', async () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const crossTeamSender = vi.fn(async () => ({ deliveredToInbox: true, messageId: 'cross-mcp-tool-1' })); service.setCrossTeamSender(crossTeamSender); const run = attachRun(service, 'my-team', { provisioningComplete: true }); run.activeCrossTeamReplyHints = [{ toTeam: 'team-best', conversationId: 'conv-tool-1' }]; callHandleStreamJsonMessage(service, run, { type: 'assistant', content: [ { type: 'tool_use', name: 'mcp__agent-teams__message_send', input: { teamName: 'my-team', to: 'cross_team_send', text: 'Исправленный ответ.', from: 'team-lead', summary: 'Ответ через tool recipient mistake', }, }, ], }); await vi.waitFor(() => { expect(crossTeamSender).toHaveBeenCalledTimes(1); }); expect(crossTeamSender).toHaveBeenCalledWith( expect.objectContaining({ fromTeam: 'my-team', fromMember: 'team-lead', toTeam: 'team-best', text: 'Исправленный ответ.', conversationId: 'conv-tool-1', replyToConversationId: 'conv-tool-1', }) ); const live = service.getLiveLeadProcessMessages('my-team'); expect(live).toHaveLength(1); expect(live[0].from).toBe('team-lead'); expect(live[0].source).toBe('cross_team_sent'); expect(live[0].to).toBe('team-best.team-lead'); expect(hoisted.sendInboxMessage).not.toHaveBeenCalled(); }); it('rescues cross_team::team pseudo recipients into actual cross-team replies', async () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const crossTeamSender = vi.fn(async () => ({ deliveredToInbox: true, messageId: 'cross-colon-1' })); service.setCrossTeamSender(crossTeamSender); const run = attachRun(service, 'my-team', { provisioningComplete: true }); callHandleStreamJsonMessage(service, run, { type: 'assistant', content: [ { type: 'tool_use', name: 'mcp__agent-teams__message_send', input: { teamName: 'my-team', to: 'cross_team::team-best', text: 'Ответ через fallback pseudo recipient.', summary: 'Fallback pseudo reply', }, }, ], }); await vi.waitFor(() => { expect(crossTeamSender).toHaveBeenCalledTimes(1); }); expect(crossTeamSender).toHaveBeenCalledWith( expect.objectContaining({ fromTeam: 'my-team', fromMember: 'team-lead', toTeam: 'team-best', text: 'Ответ через fallback pseudo recipient.', }) ); const live = service.getLiveLeadProcessMessages('my-team'); expect(live).toHaveLength(1); expect(live[0].source).toBe('cross_team_sent'); expect(live[0].to).toBe('team-best.team-lead'); expect(hoisted.sendInboxMessage).not.toHaveBeenCalled(); }); it('strips canonical cross-team tag from outbound cross-team content', async () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const crossTeamSender = vi.fn(async () => ({ deliveredToInbox: true, messageId: 'cross-legacy' })); service.setCrossTeamSender(crossTeamSender); const run = attachRun(service, 'my-team', { provisioningComplete: true }); run.activeCrossTeamReplyHints = [{ toTeam: 'team-best', conversationId: 'conv-legacy' }]; callHandleStreamJsonMessage(service, run, { type: 'assistant', content: [ { type: 'tool_use', name: 'SendMessage', input: { type: 'message', recipient: 'team-best.user', content: '\nПривет!', summary: 'Ответ', }, }, ], }); await vi.waitFor(() => { expect(crossTeamSender).toHaveBeenCalledTimes(1); }); expect(crossTeamSender).toHaveBeenCalledWith( expect.objectContaining({ text: 'Привет!', conversationId: 'conv-legacy', replyToConversationId: 'conv-legacy', }) ); }); it('does not push a duplicate live row when cross-team fallback deduplicates', async () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const emitter = vi.fn<(event: TeamChangeEvent) => void>(); const crossTeamSender = vi.fn(async () => ({ deliveredToInbox: true, messageId: 'existing-cross-1', deduplicated: true, })); service.setTeamChangeEmitter(emitter); service.setCrossTeamSender(crossTeamSender); const run = attachRun(service, 'my-team', { provisioningComplete: true }); callHandleStreamJsonMessage(service, run, { type: 'assistant', content: [ { type: 'tool_use', name: 'SendMessage', input: { type: 'message', recipient: 'team-best.user', content: 'Повтор без нового live row', summary: 'Повтор', }, }, ], }); await vi.waitFor(() => { expect(crossTeamSender).toHaveBeenCalledTimes(1); }); expect(service.getLiveLeadProcessMessages('my-team')).toHaveLength(0); expect(emitter).not.toHaveBeenCalledWith( expect.objectContaining({ type: 'lead-message', teamName: 'my-team', detail: 'cross-team-send', }) ); }); it('does not upgrade dotted local teammate names into cross-team sends', async () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const crossTeamSender = vi.fn(async () => ({ deliveredToInbox: true, messageId: 'cross-1' })); service.setCrossTeamSender(crossTeamSender); const run = attachRun(service, 'my-team', { provisioningComplete: true }); run.request.members.push({ name: 'ops.bot', role: 'Specialist' }); callHandleStreamJsonMessage(service, run, { type: 'assistant', content: [ { type: 'tool_use', name: 'SendMessage', input: { type: 'message', recipient: 'ops.bot', content: 'Please verify the rollout.', summary: 'Verify rollout', }, }, ], }); expect(crossTeamSender).not.toHaveBeenCalled(); expect(hoisted.sendInboxMessage).toHaveBeenCalledTimes(1); expect(hoisted.sendInboxMessage).toHaveBeenCalledWith( 'my-team', expect.objectContaining({ member: 'ops.bot' }) ); }); }); describe('TeamProvisioningService auto-resume cleanup', () => { beforeEach(() => { hoisted.files.clear(); hoisted.appendSentMessage.mockClear(); hoisted.sendInboxMessage.mockClear(); clearAutoResumeService(); vi.useFakeTimers(); }); afterEach(() => { clearAutoResumeService(); vi.useRealTimers(); }); it('cancels pending auto-resume timers when a run is cleaned up', async () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const run = attachRun(service, 'my-team', { provisioningComplete: true }); const autoResumeProvisioning = { getCurrentRunId: vi.fn(() => 'run-1' as string | null), isTeamAlive: vi.fn(() => true), sendMessageToTeam: vi.fn(async () => undefined), }; initializeAutoResumeService(autoResumeProvisioning); const configManager = ConfigManager.getInstance(); const actualConfig = configManager.getConfig(); const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation( () => ({ ...actualConfig, notifications: { ...actualConfig.notifications, autoResumeOnRateLimit: true, }, }) as never ); try { getAutoResumeService().handleRateLimitMessage( 'my-team', "You've hit your limit. Resets in 5 minutes.", new Date('2026-04-17T12:00:00.000Z') ); (service as unknown as { cleanupRun: (runLike: unknown) => void }).cleanupRun(run); await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 30 * 1000 + 100); expect(autoResumeProvisioning.sendMessageToTeam).not.toHaveBeenCalled(); } finally { getConfigSpy.mockRestore(); } }); it('does not let stale cleanup from an older run cancel the current run state', async () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const oldRun = attachRun(service, 'my-team', { provisioningComplete: true, runId: 'run-old', detectedSessionId: 'sess-old', }); const newRun = attachRun(service, 'my-team', { provisioningComplete: true, runId: 'run-new', detectedSessionId: 'sess-new', }); const autoResumeProvisioning = { getCurrentRunId: vi.fn(() => 'run-1' as string | null), isTeamAlive: vi.fn(() => true), sendMessageToTeam: vi.fn(async () => undefined), }; initializeAutoResumeService(autoResumeProvisioning); const configManager = ConfigManager.getInstance(); const actualConfig = configManager.getConfig(); const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation( () => ({ ...actualConfig, notifications: { ...actualConfig.notifications, autoResumeOnRateLimit: true, }, }) as never ); try { getAutoResumeService().handleRateLimitMessage( 'my-team', "You've hit your limit. Resets in 5 minutes.", new Date('2026-04-17T12:00:00.000Z') ); service.pushLiveLeadProcessMessage('my-team', { from: 'team-lead', text: 'Current run is active.', timestamp: '2026-04-17T12:00:01.000Z', read: true, source: 'lead_process', messageId: 'live-new-run', }); expect(service.getLiveLeadProcessMessages('my-team')).toHaveLength(1); (service as unknown as { cleanupRun: (runLike: unknown) => void }).cleanupRun(oldRun); expect(service.getLiveLeadProcessMessages('my-team')).toHaveLength(1); await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 30 * 1000 + 100); expect(autoResumeProvisioning.sendMessageToTeam).toHaveBeenCalledTimes(1); expect(autoResumeProvisioning.sendMessageToTeam).toHaveBeenCalledWith( 'my-team', expect.stringContaining('rate limit has reset') ); } finally { getConfigSpy.mockRestore(); (service as unknown as { cleanupRun: (runLike: unknown) => void }).cleanupRun(newRun); } }); it('removes stale live lead messages from an older run while preserving the current run', () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const oldRun = attachRun(service, 'my-team', { provisioningComplete: true, runId: 'run-old', detectedSessionId: 'sess-old', }); service.pushLiveLeadProcessMessage('my-team', { from: 'team-lead', text: "You've hit your limit. Resets in 5 minutes.", timestamp: '2026-04-17T12:00:00.000Z', read: true, source: 'lead_process', messageId: 'lead-turn-run-old-1', leadSessionId: 'sess-old', }); const newRun = attachRun(service, 'my-team', { provisioningComplete: true, runId: 'run-new', detectedSessionId: 'sess-new', }); service.pushLiveLeadProcessMessage('my-team', { from: 'team-lead', text: 'Current run is active.', timestamp: '2026-04-17T12:00:10.000Z', read: true, source: 'lead_process', messageId: 'lead-turn-run-new-1', leadSessionId: 'sess-new', }); expect(service.getLiveLeadProcessMessages('my-team')).toHaveLength(2); (service as unknown as { cleanupRun: (runLike: unknown) => void }).cleanupRun(oldRun); expect(service.getLiveLeadProcessMessages('my-team')).toEqual([ expect.objectContaining({ text: 'Current run is active.', messageId: 'lead-turn-run-new-1', leadSessionId: 'sess-new', }), ]); (service as unknown as { cleanupRun: (runLike: unknown) => void }).cleanupRun(newRun); }); it('preserves the canonical assistant timestamp for live rate-limit messages', async () => { vi.setSystemTime(new Date('2026-04-17T12:00:20.000Z')); const service = new TeamProvisioningService(); seedConfig('my-team'); const run = attachRun(service, 'my-team', { provisioningComplete: true, detectedSessionId: 'sess-live', }); const autoResumeProvisioning = { getCurrentRunId: vi.fn(() => 'run-1' as string | null), isTeamAlive: vi.fn(() => true), sendMessageToTeam: vi.fn(async () => undefined), }; initializeAutoResumeService(autoResumeProvisioning); const configManager = ConfigManager.getInstance(); const actualConfig = configManager.getConfig(); const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation( () => ({ ...actualConfig, notifications: { ...actualConfig.notifications, autoResumeOnRateLimit: true, }, }) as never ); try { callHandleStreamJsonMessage(service, run, { type: 'assistant', timestamp: '2026-04-17T12:00:00.000Z', content: [{ type: 'text', text: "You've hit your limit. Resets in 5 minutes." }], }); const live = service.getLiveLeadProcessMessages('my-team'); expect(live).toHaveLength(1); expect(live[0].timestamp).toBe('2026-04-17T12:00:00.000Z'); getAutoResumeService().handleRateLimitMessage( 'my-team', live[0].text, new Date('2026-04-17T12:00:20.000Z'), new Date(live[0].timestamp) ); await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 9 * 1000); expect(autoResumeProvisioning.sendMessageToTeam).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1500); expect(autoResumeProvisioning.sendMessageToTeam).toHaveBeenCalledTimes(1); expect(autoResumeProvisioning.sendMessageToTeam).toHaveBeenCalledWith( 'my-team', expect.stringContaining('rate limit has reset') ); } finally { getConfigSpy.mockRestore(); } }); it('schedules auto-resume from api_retry model_cooldown payloads during provisioning', async () => { vi.setSystemTime(new Date('2026-04-17T12:00:00.000Z')); const service = new TeamProvisioningService(); seedConfig('my-team'); const run = attachRun(service, 'my-team', { provisioningComplete: false, detectedSessionId: 'sess-live', }); (run as unknown as { progress: Record }).progress = { state: 'starting', updatedAt: '2026-04-17T12:00:00.000Z', }; const onProgress = vi.fn(); (run as unknown as { onProgress: (progress: unknown) => void }).onProgress = onProgress; const autoResumeProvisioning = { getCurrentRunId: vi.fn(() => 'run-1' as string | null), isTeamAlive: vi.fn(() => true), sendMessageToTeam: vi.fn(async () => undefined), }; initializeAutoResumeService(autoResumeProvisioning); const configManager = ConfigManager.getInstance(); const actualConfig = configManager.getConfig(); const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation( () => ({ ...actualConfig, notifications: { ...actualConfig.notifications, autoResumeOnRateLimit: true, }, }) as never ); try { callHandleStreamJsonMessage(service, run, { type: 'system', subtype: 'api_retry', timestamp: '2026-04-17T12:00:00.000Z', attempt: 1, max_retries: 10, error_status: 429, error: 'model_cooldown', error_message: '429 {"error":{"code":"model_cooldown","message":"All credentials for model claude-opus-4-6 are cooling down via provider claude","model":"claude-opus-4-6","provider":"claude","reset_seconds":41,"reset_time":"40s"}}', retry_delay_ms: 41_000, }); expect(onProgress).toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(41 * 1000 + 29 * 1000); expect(autoResumeProvisioning.sendMessageToTeam).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1500); expect(autoResumeProvisioning.sendMessageToTeam).toHaveBeenCalledTimes(1); expect(autoResumeProvisioning.sendMessageToTeam).toHaveBeenCalledWith( 'my-team', expect.stringContaining('rate limit has reset') ); } finally { getConfigSpy.mockRestore(); } }); });