feat: enhance cross-team messaging with message identity and metadata
- Added messageId and timestamp fields to CrossTeamSendRequest for better message tracking. - Updated CrossTeamService to utilize these fields when sending messages, ensuring consistent message identity. - Enhanced TeamProvisioningService to support cross-team sender functionality, allowing for improved message handling. - Introduced parsing for cross-team reply prefixes to manage threaded conversations effectively. - Updated tests to validate the inclusion of messageId and timestamp in cross-team messages.
This commit is contained in:
parent
e99cbe1335
commit
477c28ed30
10 changed files with 362 additions and 74 deletions
|
|
@ -676,6 +676,7 @@ function initializeServices(): void {
|
||||||
crossTeamInboxWriter,
|
crossTeamInboxWriter,
|
||||||
teamProvisioningService
|
teamProvisioningService
|
||||||
);
|
);
|
||||||
|
teamProvisioningService.setCrossTeamSender((request) => crossTeamService.send(request));
|
||||||
|
|
||||||
const teamMemberLogsFinder = new TeamMemberLogsFinder();
|
const teamMemberLogsFinder = new TeamMemberLogsFinder();
|
||||||
const memberStatsComputer = new MemberStatsComputer(teamMemberLogsFinder);
|
const memberStatsComputer = new MemberStatsComputer(teamMemberLogsFinder);
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ export class CrossTeamService {
|
||||||
async send(request: CrossTeamSendRequest): Promise<CrossTeamSendResult> {
|
async send(request: CrossTeamSendRequest): Promise<CrossTeamSendResult> {
|
||||||
const { fromTeam, fromMember, toTeam, text, summary } = request;
|
const { fromTeam, fromMember, toTeam, text, summary } = request;
|
||||||
const chainDepth = request.chainDepth ?? 0;
|
const chainDepth = request.chainDepth ?? 0;
|
||||||
|
const messageId = request.messageId?.trim() || randomUUID();
|
||||||
|
const timestamp = request.timestamp ?? new Date().toISOString();
|
||||||
const inferredReplyMeta =
|
const inferredReplyMeta =
|
||||||
!request.conversationId && !request.replyToConversationId
|
!request.conversationId && !request.replyToConversationId
|
||||||
? (this.provisioning?.resolveCrossTeamReplyMetadata(fromTeam, toTeam) ?? null)
|
? (this.provisioning?.resolveCrossTeamReplyMetadata(fromTeam, toTeam) ?? null)
|
||||||
|
|
@ -90,7 +92,6 @@ export class CrossTeamService {
|
||||||
conversationId,
|
conversationId,
|
||||||
replyToConversationId,
|
replyToConversationId,
|
||||||
});
|
});
|
||||||
const messageId = randomUUID();
|
|
||||||
const outboxMessage: CrossTeamMessage = {
|
const outboxMessage: CrossTeamMessage = {
|
||||||
messageId,
|
messageId,
|
||||||
fromTeam,
|
fromTeam,
|
||||||
|
|
@ -101,7 +102,7 @@ export class CrossTeamService {
|
||||||
text,
|
text,
|
||||||
summary,
|
summary,
|
||||||
chainDepth,
|
chainDepth,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { duplicate } = await this.outbox.appendIfNotRecent(fromTeam, outboxMessage, async () => {
|
const { duplicate } = await this.outbox.appendIfNotRecent(fromTeam, outboxMessage, async () => {
|
||||||
|
|
@ -114,6 +115,8 @@ export class CrossTeamService {
|
||||||
member: leadName,
|
member: leadName,
|
||||||
text: formattedText,
|
text: formattedText,
|
||||||
from,
|
from,
|
||||||
|
timestamp,
|
||||||
|
messageId,
|
||||||
summary: summary ?? `Cross-team message from ${fromTeam}`,
|
summary: summary ?? `Cross-team message from ${fromTeam}`,
|
||||||
source: CROSS_TEAM_SOURCE,
|
source: CROSS_TEAM_SOURCE,
|
||||||
conversationId,
|
conversationId,
|
||||||
|
|
@ -132,6 +135,8 @@ export class CrossTeamService {
|
||||||
member: senderLeadName,
|
member: senderLeadName,
|
||||||
text,
|
text,
|
||||||
from: 'user',
|
from: 'user',
|
||||||
|
timestamp,
|
||||||
|
messageId,
|
||||||
to: `${toTeam}.${leadName}`,
|
to: `${toTeam}.${leadName}`,
|
||||||
summary: summary ?? `Cross-team message to ${toTeam}`,
|
summary: summary ?? `Cross-team message to ${toTeam}`,
|
||||||
source: CROSS_TEAM_SENT_SOURCE,
|
source: CROSS_TEAM_SENT_SOURCE,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ import {
|
||||||
CROSS_TEAM_PREFIX_TAG,
|
CROSS_TEAM_PREFIX_TAG,
|
||||||
CROSS_TEAM_SENT_SOURCE,
|
CROSS_TEAM_SENT_SOURCE,
|
||||||
parseCrossTeamPrefix,
|
parseCrossTeamPrefix,
|
||||||
|
parseCrossTeamReplyPrefix,
|
||||||
|
stripCrossTeamPrefix,
|
||||||
} from '@shared/constants/crossTeam';
|
} from '@shared/constants/crossTeam';
|
||||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||||
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
||||||
|
|
@ -52,6 +54,7 @@ import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||||
import { TeamTaskReader } from './TeamTaskReader';
|
import { TeamTaskReader } from './TeamTaskReader';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
CrossTeamSendResult,
|
||||||
InboxMessage,
|
InboxMessage,
|
||||||
LeadContextUsage,
|
LeadContextUsage,
|
||||||
TeamChangeEvent,
|
TeamChangeEvent,
|
||||||
|
|
@ -72,6 +75,7 @@ import type {
|
||||||
|
|
||||||
const logger = createLogger('Service:TeamProvisioning');
|
const logger = createLogger('Service:TeamProvisioning');
|
||||||
const { createController } = agentTeamsControllerModule;
|
const { createController } = agentTeamsControllerModule;
|
||||||
|
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
||||||
const RUN_TIMEOUT_MS = 300_000;
|
const RUN_TIMEOUT_MS = 300_000;
|
||||||
const VERIFY_TIMEOUT_MS = 15_000;
|
const VERIFY_TIMEOUT_MS = 15_000;
|
||||||
const VERIFY_POLL_MS = 500;
|
const VERIFY_POLL_MS = 500;
|
||||||
|
|
@ -1067,6 +1071,19 @@ export class TeamProvisioningService {
|
||||||
private toolApprovalSettings: ToolApprovalSettings = DEFAULT_TOOL_APPROVAL_SETTINGS;
|
private toolApprovalSettings: ToolApprovalSettings = DEFAULT_TOOL_APPROVAL_SETTINGS;
|
||||||
private pendingTimeouts = new Map<string, NodeJS.Timeout>();
|
private pendingTimeouts = new Map<string, NodeJS.Timeout>();
|
||||||
private inFlightResponses = new Set<string>();
|
private inFlightResponses = new Set<string>();
|
||||||
|
private crossTeamSender:
|
||||||
|
| ((request: {
|
||||||
|
fromTeam: string;
|
||||||
|
fromMember: string;
|
||||||
|
toTeam: string;
|
||||||
|
text: string;
|
||||||
|
summary?: string;
|
||||||
|
messageId?: string;
|
||||||
|
timestamp?: string;
|
||||||
|
conversationId?: string;
|
||||||
|
replyToConversationId?: string;
|
||||||
|
}) => Promise<CrossTeamSendResult>)
|
||||||
|
| null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
||||||
|
|
@ -1076,6 +1093,24 @@ export class TeamProvisioningService {
|
||||||
private readonly mcpConfigBuilder: TeamMcpConfigBuilder = new TeamMcpConfigBuilder()
|
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<CrossTeamSendResult>)
|
||||||
|
| null
|
||||||
|
): void {
|
||||||
|
this.crossTeamSender = sender;
|
||||||
|
}
|
||||||
|
|
||||||
getClaudeLogs(
|
getClaudeLogs(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
query?: { offset?: number; limit?: number }
|
query?: { offset?: number; limit?: number }
|
||||||
|
|
@ -1186,6 +1221,21 @@ export class TeamProvisioningService {
|
||||||
this.teamChangeEmitter = emitter;
|
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 {
|
private persistSentMessage(teamName: string, message: InboxMessage): void {
|
||||||
try {
|
try {
|
||||||
createController({
|
createController({
|
||||||
|
|
@ -1200,6 +1250,8 @@ export class TeamProvisioningService {
|
||||||
messageId: message.messageId,
|
messageId: message.messageId,
|
||||||
source: message.source,
|
source: message.source,
|
||||||
leadSessionId: message.leadSessionId,
|
leadSessionId: message.leadSessionId,
|
||||||
|
conversationId: message.conversationId,
|
||||||
|
replyToConversationId: message.replyToConversationId,
|
||||||
attachments: message.attachments,
|
attachments: message.attachments,
|
||||||
color: message.color,
|
color: message.color,
|
||||||
toolSummary: message.toolSummary,
|
toolSummary: message.toolSummary,
|
||||||
|
|
@ -1224,6 +1276,8 @@ export class TeamProvisioningService {
|
||||||
messageId: message.messageId,
|
messageId: message.messageId,
|
||||||
source: message.source,
|
source: message.source,
|
||||||
leadSessionId: message.leadSessionId,
|
leadSessionId: message.leadSessionId,
|
||||||
|
conversationId: message.conversationId,
|
||||||
|
replyToConversationId: message.replyToConversationId,
|
||||||
attachments: message.attachments,
|
attachments: message.attachments,
|
||||||
color: message.color,
|
color: message.color,
|
||||||
toolSummary: message.toolSummary,
|
toolSummary: message.toolSummary,
|
||||||
|
|
@ -3016,17 +3070,91 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
const cleanContent = stripAgentBlocks(msgContent);
|
const cleanContent = stripAgentBlocks(msgContent);
|
||||||
if (cleanContent.trim().length === 0) continue;
|
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 = {
|
const msg: InboxMessage = {
|
||||||
from: leadName,
|
from: leadName,
|
||||||
to: recipient,
|
to: recipient,
|
||||||
text: cleanContent,
|
text: strippedCrossTeamContent,
|
||||||
timestamp: nowIso(),
|
timestamp: nowIso(),
|
||||||
read: recipient !== 'user',
|
read: recipient !== 'user',
|
||||||
summary:
|
summary:
|
||||||
(summary || cleanContent).length > 60
|
(summary || strippedCrossTeamContent).length > 60
|
||||||
? (summary || cleanContent).slice(0, 57) + '...'
|
? (summary || strippedCrossTeamContent).slice(0, 57) + '...'
|
||||||
: summary || cleanContent,
|
: summary || strippedCrossTeamContent,
|
||||||
messageId: `lead-sendmsg-${run.runId}-${Date.now()}`,
|
messageId: `lead-sendmsg-${run.runId}-${Date.now()}`,
|
||||||
source: 'lead_process',
|
source: 'lead_process',
|
||||||
};
|
};
|
||||||
|
|
@ -3094,9 +3222,10 @@ export class TeamProvisioningService {
|
||||||
const runId = this.activeByTeam.get(teamName);
|
const runId = this.activeByTeam.get(teamName);
|
||||||
if (!runId) return null;
|
if (!runId) return null;
|
||||||
const run = this.runs.get(runId);
|
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;
|
if (matches.length !== 1) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,15 @@ import { useCallback, useEffect, useMemo } from 'react';
|
||||||
import { isElectronMode } from '@renderer/api';
|
import { isElectronMode } from '@renderer/api';
|
||||||
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
||||||
import { formatBytes } from '@renderer/utils/formatters';
|
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';
|
import { SettingsSectionHeader } from '../components';
|
||||||
|
|
||||||
|
|
@ -66,15 +74,61 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{cliStatus.installed ? (
|
{cliStatus.installed ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div
|
<div className="flex items-center gap-2 text-sm">
|
||||||
className="flex items-center gap-2 text-sm"
|
|
||||||
style={{ color: 'var(--color-text)' }}
|
|
||||||
>
|
|
||||||
<Terminal
|
<Terminal
|
||||||
className="size-4 shrink-0"
|
className="size-4 shrink-0"
|
||||||
style={{ color: 'var(--color-text-muted)' }}
|
style={{ color: 'var(--color-text-muted)' }}
|
||||||
/>
|
/>
|
||||||
<span>Claude CLI v{cliStatus.installedVersion ?? 'unknown'}</span>
|
<span style={{ color: 'var(--color-text)' }}>
|
||||||
|
Claude CLI v{cliStatus.installedVersion ?? 'unknown'}
|
||||||
|
</span>
|
||||||
|
{/* Inline action buttons */}
|
||||||
|
{cliStatus.updateAvailable ? (
|
||||||
|
<button
|
||||||
|
onClick={handleInstall}
|
||||||
|
disabled={isBusy}
|
||||||
|
className="flex items-center gap-1.5 rounded-md px-3 py-1 text-xs font-medium text-white transition-colors disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: '#3b82f6' }}
|
||||||
|
>
|
||||||
|
<Download className="size-3.5" />
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={cliStatusLoading}
|
||||||
|
className="flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
borderColor: 'var(--color-border)',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cliStatusLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
Checking...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="size-3.5" />
|
||||||
|
Check for Updates
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Extensions button — right-aligned */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {}}
|
||||||
|
className="ml-auto flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-white/5"
|
||||||
|
style={{
|
||||||
|
borderColor: 'var(--color-border)',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Puzzle className="size-3.5" />
|
||||||
|
Extensions
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{cliStatus.binaryPath && (
|
{cliStatus.binaryPath && (
|
||||||
<p
|
<p
|
||||||
|
|
@ -103,54 +157,18 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Install button (CLI not installed) */}
|
||||||
<div className="flex gap-2">
|
{!cliStatus.installed && (
|
||||||
{!cliStatus.installed && (
|
<button
|
||||||
<button
|
onClick={handleInstall}
|
||||||
onClick={handleInstall}
|
disabled={isBusy}
|
||||||
disabled={isBusy}
|
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium text-white transition-colors disabled:opacity-50"
|
||||||
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium text-white transition-colors disabled:opacity-50"
|
style={{ backgroundColor: '#3b82f6' }}
|
||||||
style={{ backgroundColor: '#3b82f6' }}
|
>
|
||||||
>
|
<Download className="size-3.5" />
|
||||||
<Download className="size-3.5" />
|
Install Claude CLI
|
||||||
Install Claude CLI
|
</button>
|
||||||
</button>
|
)}
|
||||||
)}
|
|
||||||
{cliStatus.installed && cliStatus.updateAvailable && (
|
|
||||||
<button
|
|
||||||
onClick={handleInstall}
|
|
||||||
disabled={isBusy}
|
|
||||||
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium text-white transition-colors disabled:opacity-50"
|
|
||||||
style={{ backgroundColor: '#3b82f6' }}
|
|
||||||
>
|
|
||||||
<Download className="size-3.5" />
|
|
||||||
Update
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{cliStatus.installed && !cliStatus.updateAvailable && (
|
|
||||||
<button
|
|
||||||
onClick={handleRefresh}
|
|
||||||
disabled={cliStatusLoading}
|
|
||||||
className="flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
|
|
||||||
style={{
|
|
||||||
borderColor: 'var(--color-border)',
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{cliStatusLoading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="size-3.5 animate-spin" />
|
|
||||||
Checking...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RefreshCw className="size-3.5" />
|
|
||||||
Check for Updates
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||||
import {
|
import {
|
||||||
CROSS_TEAM_SENT_SOURCE,
|
CROSS_TEAM_SENT_SOURCE,
|
||||||
CROSS_TEAM_SOURCE,
|
CROSS_TEAM_SOURCE,
|
||||||
|
parseCrossTeamPrefix,
|
||||||
|
parseCrossTeamReplyPrefix,
|
||||||
stripCrossTeamPrefix,
|
stripCrossTeamPrefix,
|
||||||
} from '@shared/constants/crossTeam';
|
} from '@shared/constants/crossTeam';
|
||||||
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
|
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
|
||||||
|
|
@ -43,6 +45,19 @@ import type { InboxMessage } from '@shared/types';
|
||||||
|
|
||||||
type StructuredMessage = Record<string, unknown>;
|
type StructuredMessage = Record<string, unknown>;
|
||||||
|
|
||||||
|
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 {
|
interface ActivityItemProps {
|
||||||
message: InboxMessage;
|
message: InboxMessage;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -259,24 +274,36 @@ export const ActivityItem = ({
|
||||||
const isManaged = isManagedCollapseState(collapseState);
|
const isManaged = isManagedCollapseState(collapseState);
|
||||||
const isExpanded = isManaged ? !collapseState.isCollapsed : true;
|
const isExpanded = isManaged ? !collapseState.isCollapsed : true;
|
||||||
|
|
||||||
const isCrossTeam = message.source === CROSS_TEAM_SOURCE;
|
const parsedCrossTeamPrefix = useMemo(() => parseCrossTeamPrefix(message.text), [message.text]);
|
||||||
const isCrossTeamSent = message.source === CROSS_TEAM_SENT_SOURCE;
|
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 isCrossTeamAny = isCrossTeam || isCrossTeamSent;
|
||||||
const crossTeamOrigin = useMemo(() => {
|
const crossTeamOrigin = useMemo(() => {
|
||||||
if (!isCrossTeam) return null;
|
if (!isCrossTeam) return null;
|
||||||
const dot = message.from.indexOf('.');
|
const fromValue = parsedCrossTeamPrefix?.from ?? message.from;
|
||||||
if (dot <= 0 || dot === message.from.length - 1) return null;
|
const dot = fromValue.indexOf('.');
|
||||||
|
if (dot <= 0 || dot === fromValue.length - 1) return null;
|
||||||
return {
|
return {
|
||||||
teamName: message.from.substring(0, dot),
|
teamName: fromValue.substring(0, dot),
|
||||||
memberName: message.from.substring(dot + 1),
|
memberName: fromValue.substring(dot + 1),
|
||||||
};
|
};
|
||||||
}, [isCrossTeam, message.from]);
|
}, [isCrossTeam, message.from, parsedCrossTeamPrefix]);
|
||||||
const crossTeamTarget = useMemo(() => {
|
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('.');
|
const dot = message.to.indexOf('.');
|
||||||
if (dot <= 0) return message.to;
|
if (dot <= 0) return message.to;
|
||||||
return message.to.substring(0, dot);
|
return message.to.substring(0, dot);
|
||||||
}, [isCrossTeamSent, message.to]);
|
}, [isCrossTeamSent, message.to, qualifiedRecipient]);
|
||||||
|
|
||||||
// Strip agent-only blocks + normalize escape sequences (before linkification)
|
// Strip agent-only blocks + normalize escape sequences (before linkification)
|
||||||
const strippedText = useMemo(() => {
|
const strippedText = useMemo(() => {
|
||||||
|
|
@ -493,9 +520,9 @@ export const ActivityItem = ({
|
||||||
→
|
→
|
||||||
</span>
|
</span>
|
||||||
<MemberBadge
|
<MemberBadge
|
||||||
name={message.to}
|
name={qualifiedRecipient?.memberName ?? message.to}
|
||||||
color={recipientColor}
|
color={recipientColor}
|
||||||
hideAvatar={message.to === 'user'}
|
hideAvatar={(qualifiedRecipient?.memberName ?? message.to) === 'user'}
|
||||||
onClick={onMemberNameClick}
|
onClick={onMemberNameClick}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,9 @@ export function formatCrossTeamText(
|
||||||
export const CROSS_TEAM_PREFIX_RE =
|
export const CROSS_TEAM_PREFIX_RE =
|
||||||
/^\[Cross-team from (?<from>[^\]|]+?) \| depth:(?<depth>\d+)(?: \| conversation:(?<conversationId>[^\]|]+))?(?: \| replyTo:(?<replyToConversationId>[^\]|]+))?\]\n?/;
|
/^\[Cross-team from (?<from>[^\]|]+?) \| depth:(?<depth>\d+)(?: \| conversation:(?<conversationId>[^\]|]+))?(?: \| replyTo:(?<replyToConversationId>[^\]|]+))?\]\n?/;
|
||||||
|
|
||||||
|
export const CROSS_TEAM_REPLY_PREFIX_RE =
|
||||||
|
/^\[Cross-team reply(?: \| conversation:(?<conversationId>[^\]|]+))?(?: \| replyTo:(?<replyToConversationId>[^\]|]+))?\]\n?/;
|
||||||
|
|
||||||
/** Parse metadata from a cross-team prefix line. */
|
/** Parse metadata from a cross-team prefix line. */
|
||||||
export function parseCrossTeamPrefix(text: string): ParsedCrossTeamPrefix | null {
|
export function parseCrossTeamPrefix(text: string): ParsedCrossTeamPrefix | null {
|
||||||
const match = text.match(CROSS_TEAM_PREFIX_RE);
|
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). */
|
/** Strip the cross-team prefix from message text (for UI display). */
|
||||||
export function stripCrossTeamPrefix(text: string): string {
|
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 ────────────────────────────────────────────────────
|
// ── Source discriminators ────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -634,6 +634,8 @@ export interface CrossTeamSendRequest {
|
||||||
fromTeam: string;
|
fromTeam: string;
|
||||||
fromMember: string;
|
fromMember: string;
|
||||||
toTeam: string;
|
toTeam: string;
|
||||||
|
timestamp?: string;
|
||||||
|
messageId?: string;
|
||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
replyToConversationId?: string;
|
replyToConversationId?: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,10 @@ describe('CrossTeamService', () => {
|
||||||
expect(senderReq.source).toBe(CROSS_TEAM_SENT_SOURCE);
|
expect(senderReq.source).toBe(CROSS_TEAM_SENT_SOURCE);
|
||||||
expect(senderReq.to).toBe('team-b.team-lead');
|
expect(senderReq.to).toBe('team-b.team-lead');
|
||||||
expect(senderReq.text).toBe('Hello from team-a');
|
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();
|
expect(senderReq.conversationId).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
40
test/shared/constants/crossTeam.test.ts
Normal file
40
test/shared/constants/crossTeam.test.ts
Normal file
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue