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:
parent
9d2062c8c0
commit
5da9e2372d
16 changed files with 785 additions and 116 deletions
|
|
@ -3,9 +3,14 @@ const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { createControllerContext } = require('./context.js');
|
const { createControllerContext } = require('./context.js');
|
||||||
const { withFileLockSync } = require('./fileLock.js');
|
const { withFileLockSync } = require('./fileLock.js');
|
||||||
|
const messageStore = require('./messageStore.js');
|
||||||
const cascadeGuard = require('./cascadeGuard.js');
|
const cascadeGuard = require('./cascadeGuard.js');
|
||||||
const runtimeHelpers = require('./runtimeHelpers.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 TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
||||||
const CROSS_TEAM_DEDUPE_WINDOW_MS = 5 * 60 * 1000;
|
const CROSS_TEAM_DEDUPE_WINDOW_MS = 5 * 60 * 1000;
|
||||||
|
|
@ -180,6 +185,7 @@ function sendCrossTeamMessage(context, flags) {
|
||||||
replyToConversationId: replyToConversationId || undefined,
|
replyToConversationId: replyToConversationId || undefined,
|
||||||
});
|
});
|
||||||
const messageId = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
|
const messageId = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
const dedupeKey = buildCrossTeamDedupeKey(fromTeam, fromMember, toTeam, text, summary);
|
const dedupeKey = buildCrossTeamDedupeKey(fromTeam, fromMember, toTeam, text, summary);
|
||||||
|
|
||||||
const inboxPath = path.join(targetContext.paths.teamDir, 'inboxes', `${leadName}.json`);
|
const inboxPath = path.join(targetContext.paths.teamDir, 'inboxes', `${leadName}.json`);
|
||||||
|
|
@ -206,7 +212,7 @@ function sendCrossTeamMessage(context, flags) {
|
||||||
from,
|
from,
|
||||||
to: leadName,
|
to: leadName,
|
||||||
text: formattedText,
|
text: formattedText,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp,
|
||||||
read: false,
|
read: false,
|
||||||
summary: summary || `Cross-team message from ${fromTeam}`,
|
summary: summary || `Cross-team message from ${fromTeam}`,
|
||||||
messageId,
|
messageId,
|
||||||
|
|
@ -224,6 +230,18 @@ function sendCrossTeamMessage(context, flags) {
|
||||||
throw new Error('Cross-team inbox write verification failed');
|
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({
|
outList.push({
|
||||||
messageId,
|
messageId,
|
||||||
fromTeam,
|
fromTeam,
|
||||||
|
|
@ -234,7 +252,7 @@ function sendCrossTeamMessage(context, flags) {
|
||||||
text,
|
text,
|
||||||
summary,
|
summary,
|
||||||
chainDepth,
|
chainDepth,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp,
|
||||||
});
|
});
|
||||||
writeJson(outboxPath, outList);
|
writeJson(outboxPath, outList);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,15 @@ describe('crossTeam module', () => {
|
||||||
expect(outbox).toHaveLength(1);
|
expect(outbox).toHaveLength(1);
|
||||||
expect(outbox[0].toTeam).toBe('team-b');
|
expect(outbox[0].toTeam).toBe('team-b');
|
||||||
expect(outbox[0].conversationId).toBeTruthy();
|
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', () => {
|
it('preserves reply conversation metadata for explicit replies', () => {
|
||||||
|
|
@ -152,6 +161,10 @@ describe('crossTeam module', () => {
|
||||||
|
|
||||||
const outbox = controller.crossTeam.getCrossTeamOutbox();
|
const outbox = controller.crossTeam.getCrossTeamOutbox();
|
||||||
expect(outbox).toHaveLength(1);
|
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', () => {
|
it('allows resending after dedupe window expires', () => {
|
||||||
|
|
|
||||||
210
docs/research/split-screen-multi-view.md
Normal file
210
docs/research/split-screen-multi-view.md
Normal 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** — обе панели показывают одну команду (последнюю выбранную)
|
||||||
|
|
@ -10,7 +10,12 @@ import https from 'node:https';
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
|
|
||||||
import { createLogger } from '@shared/utils/logger';
|
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');
|
const logger = createLogger('Extensions:OfficialMcpRegistry');
|
||||||
|
|
||||||
|
|
@ -265,6 +270,7 @@ export class OfficialMcpRegistryService {
|
||||||
const meta = entry._meta?.['io.modelcontextprotocol.registry/official'];
|
const meta = entry._meta?.['io.modelcontextprotocol.registry/official'];
|
||||||
const installSpec = this.deriveInstallSpec(server);
|
const installSpec = this.deriveInstallSpec(server);
|
||||||
const envVars = this.collectEnvVars(server);
|
const envVars = this.collectEnvVars(server);
|
||||||
|
const authHeaders = this.collectAuthHeaders(server);
|
||||||
const requiresAuth = this.detectAuthRequired(server);
|
const requiresAuth = this.detectAuthRequired(server);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -285,6 +291,7 @@ export class OfficialMcpRegistryService {
|
||||||
status: meta?.status,
|
status: meta?.status,
|
||||||
publishedAt: meta?.publishedAt,
|
publishedAt: meta?.publishedAt,
|
||||||
updatedAt: meta?.updatedAt,
|
updatedAt: meta?.updatedAt,
|
||||||
|
authHeaders,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -330,6 +337,30 @@ export class OfficialMcpRegistryService {
|
||||||
return envVars;
|
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 {
|
private detectAuthRequired(server: RegistryServerEntry['server']): boolean {
|
||||||
for (const remote of server.remotes ?? []) {
|
for (const remote of server.remotes ?? []) {
|
||||||
for (const header of remote.headers ?? []) {
|
for (const header of remote.headers ?? []) {
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,8 @@ interface ProvisioningRun {
|
||||||
leadMsgSeq: number;
|
leadMsgSeq: number;
|
||||||
/** Accumulated tool_use details between text messages. */
|
/** Accumulated tool_use details between text messages. */
|
||||||
pendingToolCalls: ToolCallMeta[];
|
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. */
|
/** Throttle timestamp for emitting inbox refresh events for lead text. */
|
||||||
lastLeadTextEmitMs: number;
|
lastLeadTextEmitMs: number;
|
||||||
/**
|
/**
|
||||||
|
|
@ -1090,6 +1092,7 @@ function isTransientProbeWarning(warning: string): boolean {
|
||||||
|
|
||||||
export class TeamProvisioningService {
|
export class TeamProvisioningService {
|
||||||
private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000;
|
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 runs = new Map<string, ProvisioningRun>();
|
||||||
private readonly activeByTeam = new Map<string, string>();
|
private readonly activeByTeam = new Map<string, string>();
|
||||||
|
|
@ -1099,6 +1102,7 @@ export class TeamProvisioningService {
|
||||||
private readonly memberInboxRelayInFlight = new Map<string, Promise<number>>();
|
private readonly memberInboxRelayInFlight = new Map<string, Promise<number>>();
|
||||||
private readonly relayedMemberInboxMessageIds = new Map<string, Set<string>>();
|
private readonly relayedMemberInboxMessageIds = new Map<string, Set<string>>();
|
||||||
private readonly pendingCrossTeamFirstReplies = new Map<string, Map<string, number>>();
|
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 readonly liveLeadProcessMessages = new Map<string, InboxMessage[]>();
|
||||||
private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null;
|
private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null;
|
||||||
private helpOutputCache: string | 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 {
|
private getRunLeadName(run: ProvisioningRun): string {
|
||||||
return (
|
return (
|
||||||
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead'
|
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 {
|
private extractStreamUserText(msg: Record<string, unknown>): string | null {
|
||||||
const topLevelContent = msg.content;
|
const topLevelContent = msg.content;
|
||||||
if (typeof topLevelContent === 'string') {
|
if (typeof topLevelContent === 'string') {
|
||||||
|
|
@ -1415,29 +1497,48 @@ export class TeamProvisioningService {
|
||||||
return text.length > 0 ? text : null;
|
return text.length > 0 ? text : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async markDeliveredCrossTeamLeadMessagesRead(
|
private async matchCrossTeamLeadInboxMessages(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
leadName: string,
|
leadName: string,
|
||||||
deliveredBlocks: Array<{
|
deliveredBlocks: Array<{
|
||||||
teammateId: string;
|
teammateId: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
toTeam: string;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
}>
|
}>
|
||||||
): Promise<void> {
|
): Promise<
|
||||||
if (deliveredBlocks.length === 0) return;
|
Array<{
|
||||||
|
teammateId: string;
|
||||||
|
content: string;
|
||||||
|
toTeam: string;
|
||||||
|
conversationId: string;
|
||||||
|
messageId: string;
|
||||||
|
wasRead: boolean;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
if (deliveredBlocks.length === 0) return [];
|
||||||
|
|
||||||
let leadInboxMessages: Awaited<ReturnType<TeamInboxReader['getMessagesFor']>> = [];
|
let leadInboxMessages: Awaited<ReturnType<TeamInboxReader['getMessagesFor']>> = [];
|
||||||
try {
|
try {
|
||||||
leadInboxMessages = await this.inboxReader.getMessagesFor(teamName, leadName);
|
leadInboxMessages = await this.inboxReader.getMessagesFor(teamName, leadName);
|
||||||
} catch {
|
} 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) {
|
for (const block of deliveredBlocks) {
|
||||||
const matchesBlock = (message: InboxMessage, requireExactText: boolean): boolean => {
|
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 (!this.hasStableMessageId(message)) return false;
|
||||||
|
if (usedMessageIds.has(message.messageId)) return false;
|
||||||
if (message.from.trim() !== block.teammateId.trim()) return false;
|
if (message.from.trim() !== block.teammateId.trim()) return false;
|
||||||
const messageConversationId =
|
const messageConversationId =
|
||||||
message.replyToConversationId?.trim() ??
|
message.replyToConversationId?.trim() ??
|
||||||
|
|
@ -1450,17 +1551,18 @@ export class TeamProvisioningService {
|
||||||
leadInboxMessages.find((message) => matchesBlock(message, true)) ??
|
leadInboxMessages.find((message) => matchesBlock(message, true)) ??
|
||||||
leadInboxMessages.find((message) => matchesBlock(message, false));
|
leadInboxMessages.find((message) => matchesBlock(message, false));
|
||||||
if (!matched || !this.hasStableMessageId(matched)) continue;
|
if (!matched || !this.hasStableMessageId(matched)) continue;
|
||||||
matched.read = true;
|
usedMessageIds.add(matched.messageId);
|
||||||
toMark.push(matched);
|
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;
|
return matches;
|
||||||
|
|
||||||
try {
|
|
||||||
await this.markInboxMessagesRead(teamName, leadName, toMark);
|
|
||||||
} catch {
|
|
||||||
// best-effort
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleNativeTeammateUserMessage(
|
private handleNativeTeammateUserMessage(
|
||||||
|
|
@ -1490,13 +1592,33 @@ export class TeamProvisioningService {
|
||||||
});
|
});
|
||||||
if (crossTeamBlocks.length === 0) return;
|
if (crossTeamBlocks.length === 0) return;
|
||||||
|
|
||||||
run.activeCrossTeamReplyHints = crossTeamBlocks.map((block) => ({
|
|
||||||
toTeam: block.toTeam,
|
|
||||||
conversationId: block.conversationId,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const leadName = this.getRunLeadName(run);
|
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 {
|
private persistSentMessage(teamName: string, message: InboxMessage): void {
|
||||||
|
|
@ -2216,6 +2338,7 @@ export class TeamProvisioningService {
|
||||||
activeCrossTeamReplyHints: [],
|
activeCrossTeamReplyHints: [],
|
||||||
leadMsgSeq: 0,
|
leadMsgSeq: 0,
|
||||||
pendingToolCalls: [],
|
pendingToolCalls: [],
|
||||||
|
pendingDirectCrossTeamSendRefresh: false,
|
||||||
lastLeadTextEmitMs: 0,
|
lastLeadTextEmitMs: 0,
|
||||||
silentUserDmForward: null,
|
silentUserDmForward: null,
|
||||||
silentUserDmForwardClearHandle: null,
|
silentUserDmForwardClearHandle: null,
|
||||||
|
|
@ -2540,6 +2663,7 @@ export class TeamProvisioningService {
|
||||||
activeCrossTeamReplyHints: [],
|
activeCrossTeamReplyHints: [],
|
||||||
leadMsgSeq: 0,
|
leadMsgSeq: 0,
|
||||||
pendingToolCalls: [],
|
pendingToolCalls: [],
|
||||||
|
pendingDirectCrossTeamSendRefresh: false,
|
||||||
lastLeadTextEmitMs: 0,
|
lastLeadTextEmitMs: 0,
|
||||||
silentUserDmForward: null,
|
silentUserDmForward: null,
|
||||||
silentUserDmForwardClearHandle: null,
|
silentUserDmForwardClearHandle: null,
|
||||||
|
|
@ -3053,14 +3177,80 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
if (unread.length === 0) return 0;
|
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.
|
// 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
|
// 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.
|
// 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>
|
// If the same cross-team delivery already arrived via a raw <teammate-message> turn,
|
||||||
// path and are marked read when that raw user turn is observed, so we intentionally do not
|
// suppress the duplicate relay here and simply mark the inbox row as read.
|
||||||
// custom-relay them here.
|
|
||||||
const ignoredUnread = unread.filter(
|
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) {
|
if (ignoredUnread.length > 0) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -3068,13 +3258,20 @@ export class TeamProvisioningService {
|
||||||
} catch {
|
} catch {
|
||||||
// best-effort
|
// 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(
|
const actionableUnread = unread.filter(
|
||||||
(m) =>
|
(m) =>
|
||||||
!isInboxNoiseMessage(m.text) &&
|
!isInboxNoiseMessage(m.text) &&
|
||||||
m.source !== CROSS_TEAM_SENT_SOURCE &&
|
m.source !== CROSS_TEAM_SENT_SOURCE &&
|
||||||
m.source !== CROSS_TEAM_SOURCE
|
!isCrossTeamReplyToOwnOutbound(m) &&
|
||||||
|
!wasRecentlyDeliveredCrossTeam(m)
|
||||||
);
|
);
|
||||||
if (actionableUnread.length === 0) return 0;
|
if (actionableUnread.length === 0) return 0;
|
||||||
|
|
||||||
|
|
@ -3187,6 +3384,12 @@ export class TeamProvisioningService {
|
||||||
relayedIds.add(m.messageId);
|
relayedIds.add(m.messageId);
|
||||||
}
|
}
|
||||||
this.relayedLeadInboxMessageIds.set(teamName, this.trimRelayedSet(relayedIds));
|
this.relayedLeadInboxMessageIds.set(teamName, this.trimRelayedSet(relayedIds));
|
||||||
|
this.rememberRecentCrossTeamLeadDeliveryMessageIds(
|
||||||
|
teamName,
|
||||||
|
batch
|
||||||
|
.filter((message) => message.source === CROSS_TEAM_SOURCE)
|
||||||
|
.map((message) => message.messageId)
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.markInboxMessagesRead(teamName, leadName, batch);
|
await this.markInboxMessagesRead(teamName, leadName, batch);
|
||||||
|
|
@ -3435,11 +3638,22 @@ export class TeamProvisioningService {
|
||||||
if (part.type !== 'tool_use' || typeof part.name !== 'string') continue;
|
if (part.type !== 'tool_use' || typeof part.name !== 'string') continue;
|
||||||
const isNativeSendMessage = part.name === 'SendMessage';
|
const isNativeSendMessage = part.name === 'SendMessage';
|
||||||
const isTeamMessageSendTool = part.name === 'mcp__agent-teams__message_send';
|
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;
|
const input = part.input;
|
||||||
if (!input || typeof input !== 'object') continue;
|
if (!input || typeof input !== 'object') continue;
|
||||||
const inp = input as Record<string, unknown>;
|
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
|
const recipient = isNativeSendMessage
|
||||||
? typeof inp.recipient === 'string'
|
? typeof inp.recipient === 'string'
|
||||||
? inp.recipient
|
? inp.recipient
|
||||||
|
|
@ -3998,6 +4212,14 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
this.setLeadActivity(run, 'idle');
|
this.setLeadActivity(run, 'idle');
|
||||||
}
|
}
|
||||||
|
if (run.pendingDirectCrossTeamSendRefresh) {
|
||||||
|
run.pendingDirectCrossTeamSendRefresh = false;
|
||||||
|
this.teamChangeEmitter?.({
|
||||||
|
type: 'inbox',
|
||||||
|
teamName: run.teamName,
|
||||||
|
detail: 'sentMessages.json',
|
||||||
|
});
|
||||||
|
}
|
||||||
if (run.leadRelayCapture) {
|
if (run.leadRelayCapture) {
|
||||||
const capture = run.leadRelayCapture;
|
const capture = run.leadRelayCapture;
|
||||||
const combined = capture.textParts.join('\n').trim();
|
const combined = capture.textParts.join('\n').trim();
|
||||||
|
|
@ -4033,6 +4255,7 @@ export class TeamProvisioningService {
|
||||||
run.leadRelayCapture.rejectOnce(errorMsg);
|
run.leadRelayCapture.rejectOnce(errorMsg);
|
||||||
}
|
}
|
||||||
// Clear silent relay flag after any errored turn.
|
// Clear silent relay flag after any errored turn.
|
||||||
|
run.pendingDirectCrossTeamSendRefresh = false;
|
||||||
run.activeCrossTeamReplyHints = [];
|
run.activeCrossTeamReplyHints = [];
|
||||||
run.silentUserDmForward = null;
|
run.silentUserDmForward = null;
|
||||||
if (run.silentUserDmForwardClearHandle) {
|
if (run.silentUserDmForwardClearHandle) {
|
||||||
|
|
@ -4757,6 +4980,7 @@ export class TeamProvisioningService {
|
||||||
*/
|
*/
|
||||||
private cleanupRun(run: ProvisioningRun): void {
|
private cleanupRun(run: ProvisioningRun): void {
|
||||||
this.setLeadActivity(run, 'offline');
|
this.setLeadActivity(run, 'offline');
|
||||||
|
run.pendingDirectCrossTeamSendRefresh = false;
|
||||||
if (run.timeoutHandle) {
|
if (run.timeoutHandle) {
|
||||||
clearTimeout(run.timeoutHandle);
|
clearTimeout(run.timeoutHandle);
|
||||||
run.timeoutHandle = null;
|
run.timeoutHandle = null;
|
||||||
|
|
@ -4776,6 +5000,7 @@ export class TeamProvisioningService {
|
||||||
this.leadInboxRelayInFlight.delete(run.teamName);
|
this.leadInboxRelayInFlight.delete(run.teamName);
|
||||||
this.relayedLeadInboxMessageIds.delete(run.teamName);
|
this.relayedLeadInboxMessageIds.delete(run.teamName);
|
||||||
this.pendingCrossTeamFirstReplies.delete(run.teamName);
|
this.pendingCrossTeamFirstReplies.delete(run.teamName);
|
||||||
|
this.recentCrossTeamLeadDeliveryMessageIds.delete(run.teamName);
|
||||||
run.activeCrossTeamReplyHints = [];
|
run.activeCrossTeamReplyHints = [];
|
||||||
for (const key of Array.from(this.memberInboxRelayInFlight.keys())) {
|
for (const key of Array.from(this.memberInboxRelayInFlight.keys())) {
|
||||||
if (key.startsWith(`${run.teamName}:`)) {
|
if (key.startsWith(`${run.teamName}:`)) {
|
||||||
|
|
|
||||||
|
|
@ -41,11 +41,10 @@ interface CustomMcpServerDialogProps {
|
||||||
|
|
||||||
type TransportMode = 'stdio' | 'http';
|
type TransportMode = 'stdio' | 'http';
|
||||||
type HttpTransport = 'streamable-http' | 'sse' | 'http';
|
type HttpTransport = 'streamable-http' | 'sse' | 'http';
|
||||||
type Scope = 'local' | 'user' | 'project';
|
type Scope = 'local' | 'user';
|
||||||
|
|
||||||
const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
|
const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
|
||||||
{ value: 'user', label: 'User (global)' },
|
{ value: 'user', label: 'User (global)' },
|
||||||
{ value: 'project', label: 'Project' },
|
|
||||||
{ value: 'local', label: 'Local' },
|
{ value: 'local', label: 'Local' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
|
|
@ -45,6 +46,11 @@ export const McpServerCard = ({
|
||||||
server.repositoryUrl ? s.mcpGitHubStars[server.repositoryUrl] : undefined
|
server.repositoryUrl ? s.mcpGitHubStars[server.repositoryUrl] : undefined
|
||||||
);
|
);
|
||||||
const canAutoInstall = !!server.installSpec;
|
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 [imgError, setImgError] = useState(false);
|
||||||
const hasIcon = !!server.iconUrl && !imgError;
|
const hasIcon = !!server.iconUrl && !imgError;
|
||||||
|
|
||||||
|
|
@ -197,7 +203,7 @@ export const McpServerCard = ({
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{canAutoInstall && (
|
{canAutoInstall && !requiresConfiguration && (
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<InstallButton
|
<InstallButton
|
||||||
state={installProgress}
|
state={installProgress}
|
||||||
|
|
@ -217,6 +223,20 @@ export const McpServerCard = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -40,11 +40,10 @@ interface McpServerDetailDialogProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Scope = 'local' | 'user' | 'project';
|
type Scope = 'local' | 'user';
|
||||||
|
|
||||||
const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
|
const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
|
||||||
{ value: 'user', label: 'User (global)' },
|
{ value: 'user', label: 'User (global)' },
|
||||||
{ value: 'project', label: 'Project' },
|
|
||||||
{ value: 'local', label: 'Local' },
|
{ value: 'local', label: 'Local' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -71,16 +70,29 @@ export const McpServerDetailDialog = ({
|
||||||
const [imgError, setImgError] = useState(false);
|
const [imgError, setImgError] = useState(false);
|
||||||
const [autoFilledFields, setAutoFilledFields] = useState<Set<string>>(new Set());
|
const [autoFilledFields, setAutoFilledFields] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// Initialize form when server changes
|
// Initialize form when dialog opens or server changes
|
||||||
const [lastServerId, setLastServerId] = useState<string | null>(null);
|
useEffect(() => {
|
||||||
if (server && server.id !== lastServerId) {
|
if (!server || !open) {
|
||||||
setLastServerId(server.id);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setServerName(sanitizeMcpServerName(server.name));
|
setServerName(sanitizeMcpServerName(server.name));
|
||||||
setEnvValues(Object.fromEntries(server.envVars.map((env) => [env.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);
|
setImgError(false);
|
||||||
setAutoFilledFields(new Set());
|
setAutoFilledFields(new Set());
|
||||||
}
|
}, [server?.id, open]);
|
||||||
|
|
||||||
// Auto-fill env values from saved API keys
|
// Auto-fill env values from saved API keys
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -142,6 +154,18 @@ export const McpServerDetailDialog = ({
|
||||||
const canAutoInstall = !!server.installSpec;
|
const canAutoInstall = !!server.installSpec;
|
||||||
const isHttp = server.installSpec?.type === 'http';
|
const isHttp = server.installSpec?.type === 'http';
|
||||||
const hasIcon = !!server.iconUrl && !imgError;
|
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 = () => {
|
const handleInstall = () => {
|
||||||
installMcpServer({
|
installMcpServer({
|
||||||
|
|
@ -236,13 +260,21 @@ export const McpServerDetailDialog = ({
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<span className="text-text-muted">Install Type</span>
|
<span className="text-text-muted">Install Type</span>
|
||||||
<p className="text-text">
|
{server.installSpec?.type === 'stdio' ? (
|
||||||
{server.installSpec
|
<Button
|
||||||
? server.installSpec.type === 'stdio'
|
variant="link"
|
||||||
? `npm: ${server.installSpec.npmPackage}`
|
className="h-auto p-0 text-sm text-blue-400"
|
||||||
: `HTTP: ${server.installSpec.transportType}`
|
onClick={() => void api.openExternal(npmPackageUrl!)}
|
||||||
: 'Manual setup required'}
|
>
|
||||||
</p>
|
npm: {server.installSpec.npmPackage}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<p className="text-text">
|
||||||
|
{server.installSpec
|
||||||
|
? `HTTP: ${server.installSpec.transportType}`
|
||||||
|
: 'Manual setup required'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{server.author && (
|
{server.author && (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -277,6 +309,12 @@ export const McpServerDetailDialog = ({
|
||||||
This server requires authentication
|
This server requires authentication
|
||||||
</div>
|
</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 */}
|
{/* Install form */}
|
||||||
{canAutoInstall && (
|
{canAutoInstall && (
|
||||||
|
|
@ -356,34 +394,54 @@ export const McpServerDetailDialog = ({
|
||||||
className="h-6 px-1.5 text-xs"
|
className="h-6 px-1.5 text-xs"
|
||||||
>
|
>
|
||||||
<Plus className="mr-1 size-3" />
|
<Plus className="mr-1 size-3" />
|
||||||
Add
|
{hasSuggestedHeaders ? 'Add custom' : 'Add'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{headers.length > 0 && (
|
{headers.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{headers.map((header, index) => (
|
{headers.map((header, index) => (
|
||||||
<div key={index} className="flex items-center gap-2">
|
<div key={index} className="space-y-1">
|
||||||
<Input
|
<div className="flex items-center gap-2">
|
||||||
value={header.key}
|
{header.locked ? (
|
||||||
onChange={(e) => updateHeader(index, 'key', e.target.value)}
|
<code className="w-32 shrink-0 truncate text-xs text-blue-400">
|
||||||
className="h-7 w-32 text-xs"
|
{header.key}
|
||||||
placeholder="Header-Name"
|
</code>
|
||||||
/>
|
) : (
|
||||||
<Input
|
<Input
|
||||||
type={header.secret ? 'password' : 'text'}
|
value={header.key}
|
||||||
value={header.value}
|
onChange={(e) => updateHeader(index, 'key', e.target.value)}
|
||||||
onChange={(e) => updateHeader(index, 'value', e.target.value)}
|
className="h-7 w-32 text-xs"
|
||||||
className="h-7 flex-1 text-xs"
|
placeholder="Header-Name"
|
||||||
placeholder="value"
|
/>
|
||||||
/>
|
)}
|
||||||
<Button
|
<Input
|
||||||
variant="ghost"
|
type={header.secret ? 'password' : 'text'}
|
||||||
size="icon"
|
value={header.value}
|
||||||
className="size-7 text-red-400 hover:bg-red-500/10"
|
onChange={(e) => updateHeader(index, 'value', e.target.value)}
|
||||||
onClick={() => removeHeader(index)}
|
className="h-7 flex-1 text-xs"
|
||||||
>
|
placeholder={header.valueTemplate ?? header.description ?? 'value'}
|
||||||
<Trash2 className="size-3" />
|
/>
|
||||||
</Button>
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -398,7 +456,7 @@ export const McpServerDetailDialog = ({
|
||||||
isInstalled={isInstalled}
|
isInstalled={isInstalled}
|
||||||
onInstall={handleInstall}
|
onInstall={handleInstall}
|
||||||
onUninstall={handleUninstall}
|
onUninstall={handleUninstall}
|
||||||
disabled={!serverName.trim()}
|
disabled={installDisabled}
|
||||||
size="default"
|
size="default"
|
||||||
errorMessage={installError}
|
errorMessage={installError}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/**
|
/**
|
||||||
* MoreMenu - Dropdown menu behind a "..." icon for less-frequent toolbar actions.
|
* 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.
|
* Closes on outside click or Escape.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -10,7 +10,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { triggerDownload } from '@renderer/utils/sessionExporter';
|
import { triggerDownload } from '@renderer/utils/sessionExporter';
|
||||||
import { formatShortcut } from '@renderer/utils/stringUtils';
|
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 { SessionDetail } from '@renderer/types/data';
|
||||||
import type { Tab } from '@renderer/types/tabs';
|
import type { Tab } from '@renderer/types/tabs';
|
||||||
|
|
@ -41,7 +41,6 @@ export const MoreMenu = ({
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const openCommandPalette = useStore((s) => s.openCommandPalette);
|
const openCommandPalette = useStore((s) => s.openCommandPalette);
|
||||||
const openSettingsTab = useStore((s) => s.openSettingsTab);
|
|
||||||
const openSessionReport = useStore((s) => s.openSessionReport);
|
const openSessionReport = useStore((s) => s.openSessionReport);
|
||||||
|
|
||||||
// Close on outside click
|
// 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 => (
|
const renderItem = (item: MenuItem): React.JSX.Element => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
|
@ -192,7 +178,7 @@ export const MoreMenu = ({
|
||||||
{/* Dropdown menu */}
|
{/* Dropdown menu */}
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
backgroundColor: 'var(--color-surface-overlay)',
|
backgroundColor: 'var(--color-surface-overlay)',
|
||||||
borderColor: 'var(--color-border)',
|
borderColor: 'var(--color-border)',
|
||||||
|
|
@ -206,9 +192,6 @@ export const MoreMenu = ({
|
||||||
{sessionItems.map(renderItem)}
|
{sessionItems.map(renderItem)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{separator}
|
|
||||||
{bottomItems.map(renderItem)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,7 @@ export const TabBarActions = (): React.JSX.Element => {
|
||||||
<Settings className="size-4" />
|
<Settings className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* More menu (Search, Export, Analyze, Settings) */}
|
{/* More menu (Search, Export, Analyze) */}
|
||||||
<MoreMenu
|
<MoreMenu
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
activeTabSessionDetail={activeTabSessionDetail}
|
activeTabSessionDetail={activeTabSessionDetail}
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ export function linkifyTeamMentionsInMarkdown(
|
||||||
|
|
||||||
return text.replace(pattern, (_match, prefix: string, name: string) => {
|
return text.replace(pattern, (_match, prefix: string, name: string) => {
|
||||||
const canonical = sorted.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name;
|
const canonical = sorted.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name;
|
||||||
return `${prefix}[@${canonical}](team://${encodeURIComponent(canonical)})`;
|
return `${prefix}[${canonical}](team://${encodeURIComponent(canonical)})`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export { inferCapabilities } from './plugin';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
InstalledMcpEntry,
|
InstalledMcpEntry,
|
||||||
|
McpAuthHeaderDef,
|
||||||
McpCatalogItem,
|
McpCatalogItem,
|
||||||
McpCustomInstallRequest,
|
McpCustomInstallRequest,
|
||||||
McpEnvVarDef,
|
McpEnvVarDef,
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ export interface McpCatalogItem {
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
hostingType?: McpHostingType;
|
hostingType?: McpHostingType;
|
||||||
|
authHeaders?: McpAuthHeaderDef[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface McpToolDef {
|
export interface McpToolDef {
|
||||||
|
|
@ -58,12 +59,24 @@ export interface McpEnvVarDef {
|
||||||
isRequired?: boolean; // from registry, but treat all as optional in UI
|
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) ─────────────────────
|
// ── HTTP headers (for auth/config of HTTP/SSE servers) ─────────────────────
|
||||||
|
|
||||||
export interface McpHeaderDef {
|
export interface McpHeaderDef {
|
||||||
key: string;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
secret?: boolean; // true = mask in UI, don't log
|
secret?: boolean; // true = mask in UI, don't log
|
||||||
|
description?: string;
|
||||||
|
isRequired?: boolean;
|
||||||
|
valueTemplate?: string;
|
||||||
|
locked?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Installed state (from ~/.claude.json / .mcp.json) ──────────────────────
|
// ── Installed state (from ~/.claude.json / .mcp.json) ──────────────────────
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,12 @@ describe('OfficialMcpRegistryService', () => {
|
||||||
const adAdvisor = result.servers.find((s) => s.id === 'ai.adadvisor/mcp-server');
|
const adAdvisor = result.servers.find((s) => s.id === 'ai.adadvisor/mcp-server');
|
||||||
|
|
||||||
expect(adAdvisor?.requiresAuth).toBe(true);
|
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 () => {
|
it('collects environment variables', async () => {
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,7 @@ interface RunLike {
|
||||||
provisioningComplete: boolean;
|
provisioningComplete: boolean;
|
||||||
leadMsgSeq: number;
|
leadMsgSeq: number;
|
||||||
pendingToolCalls: { name: string; preview: string }[];
|
pendingToolCalls: { name: string; preview: string }[];
|
||||||
|
pendingDirectCrossTeamSendRefresh: boolean;
|
||||||
lastLeadTextEmitMs: number;
|
lastLeadTextEmitMs: number;
|
||||||
leadRelayCapture: null;
|
leadRelayCapture: null;
|
||||||
silentUserDmForward: null;
|
silentUserDmForward: null;
|
||||||
|
|
@ -156,6 +157,7 @@ function attachRun(
|
||||||
provisioningComplete: opts?.provisioningComplete ?? false,
|
provisioningComplete: opts?.provisioningComplete ?? false,
|
||||||
leadMsgSeq: 0,
|
leadMsgSeq: 0,
|
||||||
pendingToolCalls: [],
|
pendingToolCalls: [],
|
||||||
|
pendingDirectCrossTeamSendRefresh: false,
|
||||||
lastLeadTextEmitMs: 0,
|
lastLeadTextEmitMs: 0,
|
||||||
leadRelayCapture: null,
|
leadRelayCapture: null,
|
||||||
silentUserDmForward: null,
|
silentUserDmForward: null,
|
||||||
|
|
@ -588,6 +590,46 @@ describe('TeamProvisioningService pre-ready live messages', () => {
|
||||||
expect(hoisted.sendInboxMessage).not.toHaveBeenCalled();
|
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 () => {
|
it('marks native cross-team teammate-message deliveries as read and restores reply hints', async () => {
|
||||||
const service = new TeamProvisioningService();
|
const service = new TeamProvisioningService();
|
||||||
seedConfig('my-team');
|
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 () => {
|
it('rescues mistaken cross_team_send recipients into actual cross-team replies', async () => {
|
||||||
const service = new TeamProvisioningService();
|
const service = new TeamProvisioningService();
|
||||||
seedConfig('my-team');
|
seedConfig('my-team');
|
||||||
|
|
|
||||||
|
|
@ -355,7 +355,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
expect(service.resolveCrossTeamReplyMetadata(teamName, 'other-team')).toBeNull();
|
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 service = new TeamProvisioningService();
|
||||||
const teamName = 'my-team';
|
const teamName = 'my-team';
|
||||||
seedConfig(teamName);
|
seedConfig(teamName);
|
||||||
|
|
@ -373,17 +373,24 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { writeSpy } = attachAliveRun(service, teamName);
|
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);
|
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
|
||||||
expect(writeSpy).toHaveBeenCalledTimes(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(
|
(service as any).handleStreamJsonMessage(run, {
|
||||||
hoisted.files.get(`/mock/teams/${teamName}/inboxes/team-lead.json`) ?? '[]'
|
type: 'assistant',
|
||||||
) as Array<{ messageId?: string; read?: boolean }>;
|
content: [{ type: 'text', text: 'Replying properly.' }],
|
||||||
expect(updatedInbox).toHaveLength(1);
|
});
|
||||||
expect(updatedInbox[0]?.messageId).toBe('m-cross-team-explicit');
|
(service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
|
||||||
expect(updatedInbox[0]?.read).toBe(false);
|
|
||||||
|
await relayPromise;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not relay cross-team sender copies back into the live lead', async () => {
|
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);
|
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 service = new TeamProvisioningService();
|
||||||
const teamName = 'my-team';
|
const teamName = 'my-team';
|
||||||
seedConfig(teamName);
|
seedConfig(teamName);
|
||||||
|
|
@ -522,17 +529,18 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { writeSpy } = attachAliveRun(service, teamName);
|
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);
|
const relayed = await relayPromise;
|
||||||
expect(writeSpy).toHaveBeenCalledTimes(0);
|
expect(relayed).toBe(1);
|
||||||
|
expect(writeSpy).toHaveBeenCalledTimes(1);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('relays unread teammate inbox messages through the live team process', async () => {
|
it('relays unread teammate inbox messages through the live team process', async () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue