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:
iliya 2026-03-09 00:13:12 +02:00
parent 7a7e2e1f12
commit b369b779cc
9 changed files with 807 additions and 60 deletions

View 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 ."
```

View file

@ -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,49 +2985,24 @@ 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);
}
}
}
}
// 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) {
// 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' &&
@ -3002,14 +3016,14 @@ export class TeamProvisioningService {
});
}
}
}
// Capture SendMessage(to: "user") tool_use blocks from assistant output.
// Claude Code's internal teamContext may route to "default" instead of the real team
// (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 ?? []);
}

View file

@ -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;

View file

@ -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 });
}
},

View file

@ -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;
}

View file

@ -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 {

View file

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

View file

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

View file

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