feat: enhance cross-team messaging and message storage

- Introduced new parameters for cross-team messaging, including CROSS_TEAM_SENT_SOURCE for better tracking of sent messages.
- Updated sendCrossTeamMessage function to append sent messages to the message store, ensuring a complete history of communications.
- Enhanced tests to validate the new message storage functionality and ensure accurate retrieval of sent messages.
- Improved handling of message timestamps and deduplication logic for cross-team communications.
This commit is contained in:
iliya 2026-03-11 00:33:17 +02:00
parent 9d2062c8c0
commit 5da9e2372d
16 changed files with 785 additions and 116 deletions

View file

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

View file

@ -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', () => {

View file

@ -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<string, {
teamName: string;
teamData: TeamData | null;
loading: boolean;
error: string | null;
}>
```
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**обе панели показывают одну команду (последнюю выбранную)

View file

@ -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<string>();
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 ?? []) {

View file

@ -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<string, ProvisioningRun>();
private readonly activeByTeam = new Map<string, string>();
@ -1099,6 +1102,7 @@ export class TeamProvisioningService {
private readonly memberInboxRelayInFlight = new Map<string, Promise<number>>();
private readonly relayedMemberInboxMessageIds = new Map<string, Set<string>>();
private readonly pendingCrossTeamFirstReplies = new Map<string, Map<string, number>>();
private readonly recentCrossTeamLeadDeliveryMessageIds = new Map<string, Map<string, number>>();
private readonly liveLeadProcessMessages = new Map<string, InboxMessage[]>();
private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null;
private helpOutputCache: string | null = null;
@ -1367,12 +1371,90 @@ export class TeamProvisioningService {
}
}
private getPendingCrossTeamReplyExpectationKeys(teamName: string): Set<string> {
const teamMap = this.pendingCrossTeamFirstReplies.get(teamName.trim());
if (!teamMap) return new Set<string>();
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<string>();
}
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<string, number>();
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, unknown>): 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<void> {
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<ReturnType<TeamInboxReader['getMessagesFor']>> = [];
try {
leadInboxMessages = await this.inboxReader.getMessagesFor(teamName, leadName);
} catch {
return;
return [];
}
const toMark: (InboxMessage & { messageId: string })[] = [];
const usedMessageIds = new Set<string>();
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<string, number>();
const latestReadInboundByConversation = new Map<string, number>();
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<string>();
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 <teammate-message>
// 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 <teammate-message> 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<string, unknown>;
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}:`)) {

View file

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

View file

@ -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 = ({
</Tooltip>
)}
</div>
{canAutoInstall && (
{canAutoInstall && !requiresConfiguration && (
<div className="shrink-0">
<InstallButton
state={installProgress}
@ -217,6 +223,20 @@ export const McpServerCard = ({
/>
</div>
)}
{canAutoInstall && requiresConfiguration && (
<div className="shrink-0">
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
onClick(server.id);
}}
>
{isInstalled ? 'Manage' : 'Configure'}
</Button>
</div>
)}
</div>
</div>
);

View file

@ -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<Set<string>>(new Set());
// Initialize form when server changes
const [lastServerId, setLastServerId] = useState<string | null>(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 = ({
)}
<div>
<span className="text-text-muted">Install Type</span>
<p className="text-text">
{server.installSpec
? server.installSpec.type === 'stdio'
? `npm: ${server.installSpec.npmPackage}`
: `HTTP: ${server.installSpec.transportType}`
: 'Manual setup required'}
</p>
{server.installSpec?.type === 'stdio' ? (
<Button
variant="link"
className="h-auto p-0 text-sm text-blue-400"
onClick={() => void api.openExternal(npmPackageUrl!)}
>
npm: {server.installSpec.npmPackage}
</Button>
) : (
<p className="text-text">
{server.installSpec
? `HTTP: ${server.installSpec.transportType}`
: 'Manual setup required'}
</p>
)}
</div>
{server.author && (
<div>
@ -277,6 +309,12 @@ export const McpServerDetailDialog = ({
This server requires authentication
</div>
)}
{isHttp && !server.requiresAuth && (server.authHeaders?.length ?? 0) === 0 && (
<div className="rounded-md border border-blue-500/30 bg-blue-500/5 px-3 py-2 text-sm text-blue-400">
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.
</div>
)}
{/* Install form */}
{canAutoInstall && (
@ -356,34 +394,54 @@ export const McpServerDetailDialog = ({
className="h-6 px-1.5 text-xs"
>
<Plus className="mr-1 size-3" />
Add
{hasSuggestedHeaders ? 'Add custom' : 'Add'}
</Button>
</div>
{headers.length > 0 && (
<div className="space-y-2">
{headers.map((header, index) => (
<div key={index} className="flex items-center gap-2">
<Input
value={header.key}
onChange={(e) => updateHeader(index, 'key', e.target.value)}
className="h-7 w-32 text-xs"
placeholder="Header-Name"
/>
<Input
type={header.secret ? 'password' : 'text'}
value={header.value}
onChange={(e) => updateHeader(index, 'value', e.target.value)}
className="h-7 flex-1 text-xs"
placeholder="value"
/>
<Button
variant="ghost"
size="icon"
className="size-7 text-red-400 hover:bg-red-500/10"
onClick={() => removeHeader(index)}
>
<Trash2 className="size-3" />
</Button>
<div key={index} className="space-y-1">
<div className="flex items-center gap-2">
{header.locked ? (
<code className="w-32 shrink-0 truncate text-xs text-blue-400">
{header.key}
</code>
) : (
<Input
value={header.key}
onChange={(e) => updateHeader(index, 'key', e.target.value)}
className="h-7 w-32 text-xs"
placeholder="Header-Name"
/>
)}
<Input
type={header.secret ? 'password' : 'text'}
value={header.value}
onChange={(e) => updateHeader(index, 'value', e.target.value)}
className="h-7 flex-1 text-xs"
placeholder={header.valueTemplate ?? header.description ?? 'value'}
/>
<Button
variant="ghost"
size="icon"
className="size-7 text-red-400 hover:bg-red-500/10"
onClick={() => removeHeader(index)}
disabled={header.locked && header.isRequired}
>
<Trash2 className="size-3" />
</Button>
</div>
{(header.description || header.valueTemplate || header.isRequired) && (
<p className="text-[10px] text-text-muted">
{[
header.isRequired ? 'Required' : null,
header.description,
header.valueTemplate,
]
.filter(Boolean)
.join(' • ')}
</p>
)}
</div>
))}
</div>
@ -398,7 +456,7 @@ export const McpServerDetailDialog = ({
isInstalled={isInstalled}
onInstall={handleInstall}
onUninstall={handleUninstall}
disabled={!serverName.trim()}
disabled={installDisabled}
size="default"
errorMessage={installError}
/>

View file

@ -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<HTMLDivElement>(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 => (
<button
key={item.id}
@ -192,7 +178,7 @@ export const MoreMenu = ({
{/* Dropdown menu */}
{isOpen && (
<div
className="absolute right-0 top-full z-50 mt-1 w-52 overflow-hidden rounded-md border shadow-lg"
className="absolute right-0 top-full z-50 mt-1 w-64 overflow-hidden rounded-md border py-1 shadow-lg"
style={{
backgroundColor: 'var(--color-surface-overlay)',
borderColor: 'var(--color-border)',
@ -206,9 +192,6 @@ export const MoreMenu = ({
{sessionItems.map(renderItem)}
</>
)}
{separator}
{bottomItems.map(renderItem)}
</div>
)}
</div>

View file

@ -161,7 +161,7 @@ export const TabBarActions = (): React.JSX.Element => {
<Settings className="size-4" />
</button>
{/* More menu (Search, Export, Analyze, Settings) */}
{/* More menu (Search, Export, Analyze) */}
<MoreMenu
activeTab={activeTab}
activeTabSessionDetail={activeTabSessionDetail}

View file

@ -58,7 +58,7 @@ export function linkifyTeamMentionsInMarkdown(
return text.replace(pattern, (_match, prefix: string, name: string) => {
const canonical = sorted.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name;
return `${prefix}[@${canonical}](team://${encodeURIComponent(canonical)})`;
return `${prefix}[${canonical}](team://${encodeURIComponent(canonical)})`;
});
}

View file

@ -17,6 +17,7 @@ export { inferCapabilities } from './plugin';
export type {
InstalledMcpEntry,
McpAuthHeaderDef,
McpCatalogItem,
McpCustomInstallRequest,
McpEnvVarDef,

View file

@ -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) ──────────────────────

View file

@ -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 () => {

View file

@ -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 =
'<cross-team from="other-team.team-lead" depth="0" conversationId="conv-native-dup" replyToConversationId="conv-native-dup" />\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: `<teammate-message teammate_id="other-team.team-lead" color="purple" summary="Cross-team reply">${content}</teammate-message>`,
},
});
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');

View file

@ -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 () => {