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,
|
||||
teamProvisioningService
|
||||
);
|
||||
teamProvisioningService.setCrossTeamSender((request) => crossTeamService.send(request));
|
||||
|
||||
const teamMemberLogsFinder = new TeamMemberLogsFinder();
|
||||
const memberStatsComputer = new MemberStatsComputer(teamMemberLogsFinder);
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ export class CrossTeamService {
|
|||
async send(request: CrossTeamSendRequest): Promise<CrossTeamSendResult> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<string, NodeJS.Timeout>();
|
||||
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(
|
||||
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<CrossTeamSendResult>)
|
||||
| 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 {
|
||||
|
|
|
|||
|
|
@ -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 => {
|
|||
<div className="space-y-2">
|
||||
{cliStatus.installed ? (
|
||||
<div className="space-y-1">
|
||||
<div
|
||||
className="flex items-center gap-2 text-sm"
|
||||
style={{ color: 'var(--color-text)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Terminal
|
||||
className="size-4 shrink-0"
|
||||
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>
|
||||
{cliStatus.binaryPath && (
|
||||
<p
|
||||
|
|
@ -103,54 +157,18 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2">
|
||||
{!cliStatus.installed && (
|
||||
<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" />
|
||||
Install Claude CLI
|
||||
</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>
|
||||
{/* Install button (CLI not installed) */}
|
||||
{!cliStatus.installed && (
|
||||
<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" />
|
||||
Install Claude CLI
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
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 = ({
|
|||
→
|
||||
</span>
|
||||
<MemberBadge
|
||||
name={message.to}
|
||||
name={qualifiedRecipient?.memberName ?? message.to}
|
||||
color={recipientColor}
|
||||
hideAvatar={message.to === 'user'}
|
||||
hideAvatar={(qualifiedRecipient?.memberName ?? message.to) === 'user'}
|
||||
onClick={onMemberNameClick}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -51,6 +51,9 @@ export function formatCrossTeamText(
|
|||
export const CROSS_TEAM_PREFIX_RE =
|
||||
/^\[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. */
|
||||
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 ────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -634,6 +634,8 @@ export interface CrossTeamSendRequest {
|
|||
fromTeam: string;
|
||||
fromMember: string;
|
||||
toTeam: string;
|
||||
timestamp?: string;
|
||||
messageId?: string;
|
||||
conversationId?: string;
|
||||
replyToConversationId?: string;
|
||||
text: string;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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