diff --git a/src/main/index.ts b/src/main/index.ts index 7735c2dc..5c6dc87f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -676,6 +676,7 @@ function initializeServices(): void { crossTeamInboxWriter, teamProvisioningService ); + teamProvisioningService.setCrossTeamSender((request) => crossTeamService.send(request)); const teamMemberLogsFinder = new TeamMemberLogsFinder(); const memberStatsComputer = new MemberStatsComputer(teamMemberLogsFinder); diff --git a/src/main/services/team/CrossTeamService.ts b/src/main/services/team/CrossTeamService.ts index 418185e9..79de9748 100644 --- a/src/main/services/team/CrossTeamService.ts +++ b/src/main/services/team/CrossTeamService.ts @@ -45,6 +45,8 @@ export class CrossTeamService { async send(request: CrossTeamSendRequest): Promise { const { fromTeam, fromMember, toTeam, text, summary } = request; const chainDepth = request.chainDepth ?? 0; + const messageId = request.messageId?.trim() || randomUUID(); + const timestamp = request.timestamp ?? new Date().toISOString(); const inferredReplyMeta = !request.conversationId && !request.replyToConversationId ? (this.provisioning?.resolveCrossTeamReplyMetadata(fromTeam, toTeam) ?? null) @@ -90,7 +92,6 @@ export class CrossTeamService { conversationId, replyToConversationId, }); - const messageId = randomUUID(); const outboxMessage: CrossTeamMessage = { messageId, fromTeam, @@ -101,7 +102,7 @@ export class CrossTeamService { text, summary, chainDepth, - timestamp: new Date().toISOString(), + timestamp, }; const { duplicate } = await this.outbox.appendIfNotRecent(fromTeam, outboxMessage, async () => { @@ -114,6 +115,8 @@ export class CrossTeamService { member: leadName, text: formattedText, from, + timestamp, + messageId, summary: summary ?? `Cross-team message from ${fromTeam}`, source: CROSS_TEAM_SOURCE, conversationId, @@ -132,6 +135,8 @@ export class CrossTeamService { member: senderLeadName, text, from: 'user', + timestamp, + messageId, to: `${toTeam}.${leadName}`, summary: summary ?? `Cross-team message to ${toTeam}`, source: CROSS_TEAM_SENT_SOURCE, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 68347932..6c564ff1 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -23,6 +23,8 @@ import { CROSS_TEAM_PREFIX_TAG, CROSS_TEAM_SENT_SOURCE, parseCrossTeamPrefix, + parseCrossTeamReplyPrefix, + stripCrossTeamPrefix, } from '@shared/constants/crossTeam'; import { getMemberColorByName } from '@shared/constants/memberColors'; import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; @@ -52,6 +54,7 @@ import { TeamSentMessagesStore } from './TeamSentMessagesStore'; import { TeamTaskReader } from './TeamTaskReader'; import type { + CrossTeamSendResult, InboxMessage, LeadContextUsage, TeamChangeEvent, @@ -72,6 +75,7 @@ import type { const logger = createLogger('Service:TeamProvisioning'); const { createController } = agentTeamsControllerModule; +const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; const RUN_TIMEOUT_MS = 300_000; const VERIFY_TIMEOUT_MS = 15_000; const VERIFY_POLL_MS = 500; @@ -1067,6 +1071,19 @@ export class TeamProvisioningService { private toolApprovalSettings: ToolApprovalSettings = DEFAULT_TOOL_APPROVAL_SETTINGS; private pendingTimeouts = new Map(); private inFlightResponses = new Set(); + private crossTeamSender: + | ((request: { + fromTeam: string; + fromMember: string; + toTeam: string; + text: string; + summary?: string; + messageId?: string; + timestamp?: string; + conversationId?: string; + replyToConversationId?: string; + }) => Promise) + | null = null; constructor( private readonly configReader: TeamConfigReader = new TeamConfigReader(), @@ -1076,6 +1093,24 @@ export class TeamProvisioningService { private readonly mcpConfigBuilder: TeamMcpConfigBuilder = new TeamMcpConfigBuilder() ) {} + setCrossTeamSender( + sender: + | ((request: { + fromTeam: string; + fromMember: string; + toTeam: string; + text: string; + summary?: string; + messageId?: string; + timestamp?: string; + conversationId?: string; + replyToConversationId?: string; + }) => Promise) + | null + ): void { + this.crossTeamSender = sender; + } + getClaudeLogs( teamName: string, query?: { offset?: number; limit?: number } @@ -1186,6 +1221,21 @@ export class TeamProvisioningService { this.teamChangeEmitter = emitter; } + private parseCrossTeamRecipient( + currentTeam: string, + recipient: string + ): { teamName: string; memberName: string } | null { + const trimmed = recipient.trim(); + const dot = trimmed.indexOf('.'); + if (dot <= 0 || dot === trimmed.length - 1) return null; + const teamName = trimmed.slice(0, dot).trim(); + const memberName = trimmed.slice(dot + 1).trim(); + if (!TEAM_NAME_PATTERN.test(teamName) || !memberName || teamName === currentTeam) { + return null; + } + return { teamName, memberName }; + } + private persistSentMessage(teamName: string, message: InboxMessage): void { try { createController({ @@ -1200,6 +1250,8 @@ export class TeamProvisioningService { messageId: message.messageId, source: message.source, leadSessionId: message.leadSessionId, + conversationId: message.conversationId, + replyToConversationId: message.replyToConversationId, attachments: message.attachments, color: message.color, toolSummary: message.toolSummary, @@ -1224,6 +1276,8 @@ export class TeamProvisioningService { messageId: message.messageId, source: message.source, leadSessionId: message.leadSessionId, + conversationId: message.conversationId, + replyToConversationId: message.replyToConversationId, attachments: message.attachments, color: message.color, toolSummary: message.toolSummary, @@ -3016,17 +3070,91 @@ export class TeamProvisioningService { const cleanContent = stripAgentBlocks(msgContent); if (cleanContent.trim().length === 0) continue; + const strippedCrossTeamContent = stripCrossTeamPrefix(cleanContent).trim(); + if (strippedCrossTeamContent.length === 0) continue; + + const crossTeamRecipient = this.parseCrossTeamRecipient(run.teamName, recipient); + if (crossTeamRecipient && this.crossTeamSender) { + const explicitReplyMeta = parseCrossTeamReplyPrefix(cleanContent); + const inferredReplyMeta = this.resolveCrossTeamReplyMetadata( + run.teamName, + crossTeamRecipient.teamName + ); + const crossTeamMeta = parseCrossTeamPrefix(cleanContent); + const replyMeta = explicitReplyMeta ?? inferredReplyMeta; + const timestamp = nowIso(); + const messageId = `lead-sendmsg-${run.runId}-${Date.now()}`; + + void this.crossTeamSender({ + fromTeam: run.teamName, + fromMember: leadName, + toTeam: crossTeamRecipient.teamName, + text: strippedCrossTeamContent, + summary, + messageId, + timestamp, + conversationId: + explicitReplyMeta?.conversationId ?? + crossTeamMeta?.conversationId ?? + replyMeta?.conversationId, + replyToConversationId: + explicitReplyMeta?.replyToConversationId ?? + replyMeta?.replyToConversationId ?? + explicitReplyMeta?.conversationId ?? + crossTeamMeta?.conversationId ?? + replyMeta?.conversationId, + }) + .then(() => { + const msg: InboxMessage = { + from: 'user', + to: `${crossTeamRecipient.teamName}.${crossTeamRecipient.memberName}`, + text: strippedCrossTeamContent, + timestamp, + read: true, + summary: + (summary || strippedCrossTeamContent).length > 60 + ? (summary || strippedCrossTeamContent).slice(0, 57) + '...' + : summary || strippedCrossTeamContent, + messageId, + source: 'cross_team_sent', + conversationId: + explicitReplyMeta?.conversationId ?? + crossTeamMeta?.conversationId ?? + replyMeta?.conversationId, + replyToConversationId: + explicitReplyMeta?.replyToConversationId ?? + replyMeta?.replyToConversationId ?? + explicitReplyMeta?.conversationId ?? + crossTeamMeta?.conversationId ?? + replyMeta?.conversationId, + }; + this.pushLiveLeadProcessMessage(run.teamName, msg); + this.teamChangeEmitter?.({ + type: 'lead-message', + teamName: run.teamName, + detail: 'cross-team-send', + }); + }) + .catch((error: unknown) => { + logger.warn( + `[${run.teamName}] qualified SendMessage→${recipient} cross-team fallback failed: ${ + error instanceof Error ? error.message : String(error) + }` + ); + }); + continue; + } const msg: InboxMessage = { from: leadName, to: recipient, - text: cleanContent, + text: strippedCrossTeamContent, timestamp: nowIso(), read: recipient !== 'user', summary: - (summary || cleanContent).length > 60 - ? (summary || cleanContent).slice(0, 57) + '...' - : summary || cleanContent, + (summary || strippedCrossTeamContent).length > 60 + ? (summary || strippedCrossTeamContent).slice(0, 57) + '...' + : summary || strippedCrossTeamContent, messageId: `lead-sendmsg-${run.runId}-${Date.now()}`, source: 'lead_process', }; @@ -3094,9 +3222,10 @@ export class TeamProvisioningService { const runId = this.activeByTeam.get(teamName); if (!runId) return null; const run = this.runs.get(runId); - if (!run || run.activeCrossTeamReplyHints.length === 0) return null; + const hints = run?.activeCrossTeamReplyHints ?? []; + if (hints.length === 0) return null; - const matches = run.activeCrossTeamReplyHints.filter((hint) => hint.toTeam === toTeam); + const matches = hints.filter((hint) => hint.toTeam === toTeam); if (matches.length !== 1) return null; return { diff --git a/src/renderer/components/settings/sections/CliStatusSection.tsx b/src/renderer/components/settings/sections/CliStatusSection.tsx index aadc0c9c..c351c371 100644 --- a/src/renderer/components/settings/sections/CliStatusSection.tsx +++ b/src/renderer/components/settings/sections/CliStatusSection.tsx @@ -10,7 +10,15 @@ import { useCallback, useEffect, useMemo } from 'react'; import { isElectronMode } from '@renderer/api'; import { useCliInstaller } from '@renderer/hooks/useCliInstaller'; import { formatBytes } from '@renderer/utils/formatters'; -import { AlertTriangle, CheckCircle, Download, Loader2, RefreshCw, Terminal } from 'lucide-react'; +import { + AlertTriangle, + CheckCircle, + Download, + Loader2, + Puzzle, + RefreshCw, + Terminal, +} from 'lucide-react'; import { SettingsSectionHeader } from '../components'; @@ -66,15 +74,61 @@ export const CliStatusSection = (): React.JSX.Element | null => {
{cliStatus.installed ? (
-
+
- Claude CLI v{cliStatus.installedVersion ?? 'unknown'} + + Claude CLI v{cliStatus.installedVersion ?? 'unknown'} + + {/* Inline action buttons */} + {cliStatus.updateAvailable ? ( + + ) : ( + + )} + {/* Extensions button — right-aligned */} +
{cliStatus.binaryPath && (

{

)} - {/* Action buttons */} -
- {!cliStatus.installed && ( - - )} - {cliStatus.installed && cliStatus.updateAvailable && ( - - )} - {cliStatus.installed && !cliStatus.updateAvailable && ( - - )} -
+ {/* Install button (CLI not installed) */} + {!cliStatus.installed && ( + + )}
)} diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 266e7782..600a3097 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -27,6 +27,8 @@ import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { CROSS_TEAM_SENT_SOURCE, CROSS_TEAM_SOURCE, + parseCrossTeamPrefix, + parseCrossTeamReplyPrefix, stripCrossTeamPrefix, } from '@shared/constants/crossTeam'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; @@ -43,6 +45,19 @@ import type { InboxMessage } from '@shared/types'; type StructuredMessage = Record; +function parseQualifiedRecipient( + value: string | undefined +): { teamName: string; memberName: string } | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + const dot = trimmed.indexOf('.'); + if (dot <= 0 || dot === trimmed.length - 1) return null; + return { + teamName: trimmed.slice(0, dot), + memberName: trimmed.slice(dot + 1), + }; +} + interface ActivityItemProps { message: InboxMessage; teamName: string; @@ -259,24 +274,36 @@ export const ActivityItem = ({ const isManaged = isManagedCollapseState(collapseState); const isExpanded = isManaged ? !collapseState.isCollapsed : true; - const isCrossTeam = message.source === CROSS_TEAM_SOURCE; - const isCrossTeamSent = message.source === CROSS_TEAM_SENT_SOURCE; + const parsedCrossTeamPrefix = useMemo(() => parseCrossTeamPrefix(message.text), [message.text]); + const parsedCrossTeamReplyPrefix = useMemo( + () => parseCrossTeamReplyPrefix(message.text), + [message.text] + ); + const qualifiedRecipient = useMemo(() => parseQualifiedRecipient(message.to), [message.to]); + const isCrossTeam = message.source === CROSS_TEAM_SOURCE || parsedCrossTeamPrefix !== null; + const isCrossTeamSent = + message.source === CROSS_TEAM_SENT_SOURCE || + parsedCrossTeamReplyPrefix !== null || + (qualifiedRecipient?.teamName !== undefined && qualifiedRecipient.teamName !== teamName); const isCrossTeamAny = isCrossTeam || isCrossTeamSent; const crossTeamOrigin = useMemo(() => { if (!isCrossTeam) return null; - const dot = message.from.indexOf('.'); - if (dot <= 0 || dot === message.from.length - 1) return null; + const fromValue = parsedCrossTeamPrefix?.from ?? message.from; + const dot = fromValue.indexOf('.'); + if (dot <= 0 || dot === fromValue.length - 1) return null; return { - teamName: message.from.substring(0, dot), - memberName: message.from.substring(dot + 1), + teamName: fromValue.substring(0, dot), + memberName: fromValue.substring(dot + 1), }; - }, [isCrossTeam, message.from]); + }, [isCrossTeam, message.from, parsedCrossTeamPrefix]); const crossTeamTarget = useMemo(() => { - if (!isCrossTeamSent || !message.to) return null; + if (!isCrossTeamSent) return null; + if (qualifiedRecipient) return qualifiedRecipient.teamName; + if (!message.to) return null; const dot = message.to.indexOf('.'); if (dot <= 0) return message.to; return message.to.substring(0, dot); - }, [isCrossTeamSent, message.to]); + }, [isCrossTeamSent, message.to, qualifiedRecipient]); // Strip agent-only blocks + normalize escape sequences (before linkification) const strippedText = useMemo(() => { @@ -493,9 +520,9 @@ export const ActivityItem = ({ → diff --git a/src/shared/constants/crossTeam.ts b/src/shared/constants/crossTeam.ts index d905a482..c1f160f1 100644 --- a/src/shared/constants/crossTeam.ts +++ b/src/shared/constants/crossTeam.ts @@ -51,6 +51,9 @@ export function formatCrossTeamText( export const CROSS_TEAM_PREFIX_RE = /^\[Cross-team from (?[^\]|]+?) \| depth:(?\d+)(?: \| conversation:(?[^\]|]+))?(?: \| replyTo:(?[^\]|]+))?\]\n?/; +export const CROSS_TEAM_REPLY_PREFIX_RE = + /^\[Cross-team reply(?: \| conversation:(?[^\]|]+))?(?: \| replyTo:(?[^\]|]+))?\]\n?/; + /** Parse metadata from a cross-team prefix line. */ export function parseCrossTeamPrefix(text: string): ParsedCrossTeamPrefix | null { const match = text.match(CROSS_TEAM_PREFIX_RE); @@ -68,9 +71,19 @@ export function parseCrossTeamPrefix(text: string): ParsedCrossTeamPrefix | null }; } +export function parseCrossTeamReplyPrefix(text: string): CrossTeamPrefixMeta | null { + const match = text.match(CROSS_TEAM_REPLY_PREFIX_RE); + if (!match?.groups) return null; + + return { + conversationId: match.groups.conversationId?.trim() || undefined, + replyToConversationId: match.groups.replyToConversationId?.trim() || undefined, + }; +} + /** Strip the cross-team prefix from message text (for UI display). */ export function stripCrossTeamPrefix(text: string): string { - return text.replace(CROSS_TEAM_PREFIX_RE, ''); + return text.replace(CROSS_TEAM_PREFIX_RE, '').replace(CROSS_TEAM_REPLY_PREFIX_RE, ''); } // ── Source discriminators ──────────────────────────────────────────────────── diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index cf4ba2c2..e6b745fe 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -634,6 +634,8 @@ export interface CrossTeamSendRequest { fromTeam: string; fromMember: string; toTeam: string; + timestamp?: string; + messageId?: string; conversationId?: string; replyToConversationId?: string; text: string; diff --git a/test/main/services/team/CrossTeamService.test.ts b/test/main/services/team/CrossTeamService.test.ts index fc34f226..f936618a 100644 --- a/test/main/services/team/CrossTeamService.test.ts +++ b/test/main/services/team/CrossTeamService.test.ts @@ -122,6 +122,10 @@ describe('CrossTeamService', () => { expect(senderReq.source).toBe(CROSS_TEAM_SENT_SOURCE); expect(senderReq.to).toBe('team-b.team-lead'); expect(senderReq.text).toBe('Hello from team-a'); + expect(senderReq.messageId).toBeDefined(); + expect(senderReq.timestamp).toBeDefined(); + expect(senderReq.messageId).toBe(inboxWriter.sendMessage.mock.calls[0][1].messageId); + expect(senderReq.timestamp).toBe(inboxWriter.sendMessage.mock.calls[0][1].timestamp); expect(senderReq.conversationId).toBeTruthy(); }); diff --git a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts index 950e0401..1ee040cf 100644 --- a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts +++ b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts @@ -422,4 +422,53 @@ describe('TeamProvisioningService pre-ready live messages', () => { }) ); }); + + it('upgrades qualified SendMessage recipients into cross-team sends', async () => { + const service = new TeamProvisioningService(); + seedConfig('my-team'); + const emitter = vi.fn<(event: TeamChangeEvent) => void>(); + const crossTeamSender = vi.fn(async () => ({ deliveredToInbox: true, messageId: 'cross-1' })); + service.setTeamChangeEmitter(emitter); + service.setCrossTeamSender(crossTeamSender); + const run = attachRun(service, 'my-team', { provisioningComplete: true }); + + callHandleStreamJsonMessage(service, run, { + type: 'assistant', + content: [ + { + type: 'tool_use', + name: 'SendMessage', + input: { + type: 'message', + recipient: 'team-best.user', + content: '[Cross-team reply | conversation:conv-123] Привет!', + summary: 'Ответ', + }, + }, + ], + }); + + await vi.waitFor(() => { + expect(crossTeamSender).toHaveBeenCalledTimes(1); + }); + + expect(crossTeamSender).toHaveBeenCalledWith( + expect.objectContaining({ + fromTeam: 'my-team', + fromMember: 'team-lead', + toTeam: 'team-best', + text: 'Привет!', + conversationId: 'conv-123', + replyToConversationId: 'conv-123', + }) + ); + + const live = service.getLiveLeadProcessMessages('my-team'); + expect(live).toHaveLength(1); + expect(live[0].source).toBe('cross_team_sent'); + expect(live[0].to).toBe('team-best.user'); + expect(live[0].text).toBe('Привет!'); + expect(hoisted.sendInboxMessage).not.toHaveBeenCalled(); + expect(hoisted.appendSentMessage).not.toHaveBeenCalled(); + }); }); diff --git a/test/shared/constants/crossTeam.test.ts b/test/shared/constants/crossTeam.test.ts new file mode 100644 index 00000000..9e51a548 --- /dev/null +++ b/test/shared/constants/crossTeam.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { + parseCrossTeamPrefix, + parseCrossTeamReplyPrefix, + stripCrossTeamPrefix, +} from '@shared/constants/crossTeam'; + +describe('crossTeam protocol helpers', () => { + it('parses canonical cross-team prefix metadata', () => { + const parsed = parseCrossTeamPrefix( + '[Cross-team from dream-team.team-lead | depth:0 | conversation:conv-1 | replyTo:conv-0]\nHello' + ); + + expect(parsed).toEqual({ + from: 'dream-team.team-lead', + chainDepth: 0, + conversationId: 'conv-1', + replyToConversationId: 'conv-0', + }); + }); + + it('parses manual cross-team reply prefix metadata', () => { + const parsed = parseCrossTeamReplyPrefix( + '[Cross-team reply | conversation:conv-1 | replyTo:conv-1]\nHello' + ); + + expect(parsed).toEqual({ + conversationId: 'conv-1', + replyToConversationId: 'conv-1', + }); + }); + + it('strips both canonical and reply prefixes from UI text', () => { + expect(stripCrossTeamPrefix('[Cross-team from a.b | depth:0 | conversation:conv-1]\nHello')).toBe( + 'Hello' + ); + expect(stripCrossTeamPrefix('[Cross-team reply | conversation:conv-1]\nHello')).toBe('Hello'); + }); +});