diff --git a/agent-teams-controller/src/internal/crossTeam.js b/agent-teams-controller/src/internal/crossTeam.js index 34216c97..e2fb3a12 100644 --- a/agent-teams-controller/src/internal/crossTeam.js +++ b/agent-teams-controller/src/internal/crossTeam.js @@ -3,9 +3,14 @@ const path = require('path'); const crypto = require('crypto'); const { createControllerContext } = require('./context.js'); const { withFileLockSync } = require('./fileLock.js'); +const messageStore = require('./messageStore.js'); const cascadeGuard = require('./cascadeGuard.js'); const runtimeHelpers = require('./runtimeHelpers.js'); -const { formatCrossTeamText, CROSS_TEAM_SOURCE } = require('./crossTeamProtocol.js'); +const { + formatCrossTeamText, + CROSS_TEAM_SOURCE, + CROSS_TEAM_SENT_SOURCE, +} = require('./crossTeamProtocol.js'); const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; const CROSS_TEAM_DEDUPE_WINDOW_MS = 5 * 60 * 1000; @@ -180,6 +185,7 @@ function sendCrossTeamMessage(context, flags) { replyToConversationId: replyToConversationId || undefined, }); const messageId = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`; + const timestamp = new Date().toISOString(); const dedupeKey = buildCrossTeamDedupeKey(fromTeam, fromMember, toTeam, text, summary); const inboxPath = path.join(targetContext.paths.teamDir, 'inboxes', `${leadName}.json`); @@ -206,7 +212,7 @@ function sendCrossTeamMessage(context, flags) { from, to: leadName, text: formattedText, - timestamp: new Date().toISOString(), + timestamp, read: false, summary: summary || `Cross-team message from ${fromTeam}`, messageId, @@ -224,6 +230,18 @@ function sendCrossTeamMessage(context, flags) { throw new Error('Cross-team inbox write verification failed'); } + messageStore.appendSentMessage(context.paths, { + from: fromMember, + to: `${toTeam}.${leadName}`, + text, + timestamp, + messageId, + summary: summary || `Cross-team message to ${toTeam}`, + source: CROSS_TEAM_SENT_SOURCE, + conversationId: resolvedConversationId, + replyToConversationId: replyToConversationId || undefined, + }); + outList.push({ messageId, fromTeam, @@ -234,7 +252,7 @@ function sendCrossTeamMessage(context, flags) { text, summary, chainDepth, - timestamp: new Date().toISOString(), + timestamp, }); writeJson(outboxPath, outList); }); diff --git a/agent-teams-controller/test/crossTeam.test.js b/agent-teams-controller/test/crossTeam.test.js index 729bfd55..74133b6c 100644 --- a/agent-teams-controller/test/crossTeam.test.js +++ b/agent-teams-controller/test/crossTeam.test.js @@ -87,6 +87,15 @@ describe('crossTeam module', () => { expect(outbox).toHaveLength(1); expect(outbox[0].toTeam).toBe('team-b'); expect(outbox[0].conversationId).toBeTruthy(); + + const sentMessagesPath = path.join(claudeDir, 'teams', 'team-a', 'sentMessages.json'); + const sentMessages = JSON.parse(fs.readFileSync(sentMessagesPath, 'utf8')); + expect(sentMessages).toHaveLength(1); + expect(sentMessages[0].from).toBe('team-lead'); + expect(sentMessages[0].to).toBe('team-b.team-lead'); + expect(sentMessages[0].text).toBe('Hello'); + expect(sentMessages[0].source).toBe('cross_team_sent'); + expect(sentMessages[0].messageId).toBe(outbox[0].messageId); }); it('preserves reply conversation metadata for explicit replies', () => { @@ -152,6 +161,10 @@ describe('crossTeam module', () => { const outbox = controller.crossTeam.getCrossTeamOutbox(); expect(outbox).toHaveLength(1); + + const sentMessagesPath = path.join(claudeDir, 'teams', 'team-a', 'sentMessages.json'); + const sentMessages = JSON.parse(fs.readFileSync(sentMessagesPath, 'utf8')); + expect(sentMessages).toHaveLength(1); }); it('allows resending after dedupe window expires', () => { diff --git a/docs/research/split-screen-multi-view.md b/docs/research/split-screen-multi-view.md new file mode 100644 index 00000000..e76c59d4 --- /dev/null +++ b/docs/research/split-screen-multi-view.md @@ -0,0 +1,210 @@ +# Split Screen Multi-View Research + +> Исследование: поддержка одновременного просмотра нескольких сессий/команд в split pane. +> Дата: 2026-03-10 + +## Текущее состояние архитектуры + +### Split Pane System (уже реализовано) +- До **4 панелей** одновременно (`MAX_PANES = 4` в `src/renderer/types/panes.ts`) +- Drag-and-drop между панелями (dnd-kit, `TabbedLayout.tsx`) +- Resize handles между панелями (`PaneResizeHandle.tsx`) +- CSS `display: none` toggle — все вкладки mounted, только active видна (`PaneContent.tsx`) +- `TabUIContext` предоставляет `tabId` потомкам + +### Pane Layout Structure +```typescript +// src/renderer/types/panes.ts +interface Pane { + id: string; + tabs: Tab[]; + activeTabId: string; + selectedTabIds: string[]; + widthFraction: number; // 0-1, сумма всех = 1.0 +} + +interface PaneLayout { + panes: Pane[]; + focusedPaneId: string; // какая панель в фокусе +} +``` + +### Backward Compatibility Facade +Root-level `openTabs`, `activeTabId`, `selectedTabIds` синхронизируются из **focused pane only** через `syncFromLayout()` в `tabSlice.ts`. + +--- + +## Изоляция состояния: что per-tab vs глобальное + +### ✅ Per-Tab (уже изолировано) + +| Состояние | Хранение | Слайс | +|-----------|----------|-------| +| UI expansion state | `tabUIStates[tabId]` | `tabUISlice` | +| Scroll position | `tabUIStates[tabId].savedScrollTop` | `tabUISlice` | +| Context panel visibility | `tabUIStates[tabId].showContextPanel` | `tabUISlice` | +| Context phase selection | `tabUIStates[tabId].selectedContextPhase` | `tabUISlice` | +| Session data cache | `tabSessionData[tabId]` | `sessionDetailSlice` | +| Conversation cache | `tabSessionData[tabId].conversation` | `sessionDetailSlice` | + +**Паттерн чтения:** +```typescript +const stats = useStore((s) => { + const td = tabId ? s.tabSessionData[tabId] : null; + return td?.sessionClaudeMdStats ?? s.sessionClaudeMdStats; +}); +``` + +### ❌ Глобальное (проблемы для multi-view) + +| Состояние | Слайс | Проблема | +|-----------|-------|----------| +| `selectedTeamName` | `teamSlice` | Одна команда на всё приложение | +| `selectedTeamData` | `teamSlice` | Полные данные только одной команды | +| `searchQuery` | `conversationSlice` | Поиск общий для всех вкладок | +| `searchVisible` | `conversationSlice` | Показ поиска общий | +| `searchMatches` | `conversationSlice` | Результаты поиска общие | +| `currentSearchIndex` | `conversationSlice` | Навигация по результатам общая | +| `expandedAIGroupIds` | `conversationSlice` | Legacy дубль `tabUISlice` | +| `expandedDisplayItemIds` | `conversationSlice` | Legacy дубль `tabUISlice` | +| `expandedStepIds` | `conversationSlice` | Глобальное, логично per-tab | +| `activeDetailItem` | `conversationSlice` | Глобальное, логично per-tab | + +### ⚠️ Синхронизируемое (работает через swap) + +| Состояние | Механизм | +|-----------|----------| +| `selectedProjectId` | Swap при фокусе pane | +| `selectedSessionId` | Swap при фокусе pane | +| `sessionDetail` (global) | Swap из `tabSessionData[tabId]` | +| `conversation` (global) | Swap из `tabSessionData[tabId]` | + +--- + +## Варианты реализации + +### Вариант A: Полная поддержка split-screen для сессий +**Надёжность: 8/10 | Уверенность: 9/10** + +Основа уже заложена через `tabSessionData`. Нужно: + +1. **Search isolation** (~5 файлов): + - Перенести `searchQuery`, `searchVisible`, `searchMatches`, `currentSearchIndex` в `tabUISlice` + - Обновить `SearchBar`, `useSearchContextNavigation`, `searchHighlightUtils` + - Компоненты читают search state через `tabUIStates[tabId]` + +2. **Legacy cleanup** (~3 файла): + - Удалить `expandedAIGroupIds` и `expandedDisplayItemIds` из `conversationSlice` + - Убедиться все компоненты используют `tabUISlice` версии + - Удалить `expandedStepIds` из global scope + +3. **Верификация** (~3 файла): + - Проверить все компоненты в chat/ читают через `tabSessionData[tabId]` паттерн + - Проверить что `activeDetailItem` изолирован + +**Объём: ~8-12 файлов, средняя сложность.** + +### Вариант B: Полная поддержка split-screen для команд +**Надёжность: 7/10 | Уверенность: 7/10** + +Нужна новая инфраструктура: + +1. **Per-tab team data cache** (~5 файлов): + ```typescript + // В teamSlice или sessionDetailSlice + tabTeamData: Record + ``` + +2. **selectTeam() с tabId** (~3 файла): + - `selectTeam(teamName, tabId?)` — кэширует в `tabTeamData[tabId]` + - При переключении tab: swap из кэша или fetch + - При закрытии tab: cleanup кэша + +3. **Team компоненты** (~8 файлов): + - `TeamDetailView`, `TeamChatView`, `TeamKanbanView` и др. + - Читать через `tabTeamData[tabId]` паттерн + - File watcher: обновлять нужные tab кэши + +4. **Sidebar sync** (~2 файла): + - При фокусе pane с team tab: sync sidebar к этой команде + +**Объём: ~15-20 файлов, высокая сложность.** + +### Вариант C: A + B (полный split-screen) +**Надёжность: 6/10 | Уверенность: 7/10** + +**Объём: ~20-25 файлов.** + +--- + +## Риски + +### Высокие +1. **Race conditions при file watcher events** — обновление прилетает, нужно обновить правильный tab cache. Для сессий решено через `tabFetchGeneration` Map, для команд нужен аналог. +2. **Search isolation** — search завязан на глобальные `searchMatches` и навигацию по ним, самый трудоёмкий рефактор. + +### Средние +3. **Memory pressure** — каждый tab хранит полный кэш. Для сессий работает (cleanup при закрытии). Для команд нужен аналог. +4. **Sidebar sync** — сайдбар показывает контекст focused pane. При переключении нужен корректный swap project/worktree/team. +5. **Stale data** — два tab с одной сессией/командой: file watcher обновляет оба или только active? + +### Низкие +6. **DnD between panes** — перетаскивание team tab между panes должно триггерить cache transfer. +7. **Tab duplication** — `openTab()` проверяет дупликаты across ALL panes. Нужно ли разрешить одну и ту же команду в двух panes? + +--- + +## Ключевые файлы + +### Store Slices +| Файл | Роль | +|------|------| +| `src/renderer/store/slices/tabSlice.ts` | Tab lifecycle, session switching, backward compat | +| `src/renderer/store/slices/paneSlice.ts` | Multi-pane split/resize/focus | +| `src/renderer/store/slices/tabUISlice.ts` | Per-tab UI state (expansion, scroll) | +| `src/renderer/store/slices/sessionDetailSlice.ts` | Session data + per-tab caching | +| `src/renderer/store/slices/conversationSlice.ts` | Search, legacy expansion (нужен рефактор) | +| `src/renderer/store/slices/teamSlice.ts` | Team selection (глобальное, нужен рефактор) | + +### Layout Components +| Файл | Роль | +|------|------| +| `src/renderer/components/layout/TabbedLayout.tsx` | Main layout + DnD context | +| `src/renderer/components/layout/TabBarRow.tsx` | Full-width tab bar (pane-proportional) | +| `src/renderer/components/layout/TabBar.tsx` | Single pane tab bar | +| `src/renderer/components/layout/PaneContainer.tsx` | Split layout renderer | +| `src/renderer/components/layout/PaneView.tsx` | Single pane wrapper | +| `src/renderer/components/layout/PaneContent.tsx` | Tab content renderer (display-toggle) | +| `src/renderer/components/layout/SessionTabContent.tsx` | Session tab content | + +### Contexts +| Файл | Роль | +|------|------| +| `src/renderer/contexts/TabUIContext.tsx` | Per-tab ID provider | +| `src/renderer/contexts/useTabUIContext.ts` | Context hook | + +--- + +## Рекомендация + +**Начать с Варианта A** (сессии в split-screen): +- 80% инфраструктуры уже есть +- Нужно дочистить search isolation и legacy duplicates +- Низкий риск регрессий + +**Затем Вариант B** (команды): +- Когда паттерн per-tab caching отработан на сессиях +- Применить тот же подход к team data + +--- + +## Обнаруженные баги (побочный результат ресёрча) + +1. **Search state не изолирован** — поиск в одной вкладке влияет на другие +2. **Legacy дублирование** — `expandedAIGroupIds` существует и в `conversationSlice` и в `tabUISlice` +3. **Team tabs в split pane** — обе панели показывают одну команду (последнюю выбранную) diff --git a/src/main/services/extensions/catalog/OfficialMcpRegistryService.ts b/src/main/services/extensions/catalog/OfficialMcpRegistryService.ts index aed9f7a7..d6279afd 100644 --- a/src/main/services/extensions/catalog/OfficialMcpRegistryService.ts +++ b/src/main/services/extensions/catalog/OfficialMcpRegistryService.ts @@ -10,7 +10,12 @@ import https from 'node:https'; import http from 'node:http'; import { createLogger } from '@shared/utils/logger'; -import type { McpCatalogItem, McpEnvVarDef, McpInstallSpec } from '@shared/types/extensions'; +import type { + McpAuthHeaderDef, + McpCatalogItem, + McpEnvVarDef, + McpInstallSpec, +} from '@shared/types/extensions'; const logger = createLogger('Extensions:OfficialMcpRegistry'); @@ -265,6 +270,7 @@ export class OfficialMcpRegistryService { const meta = entry._meta?.['io.modelcontextprotocol.registry/official']; const installSpec = this.deriveInstallSpec(server); const envVars = this.collectEnvVars(server); + const authHeaders = this.collectAuthHeaders(server); const requiresAuth = this.detectAuthRequired(server); return { @@ -285,6 +291,7 @@ export class OfficialMcpRegistryService { status: meta?.status, publishedAt: meta?.publishedAt, updatedAt: meta?.updatedAt, + authHeaders, }; } @@ -330,6 +337,30 @@ export class OfficialMcpRegistryService { return envVars; } + private collectAuthHeaders(server: RegistryServerEntry['server']): McpAuthHeaderDef[] { + const headers: McpAuthHeaderDef[] = []; + const seenKeys = new Set(); + + for (const remote of server.remotes ?? []) { + for (const header of remote.headers ?? []) { + const key = header.name.trim(); + if (!key || seenKeys.has(key)) { + continue; + } + seenKeys.add(key); + headers.push({ + key, + description: header.description, + isRequired: header.isRequired, + isSecret: header.isSecret, + valueTemplate: header.value, + }); + } + } + + return headers; + } + private detectAuthRequired(server: RegistryServerEntry['server']): boolean { for (const remote of server.remotes ?? []) { for (const header of remote.headers ?? []) { diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 316629f0..16e99d2e 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -201,6 +201,8 @@ interface ProvisioningRun { leadMsgSeq: number; /** Accumulated tool_use details between text messages. */ pendingToolCalls: ToolCallMeta[]; + /** True when a direct MCP cross_team_send happened and sentMessages history should refresh. */ + pendingDirectCrossTeamSendRefresh: boolean; /** Throttle timestamp for emitting inbox refresh events for lead text. */ lastLeadTextEmitMs: number; /** @@ -1090,6 +1092,7 @@ function isTransientProbeWarning(warning: string): boolean { export class TeamProvisioningService { private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000; + private static readonly RECENT_CROSS_TEAM_DELIVERY_TTL_MS = 10 * 60 * 1000; private readonly runs = new Map(); private readonly activeByTeam = new Map(); @@ -1099,6 +1102,7 @@ export class TeamProvisioningService { private readonly memberInboxRelayInFlight = new Map>(); private readonly relayedMemberInboxMessageIds = new Map>(); private readonly pendingCrossTeamFirstReplies = new Map>(); + private readonly recentCrossTeamLeadDeliveryMessageIds = new Map>(); private readonly liveLeadProcessMessages = new Map(); private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null; private helpOutputCache: string | null = null; @@ -1367,12 +1371,90 @@ export class TeamProvisioningService { } } + private getPendingCrossTeamReplyExpectationKeys(teamName: string): Set { + const teamMap = this.pendingCrossTeamFirstReplies.get(teamName.trim()); + if (!teamMap) return new Set(); + const cutoff = Date.now() - TeamProvisioningService.RECENT_CROSS_TEAM_DELIVERY_TTL_MS; + for (const [key, createdAt] of teamMap.entries()) { + if (createdAt < cutoff) { + teamMap.delete(key); + } + } + if (teamMap.size === 0) { + this.pendingCrossTeamFirstReplies.delete(teamName.trim()); + return new Set(); + } + return new Set(teamMap.keys()); + } + private getRunLeadName(run: ProvisioningRun): string { return ( run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead' ); } + private rememberRecentCrossTeamLeadDeliveryMessageIds( + teamName: string, + messageIds: string[] + ): void { + const normalizedIds = messageIds.map((id) => id.trim()).filter((id) => id.length > 0); + if (normalizedIds.length === 0) return; + const teamKey = teamName.trim(); + const current = + this.recentCrossTeamLeadDeliveryMessageIds.get(teamKey) ?? new Map(); + const now = Date.now(); + const cutoff = now - TeamProvisioningService.RECENT_CROSS_TEAM_DELIVERY_TTL_MS; + for (const [key, createdAt] of current.entries()) { + if (createdAt < cutoff) current.delete(key); + } + for (const messageId of normalizedIds) { + current.set(messageId, now); + } + if (current.size > 0) { + this.recentCrossTeamLeadDeliveryMessageIds.set(teamKey, current); + } + } + + private wasRecentlyDeliveredToLead(teamName: string, messageId: string): boolean { + const normalizedMessageId = messageId.trim(); + if (!normalizedMessageId) return false; + const teamKey = teamName.trim(); + const current = this.recentCrossTeamLeadDeliveryMessageIds.get(teamKey); + if (!current) return false; + const cutoff = Date.now() - TeamProvisioningService.RECENT_CROSS_TEAM_DELIVERY_TTL_MS; + for (const [key, createdAt] of current.entries()) { + if (createdAt < cutoff) current.delete(key); + } + if (current.size === 0) { + this.recentCrossTeamLeadDeliveryMessageIds.delete(teamKey); + return false; + } + return current.has(normalizedMessageId); + } + + private parseCrossTeamTargetTeam(value: string | undefined): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + if (trimmed.startsWith('cross-team:')) { + const teamName = trimmed.slice('cross-team:'.length).trim(); + return TEAM_NAME_PATTERN.test(teamName) ? teamName : null; + } + const dot = trimmed.indexOf('.'); + if (dot <= 0) return null; + const teamName = trimmed.slice(0, dot).trim(); + return TEAM_NAME_PATTERN.test(teamName) ? teamName : null; + } + + private getCrossTeamSourceTeam(value: string | undefined): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + const dot = trimmed.indexOf('.'); + if (dot <= 0) return null; + const teamName = trimmed.slice(0, dot).trim(); + return TEAM_NAME_PATTERN.test(teamName) ? teamName : null; + } + private extractStreamUserText(msg: Record): string | null { const topLevelContent = msg.content; if (typeof topLevelContent === 'string') { @@ -1415,29 +1497,48 @@ export class TeamProvisioningService { return text.length > 0 ? text : null; } - private async markDeliveredCrossTeamLeadMessagesRead( + private async matchCrossTeamLeadInboxMessages( teamName: string, leadName: string, deliveredBlocks: Array<{ teammateId: string; content: string; + toTeam: string; conversationId: string; }> - ): Promise { - if (deliveredBlocks.length === 0) return; + ): Promise< + Array<{ + teammateId: string; + content: string; + toTeam: string; + conversationId: string; + messageId: string; + wasRead: boolean; + }> + > { + if (deliveredBlocks.length === 0) return []; let leadInboxMessages: Awaited> = []; try { leadInboxMessages = await this.inboxReader.getMessagesFor(teamName, leadName); } catch { - return; + return []; } - const toMark: (InboxMessage & { messageId: string })[] = []; + const usedMessageIds = new Set(); + const matches: Array<{ + teammateId: string; + content: string; + toTeam: string; + conversationId: string; + messageId: string; + wasRead: boolean; + }> = []; for (const block of deliveredBlocks) { const matchesBlock = (message: InboxMessage, requireExactText: boolean): boolean => { - if (message.read || message.source !== CROSS_TEAM_SOURCE) return false; + if (message.source !== CROSS_TEAM_SOURCE) return false; if (!this.hasStableMessageId(message)) return false; + if (usedMessageIds.has(message.messageId)) return false; if (message.from.trim() !== block.teammateId.trim()) return false; const messageConversationId = message.replyToConversationId?.trim() ?? @@ -1450,17 +1551,18 @@ export class TeamProvisioningService { leadInboxMessages.find((message) => matchesBlock(message, true)) ?? leadInboxMessages.find((message) => matchesBlock(message, false)); if (!matched || !this.hasStableMessageId(matched)) continue; - matched.read = true; - toMark.push(matched); + usedMessageIds.add(matched.messageId); + matches.push({ + teammateId: block.teammateId, + content: block.content, + toTeam: block.toTeam, + conversationId: block.conversationId, + messageId: matched.messageId, + wasRead: matched.read === true, + }); } - if (toMark.length === 0) return; - - try { - await this.markInboxMessagesRead(teamName, leadName, toMark); - } catch { - // best-effort - } + return matches; } private handleNativeTeammateUserMessage( @@ -1490,13 +1592,33 @@ export class TeamProvisioningService { }); if (crossTeamBlocks.length === 0) return; - run.activeCrossTeamReplyHints = crossTeamBlocks.map((block) => ({ - toTeam: block.toTeam, - conversationId: block.conversationId, - })); - const leadName = this.getRunLeadName(run); - void this.markDeliveredCrossTeamLeadMessagesRead(run.teamName, leadName, crossTeamBlocks); + void (async () => { + const matches = await this.matchCrossTeamLeadInboxMessages( + run.teamName, + leadName, + crossTeamBlocks + ); + const unreadMatches = matches.filter((match) => !match.wasRead); + if (unreadMatches.length > 0) { + try { + await this.markInboxMessagesRead(run.teamName, leadName, unreadMatches); + } catch { + // best-effort + } + } + const freshMatches = matches.filter( + (match) => !this.wasRecentlyDeliveredToLead(run.teamName, match.messageId) + ); + this.rememberRecentCrossTeamLeadDeliveryMessageIds( + run.teamName, + freshMatches.map((match) => match.messageId) + ); + run.activeCrossTeamReplyHints = freshMatches.map((match) => ({ + toTeam: match.toTeam, + conversationId: match.conversationId, + })); + })(); } private persistSentMessage(teamName: string, message: InboxMessage): void { @@ -2216,6 +2338,7 @@ export class TeamProvisioningService { activeCrossTeamReplyHints: [], leadMsgSeq: 0, pendingToolCalls: [], + pendingDirectCrossTeamSendRefresh: false, lastLeadTextEmitMs: 0, silentUserDmForward: null, silentUserDmForwardClearHandle: null, @@ -2540,6 +2663,7 @@ export class TeamProvisioningService { activeCrossTeamReplyHints: [], leadMsgSeq: 0, pendingToolCalls: [], + pendingDirectCrossTeamSendRefresh: false, lastLeadTextEmitMs: 0, silentUserDmForward: null, silentUserDmForwardClearHandle: null, @@ -3053,14 +3177,80 @@ export class TeamProvisioningService { if (unread.length === 0) return 0; + const latestOutboundByConversation = new Map(); + const latestReadInboundByConversation = new Map(); + for (const message of leadInboxMessages) { + const timestampMs = Date.parse(message.timestamp); + if (!Number.isFinite(timestampMs)) continue; + if (message.source === CROSS_TEAM_SENT_SOURCE) { + const conversationId = message.conversationId?.trim(); + const targetTeam = this.parseCrossTeamTargetTeam(message.to); + if (!conversationId || !targetTeam) continue; + const key = this.buildCrossTeamConversationKey(targetTeam, conversationId); + latestOutboundByConversation.set( + key, + Math.max(latestOutboundByConversation.get(key) ?? 0, timestampMs) + ); + continue; + } + if (message.source === CROSS_TEAM_SOURCE && message.read) { + const conversationId = + message.replyToConversationId?.trim() ?? + message.conversationId?.trim() ?? + parseCrossTeamPrefix(message.text)?.conversationId; + const sourceTeam = this.getCrossTeamSourceTeam(message.from); + if (!conversationId || !sourceTeam) continue; + const key = this.buildCrossTeamConversationKey(sourceTeam, conversationId); + latestReadInboundByConversation.set( + key, + Math.max(latestReadInboundByConversation.get(key) ?? 0, timestampMs) + ); + } + } + const pendingHistoricalReplies = new Set( + Array.from(latestOutboundByConversation.entries()) + .filter(([key, sentAtMs]) => sentAtMs > (latestReadInboundByConversation.get(key) ?? 0)) + .map(([key]) => key) + ); + const pendingTransientReplies = this.getPendingCrossTeamReplyExpectationKeys(teamName); + const matchedTransientReplyKeys = new Set(); + + const wasRecentlyDeliveredCrossTeam = (message: InboxMessage): boolean => { + if (message.source !== CROSS_TEAM_SOURCE) return false; + if (!this.hasStableMessageId(message)) return false; + return this.wasRecentlyDeliveredToLead(teamName, message.messageId); + }; + const isCrossTeamReplyToOwnOutbound = (message: InboxMessage): boolean => { + if (message.source !== CROSS_TEAM_SOURCE) return false; + const conversationId = + message.replyToConversationId?.trim() ?? + message.conversationId?.trim() ?? + parseCrossTeamPrefix(message.text)?.conversationId; + if (!conversationId) return false; + const sourceTeam = this.getCrossTeamSourceTeam(message.from); + if (!sourceTeam) return false; + const key = this.buildCrossTeamConversationKey(sourceTeam, conversationId); + if (pendingHistoricalReplies.has(key)) { + return true; + } + if (pendingTransientReplies.has(key)) { + matchedTransientReplyKeys.add(key); + return true; + } + return false; + }; + // Ignore (and auto-mark read) internal coordination noise like idle/shutdown messages. // Also ignore local sender-copy rows for cross-team traffic: those exist only so the UI // can show outbound activity and must not be re-injected into the live lead as new work. - // Incoming cross-team deliveries are handled through Claude's native - // path and are marked read when that raw user turn is observed, so we intentionally do not - // custom-relay them here. + // If the same cross-team delivery already arrived via a raw turn, + // suppress the duplicate relay here and simply mark the inbox row as read. const ignoredUnread = unread.filter( - (m) => isInboxNoiseMessage(m.text) || m.source === CROSS_TEAM_SENT_SOURCE + (m) => + isInboxNoiseMessage(m.text) || + m.source === CROSS_TEAM_SENT_SOURCE || + isCrossTeamReplyToOwnOutbound(m) || + wasRecentlyDeliveredCrossTeam(m) ); if (ignoredUnread.length > 0) { try { @@ -3068,13 +3258,20 @@ export class TeamProvisioningService { } catch { // best-effort } + for (const key of matchedTransientReplyKeys) { + const [otherTeam, conversationId] = key.split('\0'); + if (otherTeam && conversationId) { + this.clearPendingCrossTeamReplyExpectation(teamName, otherTeam, conversationId); + } + } } const actionableUnread = unread.filter( (m) => !isInboxNoiseMessage(m.text) && m.source !== CROSS_TEAM_SENT_SOURCE && - m.source !== CROSS_TEAM_SOURCE + !isCrossTeamReplyToOwnOutbound(m) && + !wasRecentlyDeliveredCrossTeam(m) ); if (actionableUnread.length === 0) return 0; @@ -3187,6 +3384,12 @@ export class TeamProvisioningService { relayedIds.add(m.messageId); } this.relayedLeadInboxMessageIds.set(teamName, this.trimRelayedSet(relayedIds)); + this.rememberRecentCrossTeamLeadDeliveryMessageIds( + teamName, + batch + .filter((message) => message.source === CROSS_TEAM_SOURCE) + .map((message) => message.messageId) + ); try { await this.markInboxMessagesRead(teamName, leadName, batch); @@ -3435,11 +3638,22 @@ export class TeamProvisioningService { if (part.type !== 'tool_use' || typeof part.name !== 'string') continue; const isNativeSendMessage = part.name === 'SendMessage'; const isTeamMessageSendTool = part.name === 'mcp__agent-teams__message_send'; - if (!isNativeSendMessage && !isTeamMessageSendTool) continue; + const isDirectCrossTeamSendTool = + part.name === 'mcp__agent-teams__cross_team_send' || part.name === 'cross_team_send'; + if (!isNativeSendMessage && !isTeamMessageSendTool && !isDirectCrossTeamSendTool) continue; const input = part.input; if (!input || typeof input !== 'object') continue; const inp = input as Record; + if (isDirectCrossTeamSendTool) { + const toTeam = typeof inp.toTeam === 'string' ? inp.toTeam.trim() : ''; + const text = typeof inp.text === 'string' ? stripAgentBlocks(inp.text).trim() : ''; + if (toTeam && text) { + run.pendingDirectCrossTeamSendRefresh = true; + } + continue; + } + const recipient = isNativeSendMessage ? typeof inp.recipient === 'string' ? inp.recipient @@ -3998,6 +4212,14 @@ export class TeamProvisioningService { this.setLeadActivity(run, 'idle'); } + if (run.pendingDirectCrossTeamSendRefresh) { + run.pendingDirectCrossTeamSendRefresh = false; + this.teamChangeEmitter?.({ + type: 'inbox', + teamName: run.teamName, + detail: 'sentMessages.json', + }); + } if (run.leadRelayCapture) { const capture = run.leadRelayCapture; const combined = capture.textParts.join('\n').trim(); @@ -4033,6 +4255,7 @@ export class TeamProvisioningService { run.leadRelayCapture.rejectOnce(errorMsg); } // Clear silent relay flag after any errored turn. + run.pendingDirectCrossTeamSendRefresh = false; run.activeCrossTeamReplyHints = []; run.silentUserDmForward = null; if (run.silentUserDmForwardClearHandle) { @@ -4757,6 +4980,7 @@ export class TeamProvisioningService { */ private cleanupRun(run: ProvisioningRun): void { this.setLeadActivity(run, 'offline'); + run.pendingDirectCrossTeamSendRefresh = false; if (run.timeoutHandle) { clearTimeout(run.timeoutHandle); run.timeoutHandle = null; @@ -4776,6 +5000,7 @@ export class TeamProvisioningService { this.leadInboxRelayInFlight.delete(run.teamName); this.relayedLeadInboxMessageIds.delete(run.teamName); this.pendingCrossTeamFirstReplies.delete(run.teamName); + this.recentCrossTeamLeadDeliveryMessageIds.delete(run.teamName); run.activeCrossTeamReplyHints = []; for (const key of Array.from(this.memberInboxRelayInFlight.keys())) { if (key.startsWith(`${run.teamName}:`)) { diff --git a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx index d1586b21..53e1dc42 100644 --- a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx +++ b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx @@ -41,11 +41,10 @@ interface CustomMcpServerDialogProps { type TransportMode = 'stdio' | 'http'; type HttpTransport = 'streamable-http' | 'sse' | 'http'; -type Scope = 'local' | 'user' | 'project'; +type Scope = 'local' | 'user'; const SCOPE_OPTIONS: { value: Scope; label: string }[] = [ { value: 'user', label: 'User (global)' }, - { value: 'project', label: 'Project' }, { value: 'local', label: 'Local' }, ]; diff --git a/src/renderer/components/extensions/mcp/McpServerCard.tsx b/src/renderer/components/extensions/mcp/McpServerCard.tsx index dcab2697..d09e64b9 100644 --- a/src/renderer/components/extensions/mcp/McpServerCard.tsx +++ b/src/renderer/components/extensions/mcp/McpServerCard.tsx @@ -6,6 +6,7 @@ import { useState } from 'react'; import { Badge } from '@renderer/components/ui/badge'; +import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; import { api } from '@renderer/api'; @@ -45,6 +46,11 @@ export const McpServerCard = ({ server.repositoryUrl ? s.mcpGitHubStars[server.repositoryUrl] : undefined ); const canAutoInstall = !!server.installSpec; + const requiresConfiguration = + server.installSpec?.type === 'http' || + server.envVars.length > 0 || + server.requiresAuth || + (server.authHeaders?.length ?? 0) > 0; const [imgError, setImgError] = useState(false); const hasIcon = !!server.iconUrl && !imgError; @@ -197,7 +203,7 @@ export const McpServerCard = ({ )} - {canAutoInstall && ( + {canAutoInstall && !requiresConfiguration && (
)} + {canAutoInstall && requiresConfiguration && ( +
+ +
+ )} ); diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index deef5be5..c7b72d29 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -40,11 +40,10 @@ interface McpServerDetailDialogProps { onClose: () => void; } -type Scope = 'local' | 'user' | 'project'; +type Scope = 'local' | 'user'; const SCOPE_OPTIONS: { value: Scope; label: string }[] = [ { value: 'user', label: 'User (global)' }, - { value: 'project', label: 'Project' }, { value: 'local', label: 'Local' }, ]; @@ -71,16 +70,29 @@ export const McpServerDetailDialog = ({ const [imgError, setImgError] = useState(false); const [autoFilledFields, setAutoFilledFields] = useState>(new Set()); - // Initialize form when server changes - const [lastServerId, setLastServerId] = useState(null); - if (server && server.id !== lastServerId) { - setLastServerId(server.id); + // Initialize form when dialog opens or server changes + useEffect(() => { + if (!server || !open) { + return; + } + setServerName(sanitizeMcpServerName(server.name)); setEnvValues(Object.fromEntries(server.envVars.map((env) => [env.name, '']))); - setHeaders([]); + setHeaders( + (server.authHeaders ?? []).map((header) => ({ + key: header.key, + value: '', + secret: header.isSecret, + description: header.description, + isRequired: header.isRequired, + valueTemplate: header.valueTemplate, + locked: true, + })) + ); + setScope('user'); setImgError(false); setAutoFilledFields(new Set()); - } + }, [server?.id, open]); // Auto-fill env values from saved API keys useEffect(() => { @@ -142,6 +154,18 @@ export const McpServerDetailDialog = ({ const canAutoInstall = !!server.installSpec; const isHttp = server.installSpec?.type === 'http'; const hasIcon = !!server.iconUrl && !imgError; + const npmPackageUrl = + server.installSpec?.type === 'stdio' + ? `https://www.npmjs.com/package/${server.installSpec.npmPackage}` + : null; + const hasSuggestedHeaders = headers.some((header) => header.locked); + const missingRequiredEnvVars = server.envVars.some( + (env) => env.isRequired && !envValues[env.name]?.trim() + ); + const missingRequiredHeaders = headers.some( + (header) => header.isRequired && !header.value.trim() + ); + const installDisabled = !serverName.trim() || missingRequiredEnvVars || missingRequiredHeaders; const handleInstall = () => { installMcpServer({ @@ -236,13 +260,21 @@ export const McpServerDetailDialog = ({ )}
Install Type -

- {server.installSpec - ? server.installSpec.type === 'stdio' - ? `npm: ${server.installSpec.npmPackage}` - : `HTTP: ${server.installSpec.transportType}` - : 'Manual setup required'} -

+ {server.installSpec?.type === 'stdio' ? ( + + ) : ( +

+ {server.installSpec + ? `HTTP: ${server.installSpec.transportType}` + : 'Manual setup required'} +

+ )}
{server.author && (
@@ -277,6 +309,12 @@ export const McpServerDetailDialog = ({ This server requires authentication
)} + {isHttp && !server.requiresAuth && (server.authHeaders?.length ?? 0) === 0 && ( +
+ Remote MCP servers may still require custom headers or API keys even when the registry + does not describe them. If connection fails after install, check the provider docs. +
+ )} {/* Install form */} {canAutoInstall && ( @@ -356,34 +394,54 @@ export const McpServerDetailDialog = ({ className="h-6 px-1.5 text-xs" > - Add + {hasSuggestedHeaders ? 'Add custom' : 'Add'} {headers.length > 0 && (
{headers.map((header, index) => ( -
- updateHeader(index, 'key', e.target.value)} - className="h-7 w-32 text-xs" - placeholder="Header-Name" - /> - updateHeader(index, 'value', e.target.value)} - className="h-7 flex-1 text-xs" - placeholder="value" - /> - +
+
+ {header.locked ? ( + + {header.key} + + ) : ( + updateHeader(index, 'key', e.target.value)} + className="h-7 w-32 text-xs" + placeholder="Header-Name" + /> + )} + updateHeader(index, 'value', e.target.value)} + className="h-7 flex-1 text-xs" + placeholder={header.valueTemplate ?? header.description ?? 'value'} + /> + +
+ {(header.description || header.valueTemplate || header.isRequired) && ( +

+ {[ + header.isRequired ? 'Required' : null, + header.description, + header.valueTemplate, + ] + .filter(Boolean) + .join(' • ')} +

+ )}
))}
@@ -398,7 +456,7 @@ export const McpServerDetailDialog = ({ isInstalled={isInstalled} onInstall={handleInstall} onUninstall={handleUninstall} - disabled={!serverName.trim()} + disabled={installDisabled} size="default" errorMessage={installError} /> diff --git a/src/renderer/components/layout/MoreMenu.tsx b/src/renderer/components/layout/MoreMenu.tsx index 4b30a736..13775035 100644 --- a/src/renderer/components/layout/MoreMenu.tsx +++ b/src/renderer/components/layout/MoreMenu.tsx @@ -1,7 +1,7 @@ /** * MoreMenu - Dropdown menu behind a "..." icon for less-frequent toolbar actions. * - * Groups: Search, Export (session-only), Analyze (session-only), Settings. + * Groups: Search, Export (session-only), Analyze (session-only). * Closes on outside click or Escape. */ @@ -10,7 +10,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useStore } from '@renderer/store'; import { triggerDownload } from '@renderer/utils/sessionExporter'; import { formatShortcut } from '@renderer/utils/stringUtils'; -import { Activity, Braces, FileText, MoreHorizontal, Search, Settings, Type } from 'lucide-react'; +import { Activity, Braces, FileText, MoreHorizontal, Search, Type } from 'lucide-react'; import type { SessionDetail } from '@renderer/types/data'; import type { Tab } from '@renderer/types/tabs'; @@ -41,7 +41,6 @@ export const MoreMenu = ({ const containerRef = useRef(null); const openCommandPalette = useStore((s) => s.openCommandPalette); - const openSettingsTab = useStore((s) => s.openSettingsTab); const openSessionReport = useStore((s) => s.openSessionReport); // Close on outside click @@ -133,19 +132,6 @@ export const MoreMenu = ({ ] : []; - const bottomItems: MenuItem[] = [ - { - id: 'settings', - label: 'Settings', - icon: Settings, - shortcut: formatShortcut(','), - onClick: () => { - openSettingsTab(); - setIsOpen(false); - }, - }, - ]; - const renderItem = (item: MenuItem): React.JSX.Element => (
)} diff --git a/src/renderer/components/layout/TabBarActions.tsx b/src/renderer/components/layout/TabBarActions.tsx index 657c3731..676ba890 100644 --- a/src/renderer/components/layout/TabBarActions.tsx +++ b/src/renderer/components/layout/TabBarActions.tsx @@ -161,7 +161,7 @@ export const TabBarActions = (): React.JSX.Element => { - {/* More menu (Search, Export, Analyze, Settings) */} + {/* More menu (Search, Export, Analyze) */} { const canonical = sorted.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name; - return `${prefix}[@${canonical}](team://${encodeURIComponent(canonical)})`; + return `${prefix}[${canonical}](team://${encodeURIComponent(canonical)})`; }); } diff --git a/src/shared/types/extensions/index.ts b/src/shared/types/extensions/index.ts index 2d51a1d5..e91d3f21 100644 --- a/src/shared/types/extensions/index.ts +++ b/src/shared/types/extensions/index.ts @@ -17,6 +17,7 @@ export { inferCapabilities } from './plugin'; export type { InstalledMcpEntry, + McpAuthHeaderDef, McpCatalogItem, McpCustomInstallRequest, McpEnvVarDef, diff --git a/src/shared/types/extensions/mcp.ts b/src/shared/types/extensions/mcp.ts index 0131fc09..ef05d644 100644 --- a/src/shared/types/extensions/mcp.ts +++ b/src/shared/types/extensions/mcp.ts @@ -26,6 +26,7 @@ export interface McpCatalogItem { updatedAt?: string; author?: string; hostingType?: McpHostingType; + authHeaders?: McpAuthHeaderDef[]; } export interface McpToolDef { @@ -58,12 +59,24 @@ export interface McpEnvVarDef { isRequired?: boolean; // from registry, but treat all as optional in UI } +export interface McpAuthHeaderDef { + key: string; + description?: string; + isRequired?: boolean; + isSecret?: boolean; + valueTemplate?: string; +} + // ── HTTP headers (for auth/config of HTTP/SSE servers) ───────────────────── export interface McpHeaderDef { key: string; value: string; secret?: boolean; // true = mask in UI, don't log + description?: string; + isRequired?: boolean; + valueTemplate?: string; + locked?: boolean; } // ── Installed state (from ~/.claude.json / .mcp.json) ────────────────────── diff --git a/test/main/services/extensions/OfficialMcpRegistryService.test.ts b/test/main/services/extensions/OfficialMcpRegistryService.test.ts index 811e8929..9d7e08fc 100644 --- a/test/main/services/extensions/OfficialMcpRegistryService.test.ts +++ b/test/main/services/extensions/OfficialMcpRegistryService.test.ts @@ -143,6 +143,12 @@ describe('OfficialMcpRegistryService', () => { const adAdvisor = result.servers.find((s) => s.id === 'ai.adadvisor/mcp-server'); expect(adAdvisor?.requiresAuth).toBe(true); + expect(adAdvisor?.authHeaders).toHaveLength(1); + expect(adAdvisor?.authHeaders?.[0]).toMatchObject({ + key: 'Authorization', + isRequired: true, + isSecret: true, + }); }); it('collects environment variables', async () => { diff --git a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts index 591b7c05..ce9a5f97 100644 --- a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts +++ b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts @@ -128,6 +128,7 @@ interface RunLike { provisioningComplete: boolean; leadMsgSeq: number; pendingToolCalls: { name: string; preview: string }[]; + pendingDirectCrossTeamSendRefresh: boolean; lastLeadTextEmitMs: number; leadRelayCapture: null; silentUserDmForward: null; @@ -156,6 +157,7 @@ function attachRun( provisioningComplete: opts?.provisioningComplete ?? false, leadMsgSeq: 0, pendingToolCalls: [], + pendingDirectCrossTeamSendRefresh: false, lastLeadTextEmitMs: 0, leadRelayCapture: null, silentUserDmForward: null, @@ -588,6 +590,46 @@ describe('TeamProvisioningService pre-ready live messages', () => { expect(hoisted.sendInboxMessage).not.toHaveBeenCalled(); }); + it('refreshes sentMessages history after direct MCP cross_team_send succeeds', () => { + const service = new TeamProvisioningService(); + seedConfig('my-team'); + const emitter = vi.fn<(event: TeamChangeEvent) => void>(); + service.setTeamChangeEmitter(emitter); + const run = attachRun(service, 'my-team', { provisioningComplete: true }); + + callHandleStreamJsonMessage(service, run, { + type: 'assistant', + content: [ + { + type: 'tool_use', + name: 'mcp__agent-teams__cross_team_send', + input: { + teamName: 'my-team', + toTeam: 'team-best', + text: 'Прямой вызов MCP.', + summary: 'Direct MCP send', + }, + }, + ], + }); + + expect(run.pendingDirectCrossTeamSendRefresh).toBe(true); + + callHandleStreamJsonMessage(service, run, { + type: 'result', + subtype: 'success', + }); + + expect(run.pendingDirectCrossTeamSendRefresh).toBe(false); + expect(emitter).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'inbox', + teamName: 'my-team', + detail: 'sentMessages.json', + }) + ); + }); + it('marks native cross-team teammate-message deliveries as read and restores reply hints', async () => { const service = new TeamProvisioningService(); seedConfig('my-team'); @@ -627,6 +669,48 @@ describe('TeamProvisioningService pre-ready live messages', () => { ]); }); + it('suppresses native duplicate cross-team teammate-message after recent relay delivery', async () => { + const service = new TeamProvisioningService(); + seedConfig('my-team'); + const content = + '\nПовторная доставка.'; + seedLeadInbox('my-team', [ + { + from: 'other-team.team-lead', + to: 'team-lead', + text: content, + timestamp: '2026-03-10T21:43:00.000Z', + read: false, + source: 'cross_team', + messageId: 'm-native-cross-team-dup', + conversationId: 'conv-native-dup', + replyToConversationId: 'conv-native-dup', + }, + ]); + const run = attachRun(service, 'my-team', { provisioningComplete: true }); + + (service as any).rememberRecentCrossTeamLeadDeliveryMessageIds('my-team', [ + 'm-native-cross-team-dup', + ]); + + callHandleStreamJsonMessage(service, run, { + type: 'user', + message: { + role: 'user', + content: `${content}`, + }, + }); + + await vi.waitFor(() => { + const updatedInbox = JSON.parse( + hoisted.files.get('/mock/teams/my-team/inboxes/team-lead.json') ?? '[]' + ) as Array<{ read?: boolean }>; + expect(updatedInbox[0]?.read).toBe(true); + }); + + expect(run.activeCrossTeamReplyHints).toEqual([]); + }); + it('rescues mistaken cross_team_send recipients into actual cross-team replies', async () => { const service = new TeamProvisioningService(); seedConfig('my-team'); diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index 5f60969b..f4d9ed15 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -355,7 +355,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { expect(service.resolveCrossTeamReplyMetadata(teamName, 'other-team')).toBeNull(); }); - it('does not custom-relay incoming cross-team lead inbox messages', async () => { + it('includes explicit cross-team reply instructions in lead relay prompts', async () => { const service = new TeamProvisioningService(); const teamName = 'my-team'; seedConfig(teamName); @@ -373,17 +373,24 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { ]); const { writeSpy } = attachAliveRun(service, teamName); - const relayed = await service.relayLeadInboxMessages(teamName); + const relayPromise = service.relayLeadInboxMessages(teamName); + const run = await waitForCapture(service); + expect(run?.leadRelayCapture).toBeTruthy(); - expect(relayed).toBe(0); - expect(writeSpy).toHaveBeenCalledTimes(0); + const payload = String(writeSpy.mock.calls[0]?.[0] ?? ''); + expect(payload).toContain('Source: cross_team'); + expect(payload).toContain('Cross-team conversationId: conv-explicit'); + expect(payload).toContain('Call the MCP tool named cross_team_send with toTeam=\\"other-team\\"'); + expect(payload).toContain('replyToConversationId=\\"conv-explicit\\"'); + expect(payload).toContain('NEVER set recipient/to to \\"cross_team_send\\"'); - const updatedInbox = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/team-lead.json`) ?? '[]' - ) as Array<{ messageId?: string; read?: boolean }>; - expect(updatedInbox).toHaveLength(1); - expect(updatedInbox[0]?.messageId).toBe('m-cross-team-explicit'); - expect(updatedInbox[0]?.read).toBe(false); + (service as any).handleStreamJsonMessage(run, { + type: 'assistant', + content: [{ type: 'text', text: 'Replying properly.' }], + }); + (service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' }); + + await relayPromise; }); it('does not relay cross-team sender copies back into the live lead', async () => { @@ -482,7 +489,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { expect(writeSpy).toHaveBeenCalledTimes(0); }); - it('leaves later cross-team follow-up messages for the native teammate-message path', async () => { + it('relays later follow-up messages after the first reply in a conversation was already received', async () => { const service = new TeamProvisioningService(); const teamName = 'my-team'; seedConfig(teamName); @@ -522,17 +529,18 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { ]); const { writeSpy } = attachAliveRun(service, teamName); - const relayed = await service.relayLeadInboxMessages(teamName); + const relayPromise = service.relayLeadInboxMessages(teamName); + const run = await waitForCapture(service); + expect(run?.leadRelayCapture).toBeTruthy(); + (service as any).handleStreamJsonMessage(run, { + type: 'assistant', + content: [{ type: 'text', text: 'I will answer the follow-up.' }], + }); + (service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' }); - expect(relayed).toBe(0); - expect(writeSpy).toHaveBeenCalledTimes(0); - - const updatedInbox = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/team-lead.json`) ?? '[]' - ) as Array<{ messageId?: string; read?: boolean }>; - expect(updatedInbox).toHaveLength(3); - expect(updatedInbox[2]?.messageId).toBe('m-cross-team-followup'); - expect(updatedInbox[2]?.read).toBe(false); + const relayed = await relayPromise; + expect(relayed).toBe(1); + expect(writeSpy).toHaveBeenCalledTimes(1); }); it('relays unread teammate inbox messages through the live team process', async () => {