diff --git a/docs/research/electron-decoupling.md b/docs/research/electron-decoupling.md new file mode 100644 index 00000000..30485dfa --- /dev/null +++ b/docs/research/electron-decoupling.md @@ -0,0 +1,163 @@ +# Electron Decoupling Audit + +> Дата аудита: 2026-03-08 + +## Executive Summary + +Кодовая база **на 68% независима от Electron**. Только 30 из 692 файлов имеют прямые Electron импорты. Миграция оценивается в 4-6 недель, в основном механический рефакторинг. + +## Структура кодовой базы + +| Категория | Файлов | % | Electron-зависимость | +|-----------|:------:|:-:|---------------------| +| Renderer (React) | 473 | 68% | Нет — pure React, работает в браузере | +| Main (Services) | ~140 | 20% | Минимальная — pure Node.js | +| Main (IPC handlers) | 22 | 3% | Да — `ipcMain.handle()` | +| Main (Electron APIs) | 8 | 1% | Да — BrowserWindow, app, dialog, shell | +| Preload | 2 | 0.3% | Да — contextBridge | +| Shared (types/utils) | 45 | 6.5% | Нет — полностью agnostic | + +## Уже реализованная инфраструктура отвязки + +### 1. HTTP Server (Fastify) +- **Файл**: `src/main/services/infrastructure/HttpServer.ts` (179 LOC) +- Работает на `127.0.0.1:3456` +- Раздаёт статику + API роуты +- CORS настроен для standalone режима + +### 2. HTTP Routes (80% покрытие) +- **Директория**: `src/main/http/` +- 13 файлов роутов, дублируют IPC handlers: + - projects, sessions, search, subagents + - config, notifications, utility, validation + - ssh, updater, events, schedule + +### 3. HttpAPIClient +- **Файл**: `src/renderer/api/httpClient.ts` (400+ LOC) +- Полная имплементация `ElectronAPI` интерфейса +- EventSource (SSE) для real-time событий +- Fetch для request/response + +### 4. Unified API Proxy +- **Файл**: `src/renderer/api/index.ts` +- Автоматически переключается между: + - `window.electronAPI` (Electron mode) + - `HttpAPIClient` (browser mode) +- Прозрачно для всех компонентов + +### 5. Standalone Entry Point +- **Файл**: `src/main/standalone.ts` +- Запускает HTTP сервер без Electron +- Стабит UpdaterService, SshConnectionManager +- Протестирован и работает + +## 30 файлов с Electron импортами + +### ipcMain (22 файла) +``` +src/main/ipc/handlers.ts — оркестратор +src/main/ipc/cliInstaller.ts +src/main/ipc/config.ts — + dialog, BrowserWindow +src/main/ipc/context.ts +src/main/ipc/editor.ts — + BrowserWindow +src/main/ipc/extensions.ts +src/main/ipc/httpServer.ts +src/main/ipc/notifications.ts +src/main/ipc/projects.ts +src/main/ipc/rendererLogs.ts +src/main/ipc/review.ts +src/main/ipc/schedule.ts +src/main/ipc/search.ts +src/main/ipc/sessions.ts +src/main/ipc/ssh.ts +src/main/ipc/subagents.ts +src/main/ipc/teams.ts — + BrowserWindow, Notification +src/main/ipc/terminal.ts +src/main/ipc/updater.ts +src/main/ipc/utility.ts — + shell +src/main/ipc/validation.ts +src/main/ipc/window.ts — + app, BrowserWindow +``` + +### Другие Electron API +``` +src/main/index.ts — app, BrowserWindow, ipcMain +src/main/utils/pathDecoder.ts — app.getPath('home') +src/main/services/infrastructure/UpdaterService.ts — BrowserWindow, electron-updater +src/main/services/infrastructure/NotificationManager.ts — Notification +src/preload/index.ts — contextBridge, ipcRenderer +``` + +## Замена каждого Electron API + +| API | Файлов | Замена | Усилия | +|-----|:------:|--------|:------:| +| `ipcMain.handle()` | 22 | HTTP роуты (80% уже есть) | 3-4ч | +| `BrowserWindow` | 6 | Убрать (браузер сам управляет) | 1-2ч | +| `app` lifecycle | 2 | Прямой запуск Node.js сервера | 4-5ч | +| `dialog.showOpenDialog()` | 1 | HTML `` / env var | 3ч | +| `shell.openExternal/Path` | 1 | `window.open()` / убрать | 1ч | +| `Notification` | 2 | Browser Notification API | 2ч | +| `electron-updater` | 1 | GitHub releases redirect | 2ч | +| `contextBridge` | 1 | Убрать полностью | 1ч | + +## Оценка миграции + +| Метрика | Значение | +|---------|----------| +| Сложность | 6/10 — механический рефакторинг | +| Объём работы | 4-6 недель | +| Вероятность успеха | 95% | +| Уверенность в оценке | 9/10 | + +## Фазы миграции + +### Phase 1: Setup (2-3 дня) +- Рефакторинг `src/main/index.ts` в pure HTTP server bootstrap +- Удаление Electron app lifecycle +- HTTP server как primary entry point + +### Phase 2: IPC → HTTP (3-4 дня) +- Завершить оставшиеся HTTP роуты (~20%) +- Удалить все `ipcMain.handle()` вызовы +- SSE для event delivery + +### Phase 3: Desktop-Only Features (2-3 дня) +- Убрать auto-updater → version check API +- Убрать dialog → env var / HTML input +- Убрать shell → client-side links +- Browser Notification API + +### Phase 4: Build System (2-3 дня) +- `electron-vite` → стандартный Vite +- Убрать preload bundling +- Docker build config + +### Phase 5: Testing (2-3 дня) +- HTTP endpoint coverage +- Browser compatibility +- Docker deployment + +## Что нельзя заменить (Electron-only) +- Auto-update бинарных патчей → GitHub releases +- System tray → убрать +- Native menu → web context menus +- System hotkeys → browser keyboard events +- Native file dialogs → HTML file input + +## Build изменения + +**Текущий:** +```json +"dev": "electron-vite dev", +"build": "electron-vite build", +"dist": "electron-builder --mac --win --linux" +``` + +**После миграции:** +```json +"dev": "tsx src/main/standalone.ts & vite", +"build": "vite build && tsc --noEmit", +"start": "node dist/main/index.cjs", +"docker": "docker build -t claude-teams ." +``` diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 3678405f..d064cc25 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -2852,6 +2852,45 @@ export class TeamProvisioningService { } } + /** + * Create an InboxMessage from assistant text and push it into the live cache. + * Used for both pre-ready (provisioning) and post-ready assistant text. + * Emits a coalesced `lead-message` event for renderer refresh. + */ + private pushLiveLeadTextMessage(run: ProvisioningRun, cleanText: string): void { + run.leadMsgSeq += 1; + const leadName = + run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead'; + const messageId = `lead-turn-${run.runId}-${run.leadMsgSeq}`; + // Attach accumulated tool call details from preceding tool_use messages, then reset. + const toolCalls = run.pendingToolCalls.length > 0 ? [...run.pendingToolCalls] : undefined; + const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined; + run.pendingToolCalls = []; + const leadMsg: InboxMessage = { + from: leadName, + text: cleanText, + timestamp: nowIso(), + read: true, + summary: cleanText.length > 60 ? cleanText.slice(0, 57) + '...' : cleanText, + messageId, + source: 'lead_process', + toolSummary, + toolCalls, + }; + this.pushLiveLeadProcessMessage(run.teamName, leadMsg); + + // Coalesced refresh: at most one event per LEAD_TEXT_EMIT_THROTTLE_MS per team. + const now = Date.now(); + if (now - run.lastLeadTextEmitMs >= TeamProvisioningService.LEAD_TEXT_EMIT_THROTTLE_MS) { + run.lastLeadTextEmitMs = now; + this.teamChangeEmitter?.({ + type: 'lead-message', + teamName: run.teamName, + detail: 'lead-text', + }); + } + } + /** * Stop the running process for a team. No-op if team is not running. */ @@ -2946,41 +2985,16 @@ export class TeamProvisioningService { ) { const cleanText = stripAgentBlocks(text).trim(); if (cleanText.length > 0) { - run.leadMsgSeq += 1; - const leadName = - run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || - 'team-lead'; - const messageId = `lead-turn-${run.runId}-${run.leadMsgSeq}`; - // Attach accumulated tool call details from preceding tool_use messages, then reset. - const toolCalls = - run.pendingToolCalls.length > 0 ? [...run.pendingToolCalls] : undefined; - const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined; - run.pendingToolCalls = []; - const leadMsg: InboxMessage = { - from: leadName, - text: cleanText, - timestamp: nowIso(), - read: true, - summary: cleanText.length > 60 ? cleanText.slice(0, 57) + '...' : cleanText, - messageId, - source: 'lead_process', - toolSummary, - toolCalls, - }; - this.pushLiveLeadProcessMessage(run.teamName, leadMsg); - - const now = Date.now(); - if ( - now - run.lastLeadTextEmitMs >= - TeamProvisioningService.LEAD_TEXT_EMIT_THROTTLE_MS - ) { - run.lastLeadTextEmitMs = now; - this.teamChangeEmitter?.({ - type: 'inbox', - teamName: run.teamName, - detail: 'lead-text', - }); - } + this.pushLiveLeadTextMessage(run, cleanText); + } + } + } else { + // Pre-ready: also push to live cache so Messages shows early narration + // once team:getData becomes readable. The banner still uses provisioningOutputParts. + if (!run.silentUserDmForward && !hasSendMessageToUser) { + const cleanText = stripAgentBlocks(text).trim(); + if (cleanText.length > 0) { + this.pushLiveLeadTextMessage(run, cleanText); } } } @@ -2988,19 +3002,18 @@ export class TeamProvisioningService { // Accumulate tool_use details from tool-only messages (text + tool_use are separate in stream-json). // These details will be attached to the next text message as toolCalls/toolSummary. - if (run.provisioningComplete) { - for (const block of content ?? []) { - if ( - block?.type === 'tool_use' && - typeof block.name === 'string' && - block.name !== 'SendMessage' - ) { - const input = (block.input ?? {}) as Record; - run.pendingToolCalls.push({ - name: block.name, - preview: extractToolPreview(block.name, input), - }); - } + // Works in both pre-ready and post-ready phases so early live messages get tool metadata. + for (const block of content ?? []) { + if ( + block?.type === 'tool_use' && + typeof block.name === 'string' && + block.name !== 'SendMessage' + ) { + const input = (block.input ?? {}) as Record; + run.pendingToolCalls.push({ + name: block.name, + preview: extractToolPreview(block.name, input), + }); } } @@ -3009,7 +3022,8 @@ export class TeamProvisioningService { // (e.g., after session resume when teamContext is lost). We intercept the tool calls // from stdout and persist them to sentMessages.json under the correct team name, // ensuring the UI and notifications show the right team. - if (run.provisioningComplete && !run.silentUserDmForward) { + // Works in both pre-ready and post-ready phases so provisioning-time user DMs are captured. + if (!run.silentUserDmForward) { this.captureSendMessageToUser(run, content ?? []); } diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 2de404d0..d1debcd3 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -132,7 +132,7 @@ export function initializeNotificationListeners(): () => void { }); const pendingSessionRefreshTimers = new Map>(); const pendingProjectRefreshTimers = new Map>(); - let teamRefreshTimer: ReturnType | null = null; + let teamRefreshTimers = new Map>(); let teamListRefreshTimer: ReturnType | null = null; let globalTasksRefreshTimer: ReturnType | null = null; @@ -400,6 +400,24 @@ export function initializeNotificationListeners(): () => void { return; } + // Live lead-message events: only refresh the visible team detail, not team/task lists. + // This keeps the refresh lightweight and prevents one noisy team from starving another. + if (event.type === 'lead-message') { + if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) { + return; + } + if (teamRefreshTimers.has(event.teamName)) { + return; + } + const timer = setTimeout(() => { + teamRefreshTimers.delete(event.teamName); + const current = useStore.getState(); + void current.refreshTeamData(event.teamName); + }, TEAM_REFRESH_THROTTLE_MS); + teamRefreshTimers.set(event.teamName, timer); + return; + } + // Throttled refresh of summary list (keeps TeamListView current without flooding). if (!teamListRefreshTimer) { teamListRefreshTimer = setTimeout(() => { @@ -420,26 +438,25 @@ export function initializeNotificationListeners(): () => void { return; } - // Throttle (not debounce): keep at most one pending detail refresh. + // Per-team throttle (not debounce): keep at most one pending detail refresh per team. // Debounce would delay indefinitely while inbox messages keep arriving. - if (teamRefreshTimer) { + if (teamRefreshTimers.has(event.teamName)) { return; } - teamRefreshTimer = setTimeout(() => { - teamRefreshTimer = null; + const timer = setTimeout(() => { + teamRefreshTimers.delete(event.teamName); const current = useStore.getState(); void current.refreshTeamData(event.teamName); }, TEAM_REFRESH_THROTTLE_MS); + teamRefreshTimers.set(event.teamName, timer); }); if (typeof cleanup === 'function') { cleanupFns.push(() => { cleanup(); - if (teamRefreshTimer) { - clearTimeout(teamRefreshTimer); - teamRefreshTimer = null; - } + for (const t of teamRefreshTimers.values()) clearTimeout(t); + teamRefreshTimers = new Map(); if (teamListRefreshTimer) { clearTimeout(teamListRefreshTimer); teamListRefreshTimer = null; diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 48409e57..681334e4 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -801,7 +801,24 @@ export const createTeamSlice: StateCreator = (set, : error instanceof Error ? error.message : 'Failed to refresh team data'; + + // During provisioning, team:getData may not be readable yet. + // Preserve existing data instead of showing a fatal error. + if (msg === 'TEAM_PROVISIONING' || msg.includes('TEAM_PROVISIONING')) { + logger.debug(`refreshTeamData(${teamName}) skipped: team is still provisioning`); + set({ selectedTeamError: null }); + return; + } + logger.warn(`refreshTeamData(${teamName}) failed: ${msg}`); + + // Non-destructive: if we already have data, keep it visible. + // Only set error when there's nothing to show. + if (get().selectedTeamData) { + logger.debug(`refreshTeamData(${teamName}) preserving existing data after transient error`); + set({ selectedTeamError: null }); + return; + } set({ selectedTeamError: msg }); } }, diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 98dbc85e..614b51de 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -347,7 +347,7 @@ export interface LeadContextUsage { } export interface TeamChangeEvent { - type: 'config' | 'inbox' | 'task' | 'lead-activity' | 'lead-context' | 'process'; + type: 'config' | 'inbox' | 'task' | 'lead-activity' | 'lead-context' | 'lead-message' | 'process'; teamName: string; detail?: string; } diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index d39c516b..55dfe7fb 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -318,6 +318,53 @@ describe('ipc teams handlers', () => { expect(sources.filter((s) => s === 'lead_session')).toHaveLength(1); }); + it('merges early live messages before durable lead_session backfill exists', async () => { + // Simulate: team just became readable but lead_session JSONL hasn't been written yet. + // Only live in-memory messages exist from the provisioning process. + service.getTeamData.mockResolvedValueOnce({ + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [], + messages: [], // No durable messages yet + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }); + provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([ + { + from: 'team-lead', + text: 'Команда создана. Запускаю тиммейтов.', + timestamp: '2026-02-23T10:00:00.000Z', + read: true, + source: 'lead_process' as const, + messageId: 'lead-turn-run-1-1', + }, + { + from: 'team-lead', + text: 'All teammates online!', + timestamp: '2026-02-23T10:00:01.000Z', + read: true, + source: 'lead_process' as const, + messageId: 'lead-turn-run-1-2', + to: 'user', + }, + ]); + + 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); + // Both live messages should appear since there's no durable backfill yet + // Sorted by timestamp descending (newest first) + expect(result.data.messages).toHaveLength(2); + expect(result.data.messages[0].source).toBe('lead_process'); + expect(result.data.messages[0].text).toBe('All teammates online!'); + expect(result.data.messages[1].source).toBe('lead_process'); + expect(result.data.messages[1].text).toBe('Команда создана. Запускаю тиммейтов.'); + }); + 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 { diff --git a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts new file mode 100644 index 00000000..514c914d --- /dev/null +++ b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts @@ -0,0 +1,298 @@ +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; + }), + }; +}); + +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/utils/pathDecoder', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getTeamsBasePath: () => '/mock/teams' }; +}); + +vi.mock('agent-teams-controller', () => ({ + createController: ({ teamName }: { teamName: string }) => ({ + messages: { + appendSentMessage: (message: Record) => + hoisted.appendSentMessage(teamName, message), + }, + }), +})); + +import type { TeamChangeEvent } from '@shared/types/team'; +import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService'; + +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' }], + }) + ); +} + +interface RunLike { + runId: string; + teamName: string; + provisioningComplete: boolean; + leadMsgSeq: number; + pendingToolCalls: { name: string; preview: string }[]; + lastLeadTextEmitMs: number; + leadRelayCapture: null; + silentUserDmForward: null; + suppressPostCompactReminderOutput?: boolean; + child: Record | null; + processKilled: boolean; + cancelRequested: boolean; + provisioningOutputParts: string[]; + request: { members: { name: string; role?: string }[] }; +} + +/** + * 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 } +): RunLike { + const runId = 'run-1'; + const run: RunLike = { + runId, + teamName, + provisioningComplete: opts?.provisioningComplete ?? false, + leadMsgSeq: 0, + pendingToolCalls: [], + lastLeadTextEmitMs: 0, + leadRelayCapture: null, + silentUserDmForward: null, + child: { stdin: { writable: true, write: vi.fn(), end: vi.fn() } }, + processKilled: false, + cancelRequested: false, + provisioningOutputParts: [], + request: { members: [{ name: 'team-lead', role: 'Team Lead' }] }, + }; + + (service as unknown as { activeByTeam: Map }).activeByTeam.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(); + }); + + 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('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('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' }) + ); + }); +}); diff --git a/test/renderer/store/teamChangeThrottle.test.ts b/test/renderer/store/teamChangeThrottle.test.ts index 1e2671a7..c48bfd34 100644 --- a/test/renderer/store/teamChangeThrottle.test.ts +++ b/test/renderer/store/teamChangeThrottle.test.ts @@ -1,7 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const hoisted = vi.hoisted(() => ({ - onTeamChangeCb: null as ((event: unknown, data: { teamName: string }) => void) | null, + onTeamChangeCb: null as + | ((event: unknown, data: { type?: string; teamName: string }) => void) + | null, onProvisioningProgressCb: null as | ((event: unknown, data: { runId: string; teamName: string }) => void) | null, @@ -135,4 +137,71 @@ describe('team change throttling', () => { await vi.advanceTimersByTimeAsync(800); expect(refreshTeamDataSpy).toHaveBeenCalledTimes(2); }); + + it('lead-message refreshes detail only, not team list or tasks', async () => { + const state = useStore.getState(); + const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams'); + const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); + + // Emit a lead-message event + hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' }); + + // Should NOT trigger fetchTeams + await vi.advanceTimersByTimeAsync(2100); + expect(fetchTeamsSpy).not.toHaveBeenCalled(); + + // Should trigger refreshTeamData at 800ms + expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1); + expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team'); + }); + + it('lead-message does not call fetchAllTasks', async () => { + const fetchAllTasksSpy = vi.fn(async () => undefined); + useStore.setState({ fetchAllTasks: fetchAllTasksSpy } as never); + + hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' }); + + await vi.advanceTimersByTimeAsync(2100); + expect(fetchAllTasksSpy).not.toHaveBeenCalled(); + }); + + it('per-team throttling: busy team does not block another visible team', async () => { + // Add a second visible team tab + useStore.setState({ + paneLayout: { + focusedPaneId: 'p1', + panes: [ + { + id: 'p1', + widthFraction: 0.5, + tabs: [{ id: 't1', type: 'team', teamName: 'my-team', label: 'my-team' }], + activeTabId: 't1', + }, + { + id: 'p2', + widthFraction: 0.5, + tabs: [{ id: 't2', type: 'team', teamName: 'other-team', label: 'other-team' }], + activeTabId: 't2', + }, + ], + }, + } as never); + + const state = useStore.getState(); + const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); + + // Fire rapid events for my-team (throttled) + hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' }); + hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' }); + + // Fire event for other-team — should NOT be blocked by my-team's throttle + hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'other-team' }); + + await vi.advanceTimersByTimeAsync(800); + + // Both teams should get exactly 1 refresh each + expect(refreshTeamDataSpy).toHaveBeenCalledTimes(2); + expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team'); + expect(refreshTeamDataSpy).toHaveBeenCalledWith('other-team'); + }); }); diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index daf850b3..58a91192 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -130,4 +130,126 @@ describe('teamSlice actions', () => { 'Failed to update task status (possible agent conflict).' ); }); + + describe('refreshTeamData provisioning safety', () => { + it('does not set fatal error on TEAM_PROVISIONING', async () => { + const store = createSliceStore(); + // First, select a team so selectedTeamName is set + store.setState({ + selectedTeamName: 'my-team', + selectedTeamData: { + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [], + messages: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + }, + selectedTeamError: null, + }); + + hoisted.getData.mockRejectedValue(new Error('TEAM_PROVISIONING')); + + await store.getState().refreshTeamData('my-team'); + + // Should NOT set error — team is still provisioning + expect(store.getState().selectedTeamError).toBeNull(); + // Should preserve existing data + expect(store.getState().selectedTeamData).not.toBeNull(); + expect(store.getState().selectedTeamData?.teamName).toBe('my-team'); + }); + + it('preserves existing data on transient refresh error', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const store = createSliceStore(); + const existingData = { + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [], + messages: [{ from: 'lead', text: 'Hello', timestamp: '2026-01-01T00:00:00Z' }], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + }; + store.setState({ + selectedTeamName: 'my-team', + selectedTeamData: existingData, + selectedTeamError: null, + }); + + hoisted.getData.mockRejectedValue(new Error('Network timeout')); + + await store.getState().refreshTeamData('my-team'); + + // Should NOT replace data with error — preserve existing data + expect(store.getState().selectedTeamError).toBeNull(); + expect(store.getState().selectedTeamData).toEqual(existingData); + }); + + it('clears stale selectedTeamError when TEAM_PROVISIONING with existing data', async () => { + const store = createSliceStore(); + store.setState({ + selectedTeamName: 'my-team', + selectedTeamData: { + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [], + messages: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + }, + selectedTeamError: 'Previous failure', + }); + + hoisted.getData.mockRejectedValue(new Error('TEAM_PROVISIONING')); + + await store.getState().refreshTeamData('my-team'); + + // Stale error should be cleared even though provisioning prevents new data + expect(store.getState().selectedTeamError).toBeNull(); + expect(store.getState().selectedTeamData).not.toBeNull(); + }); + + it('clears stale selectedTeamError on transient error when data exists', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const store = createSliceStore(); + const existingData = { + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [], + messages: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + }; + store.setState({ + selectedTeamName: 'my-team', + selectedTeamData: existingData, + selectedTeamError: 'Old stale error', + }); + + hoisted.getData.mockRejectedValue(new Error('Network timeout')); + + await store.getState().refreshTeamData('my-team'); + + // Stale error should be cleared because we still have usable data + expect(store.getState().selectedTeamError).toBeNull(); + expect(store.getState().selectedTeamData).toEqual(existingData); + }); + + it('sets error when no previous data exists', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const store = createSliceStore(); + store.setState({ + selectedTeamName: 'my-team', + selectedTeamData: null, + selectedTeamError: null, + }); + + hoisted.getData.mockRejectedValue(new Error('Team not found')); + + await store.getState().refreshTeamData('my-team'); + + // No previous data — error should be shown + expect(store.getState().selectedTeamError).toBe('Team not found'); + }); + }); });