feat: implement live lead message handling and improve team data refresh logic
- Added a new method to create and push live lead messages during team provisioning, enhancing real-time communication. - Updated the event handling to ensure lead-message events only refresh team details, preventing unnecessary updates to team/task lists. - Improved error handling in team data refresh to preserve existing data during provisioning and transient errors. - Enhanced tests to cover new lead-message functionality and ensure proper behavior during team provisioning scenarios.
This commit is contained in:
parent
7a7e2e1f12
commit
b369b779cc
9 changed files with 807 additions and 60 deletions
163
docs/research/electron-decoupling.md
Normal file
163
docs/research/electron-decoupling.md
Normal file
|
|
@ -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 `<input type="file">` / 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 ."
|
||||
```
|
||||
|
|
@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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 ?? []);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ export function initializeNotificationListeners(): () => void {
|
|||
});
|
||||
const pendingSessionRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const pendingProjectRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let teamRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let teamRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
let teamListRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let globalTasksRefreshTimer: ReturnType<typeof setTimeout> | 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;
|
||||
|
|
|
|||
|
|
@ -801,7 +801,24 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (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 });
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,298 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const files = new Map<string, string>();
|
||||
|
||||
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<string, unknown>) => {
|
||||
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<typeof import('fs')>();
|
||||
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<typeof import('../../../../src/main/utils/pathDecoder')>();
|
||||
return { ...actual, getTeamsBasePath: () => '/mock/teams' };
|
||||
});
|
||||
|
||||
vi.mock('agent-teams-controller', () => ({
|
||||
createController: ({ teamName }: { teamName: string }) => ({
|
||||
messages: {
|
||||
appendSentMessage: (message: Record<string, unknown>) =>
|
||||
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<string, unknown> | 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<string, string> }).activeByTeam.set(teamName, runId);
|
||||
(service as unknown as { runs: Map<string, unknown> }).runs.set(runId, run);
|
||||
|
||||
return run;
|
||||
}
|
||||
|
||||
function callHandleStreamJsonMessage(
|
||||
service: TeamProvisioningService,
|
||||
run: RunLike,
|
||||
msg: Record<string, unknown>
|
||||
): 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' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue