From 2eb814bb70f77cebe32ce424eda57d2c6b52d826 Mon Sep 17 00:00:00 2001 From: iliya Date: Tue, 10 Mar 2026 12:22:10 +0200 Subject: [PATCH] feat: enhance team task handling and cross-team messaging - Updated TeamDataService to attach kanban compatibility with reviewer information for tasks. - Introduced new utility functions in TeamMemberResolver and TeamProvisioningService to handle cross-team pseudo recipients and improve member resolution. - Enhanced ActivityTimeline and Member components to display current and review tasks more effectively. - Added tests to validate the handling of cross-team inbox names and task assignments. - Improved MessageComposer to support action mode selection for lead recipients. --- src/main/services/team/TeamDataService.ts | 10 +- src/main/services/team/TeamMemberResolver.ts | 16 + .../services/team/TeamProvisioningService.ts | 33 +- .../team/activity/ActivityTimeline.tsx | 38 ++ .../team/activity/LeadThoughtsGroup.tsx | 9 + .../team/members/CurrentTaskIndicator.tsx | 4 +- .../components/team/members/MemberCard.tsx | 30 +- .../team/members/MemberHoverCard.tsx | 17 +- .../components/team/members/MemberList.tsx | 15 +- .../team/messages/ActionModeSelector.tsx | 98 +++ .../team/messages/MessageComposer.tsx | 565 +++++++++++------- .../components/ui/MentionableTextarea.tsx | 13 +- src/shared/types/team.ts | 2 + .../services/team/TeamMemberResolver.test.ts | 22 + ...eamProvisioningServiceLiveMessages.test.ts | 43 ++ .../TeamProvisioningServicePrompts.test.ts | 4 + .../team/TeamProvisioningServiceRelay.test.ts | 21 + 17 files changed, 689 insertions(+), 251 deletions(-) create mode 100644 src/renderer/components/team/messages/ActionModeSelector.tsx diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index fc5a3ce1..92903f18 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -111,12 +111,16 @@ export class TeamDataService { return normalizeReviewState(task.reviewState); } - private attachKanbanCompatibility(task: TeamTask): TeamTaskWithKanban { + private attachKanbanCompatibility( + task: TeamTask, + kanbanTaskState?: KanbanState['tasks'][string] + ): TeamTaskWithKanban { const reviewState = this.resolveTaskReviewState(task); return { ...task, reviewState, kanbanColumn: getKanbanColumnFromReviewState(reviewState), + reviewer: kanbanTaskState?.reviewer ?? null, }; } @@ -403,7 +407,7 @@ export class TeamDataService { mark('kanbanGc'); const tasksWithKanban: TeamTaskWithKanban[] = tasks.map((task) => - this.attachKanbanCompatibility(task) + this.attachKanbanCompatibility(task, kanbanState.tasks[task.id]) ); const members = this.memberResolver.resolveMembers( @@ -422,7 +426,7 @@ export class TeamDataService { mark('syncComments'); const tasksToReturn: TeamTaskWithKanban[] = tasks.map((task) => - this.attachKanbanCompatibility(task) + this.attachKanbanCompatibility(task, kanbanState.tasks[task.id]) ); let processes: TeamProcess[] = []; diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index 34fe0880..65878ac5 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -19,6 +19,19 @@ function looksLikeQualifiedExternalRecipient(name: string): boolean { return TEAM_NAME_PATTERN.test(teamName) && memberName.length > 0; } +function looksLikeCrossTeamPseudoRecipient(name: string): boolean { + const trimmed = name.trim(); + if (trimmed.startsWith('cross-team:')) { + const teamName = trimmed.slice('cross-team:'.length).trim(); + return TEAM_NAME_PATTERN.test(teamName); + } + if (trimmed.startsWith('cross-team-')) { + const teamName = trimmed.slice('cross-team-'.length).trim(); + return TEAM_NAME_PATTERN.test(teamName); + } + return false; +} + export class TeamMemberResolver { resolveMembers( config: TeamConfig, @@ -62,6 +75,9 @@ export class TeamMemberResolver { for (const inboxName of inboxNames) { if (typeof inboxName === 'string' && inboxName.trim() !== '') { const trimmed = inboxName.trim(); + if (looksLikeCrossTeamPseudoRecipient(trimmed)) { + continue; + } if ( !explicitNames.has(trimmed.toLowerCase()) && looksLikeQualifiedExternalRecipient(trimmed) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index e77fa0de..bb77bdfe 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -902,11 +902,13 @@ ${persistentContext} Steps (execute in this exact order): -1) Read team config at ~/.claude/teams/${request.teamName}/config.json — understand current team state. +1) Restore/start the existing teammates first. Do NOT delay this reconnect turn by reading internal config files before teammates are back online. ${step2And3Block} -4) After all steps, output a short summary of reconnected members and what happens next. +4) If something about team state looks unclear or inconsistent, you MAY inspect ~/.claude/teams/${request.teamName}/config.json after teammates are restored (or immediately in solo mode). Treat it as a diagnostic cross-check, not as the first reconnect action. + +5) After all steps, output a short summary of reconnected members and what happens next. `; } @@ -1229,6 +1231,13 @@ export class TeamProvisioningService { ): { teamName: string; memberName: string } | null { const trimmed = recipient.trim(); if (localRecipientNames.has(trimmed)) return null; + if (trimmed.startsWith('cross-team:')) { + const teamName = trimmed.slice('cross-team:'.length).trim(); + if (!TEAM_NAME_PATTERN.test(teamName) || teamName === currentTeam) { + return null; + } + return { teamName, memberName: 'team-lead' }; + } const dot = trimmed.indexOf('.'); if (dot <= 0 || dot === trimmed.length - 1) return null; const teamName = trimmed.slice(0, dot).trim(); @@ -1239,6 +1248,19 @@ export class TeamProvisioningService { return { teamName, memberName }; } + private isCrossTeamPseudoRecipientName(name: string): boolean { + const trimmed = name.trim(); + if (trimmed.startsWith('cross-team:')) { + const teamName = trimmed.slice('cross-team:'.length).trim(); + return TEAM_NAME_PATTERN.test(teamName); + } + if (trimmed.startsWith('cross-team-')) { + const teamName = trimmed.slice('cross-team-'.length).trim(); + return TEAM_NAME_PATTERN.test(teamName); + } + return false; + } + private persistSentMessage(teamName: string, message: InboxMessage): void { try { createController({ @@ -2561,6 +2583,9 @@ export class TeamProvisioningService { } async relayMemberInboxMessages(teamName: string, memberName: string): Promise { + if (this.isCrossTeamPseudoRecipientName(memberName)) { + return 0; + } const relayKey = this.getMemberRelayKey(teamName, memberName); const existing = this.memberInboxRelayInFlight.get(relayKey); if (existing) { @@ -3155,7 +3180,9 @@ export class TeamProvisioningService { } const msg: InboxMessage = { from: 'user', - to: `${crossTeamRecipient.teamName}.${crossTeamRecipient.memberName}`, + to: recipient.startsWith('cross-team:') + ? recipient + : `${crossTeamRecipient.teamName}.${crossTeamRecipient.memberName}`, text: strippedCrossTeamContent, timestamp, read: true, diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index 068fe139..a40c1b33 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; +import { Layers } from 'lucide-react'; import { ActivityItem, isNoiseMessage } from './ActivityItem'; import { AnimatedHeightReveal } from './AnimatedHeightReveal'; @@ -9,6 +10,7 @@ import { findNewestMessageIndex, resolveTimelineCollapseState } from './collapse import { getThoughtGroupKey, groupTimelineItems, + isCompactionMessage, isLeadThought, LeadThoughtsGroupRow, } from './LeadThoughtsGroup'; @@ -55,6 +57,29 @@ interface ActivityTimelineProps { const VIEWPORT_THRESHOLD = 0.15; const MESSAGES_PAGE_SIZE = 30; +/** Inline compaction boundary divider — styled like session separators but with amber accent. */ +const CompactionDivider = ({ message }: { message: InboxMessage }): React.JSX.Element => ( +
+
+
+ + + {message.text} + +
+
+
+); + const MessageRowWithObserver = ({ message, teamName, @@ -239,6 +264,7 @@ export const ActivityTimeline = ({ cardCount++; } else { if (isNoiseMessage(item.message.text)) continue; + if (isCompactionMessage(item.message)) continue; if (cardCount % 2 === 1) result.add(i); cardCount++; } @@ -420,6 +446,18 @@ export const ActivityTimeline = ({ } const { message } = item; + + // Compaction boundary — render as a divider instead of a regular message card + if (isCompactionMessage(message)) { + const messageKey = toMessageKey(message); + return ( + + {sessionSeparator} + + + ); + } + const info = memberInfo.get(message.from); const recipientInfo = message.to ? memberInfo.get(message.to) : undefined; const recipientColor = diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 408b8c87..93c92446 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -36,12 +36,21 @@ export interface LeadThoughtGroup { thoughts: InboxMessage[]; } +/** + * Check if a message is a context compaction boundary (system event from lead process). + */ +export function isCompactionMessage(msg: InboxMessage): boolean { + return msg.from === 'system' && !!msg.messageId?.startsWith('compact-'); +} + /** * Check if a message is an intermediate lead "thought" (assistant text) rather than * an official message (SendMessage, direct reply, inbox, etc.). */ export function isLeadThought(msg: InboxMessage): boolean { if (typeof msg.to === 'string' && msg.to.trim().length > 0) return false; + // Compaction boundary events are system messages, not lead thoughts + if (isCompactionMessage(msg)) return false; if (msg.source === 'lead_session') return true; if (msg.source === 'lead_process') return true; return false; diff --git a/src/renderer/components/team/members/CurrentTaskIndicator.tsx b/src/renderer/components/team/members/CurrentTaskIndicator.tsx index eb3653e2..e4df93f6 100644 --- a/src/renderer/components/team/members/CurrentTaskIndicator.tsx +++ b/src/renderer/components/team/members/CurrentTaskIndicator.tsx @@ -8,6 +8,7 @@ interface CurrentTaskIndicatorProps { borderColor: string; /** Max characters for the subject before truncating */ maxSubjectLength?: number; + activityLabel?: string; onOpenTask?: () => void; } @@ -19,6 +20,7 @@ export const CurrentTaskIndicator = ({ task, borderColor, maxSubjectLength = 36, + activityLabel = 'working on', onOpenTask, }: CurrentTaskIndicatorProps): React.JSX.Element => { const truncated = task.subject.length > maxSubjectLength; @@ -27,7 +29,7 @@ export const CurrentTaskIndicator = ({ return ( <> - working on + {activityLabel} + + + {cfg.tooltip} + + + ); + })} +
+ + ); +}; diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 2a47f6ce..923bc278 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList'; import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay'; +import { ActionModeSelector } from '@renderer/components/team/messages/ActionModeSelector'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; @@ -18,6 +19,7 @@ import { MAX_TEXT_LENGTH } from '@shared/constants'; import { AlertCircle, Check, ChevronDown, ImagePlus, Mic, Search, Send } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; +import type { ActionMode } from '@renderer/components/team/messages/ActionModeSelector'; import type { AttachmentPayload, ResolvedTeamMember, SendMessageResult } from '@shared/types'; interface MessageComposerProps { @@ -58,6 +60,7 @@ export const MessageComposer = ({ const fileInputRef = useRef(null); const [imageRestrictionError, setImageRestrictionError] = useState(null); const imageRestrictionTimerRef = useRef(0); + const [actionMode, setActionMode] = useState('do'); // Cross-team state const [selectedTeam, setSelectedTeam] = useState(null); @@ -128,6 +131,15 @@ export const MessageComposer = ({ const selectedMember = members.find((m) => m.name === recipient); const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined; const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead'; + + // Auto-select delegate when lead recipient changes, reset when non-lead + useEffect(() => { + if (isLeadRecipient) { + setActionMode('delegate'); + } else { + setActionMode((prev) => (prev === 'delegate' ? 'do' : prev)); + } + }, [isLeadRecipient]); // NOTE: lead context ring disabled — usage formula is inaccurate // const isLeadAgentRecipient = selectedMember?.agentType === 'team-lead'; // const leadContext = useStore((s) => @@ -351,246 +363,346 @@ export const MessageComposer = ({ ) : null} - {/* Cross-team selector */} + {/* Combined team + member selector */} {crossTeamTargets.length > 0 ? ( - - - + + +
+ {/* Current team option */} + + + {/* Separator */} +
+ + {/* Other teams */} + {crossTeamTargets.map((target) => { + const isSelected = selectedTeam === target.teamName; + return ( + + ); + })} +
+ + + + + + + + { + e.preventDefault(); + setRecipientSearch(''); + setTimeout(() => recipientSearchRef.current?.focus(), 0); + }} + > + {members.length > 5 && ( +
+ + setRecipientSearch(e.target.value)} + /> +
+ )} +
+ {/* eslint-disable-next-line sonarjs/function-return-type -- IIFE rendering mixed elements/null */} + {(() => { + const query = recipientSearch.toLowerCase().trim(); + const filtered = query + ? members.filter((m) => m.name.toLowerCase().includes(query)) + : members; + if (filtered.length === 0) { + return ( +
+ No results +
+ ); + } + const sorted = [...filtered].sort((a, b) => { + const aIsLead = a.role === 'lead' || a.name === 'team-lead' ? 1 : 0; + const bIsLead = b.role === 'lead' || b.name === 'team-lead' ? 1 : 0; + return bIsLead - aIsLead; + }); + return sorted.map((m) => { + const resolvedColor = colorMap.get(m.name); + const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType); + const isSelected = m.name === recipient; + return ( + + ); + }); + })()} +
+
+
+
+ ) : ( + + + - + { + e.preventDefault(); + setRecipientSearch(''); + setTimeout(() => recipientSearchRef.current?.focus(), 0); + }} + > + {members.length > 5 && ( +
+ + setRecipientSearch(e.target.value)} + /> +
+ )}
- {/* Current team option */} - - - {/* Separator */} -
- - {/* Other teams */} - {crossTeamTargets.map((target) => { - const isSelected = selectedTeam === target.teamName; - return ( - - ); - })} + ); + } + const sorted = [...filtered].sort((a, b) => { + const aIsLead = a.role === 'lead' || a.name === 'team-lead' ? 1 : 0; + const bIsLead = b.role === 'lead' || b.name === 'team-lead' ? 1 : 0; + return bIsLead - aIsLead; + }); + return sorted.map((m) => { + const resolvedColor = colorMap.get(m.name); + const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType); + const isSelected = m.name === recipient; + return ( + + ); + }); + })()}
- ) : null} - - - - - - { - e.preventDefault(); - setRecipientSearch(''); - setTimeout(() => recipientSearchRef.current?.focus(), 0); - }} - > - {members.length > 5 && ( -
- - setRecipientSearch(e.target.value)} - /> -
- )} -
- {/* eslint-disable-next-line sonarjs/function-return-type -- IIFE rendering mixed elements/null */} - {(() => { - const query = recipientSearch.toLowerCase().trim(); - const filtered = query - ? members.filter((m) => m.name.toLowerCase().includes(query)) - : members; - if (filtered.length === 0) { - return ( -
- No results -
- ); - } - const sorted = [...filtered].sort((a, b) => { - const aIsLead = a.role === 'lead' || a.name === 'team-lead' ? 1 : 0; - const bIsLead = b.role === 'lead' || b.name === 'team-lead' ? 1 : 0; - return bIsLead - aIsLead; - }); - return sorted.map((m) => { - const resolvedColor = colorMap.get(m.name); - const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType); - const isSelected = m.name === recipient; - return ( - - ); - }); - })()} -
-
-
+ )}
@@ -616,6 +728,13 @@ export const MessageComposer = ({ maxLength={MAX_TEXT_LENGTH} disabled={sending} hintText={crossTeamHintText} + cornerActionLeft={ + + } cornerAction={
{/* NOTE: ContextRing disabled — usage formula is inaccurate */} diff --git a/src/renderer/components/ui/MentionableTextarea.tsx b/src/renderer/components/ui/MentionableTextarea.tsx index c3739579..8b46c6df 100644 --- a/src/renderer/components/ui/MentionableTextarea.tsx +++ b/src/renderer/components/ui/MentionableTextarea.tsx @@ -197,6 +197,8 @@ interface MentionableTextareaProps extends Omit< footerRight?: React.ReactNode; /** Content rendered in the bottom-right corner inside the textarea (e.g. send button) */ cornerAction?: React.ReactNode; + /** Content rendered in the bottom-left corner inside the textarea (e.g. mode selector) */ + cornerActionLeft?: React.ReactNode; /** Inline code chips to display as badges */ chips?: InlineChip[]; /** Called when a chip is removed (by X button, backspace, or reconciliation) */ @@ -219,6 +221,7 @@ export const MentionableTextarea = React.forwardRef @@ -720,6 +723,12 @@ export const MentionableTextarea = React.forwardRef{cornerAction}
) : null} + + {cornerActionLeft ? ( +
+
{cornerActionLeft}
+
+ ) : null} {showFooter ? ( diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index e6b745fe..e9ca17a5 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -175,6 +175,8 @@ export interface TeamTask { export interface TeamTaskWithKanban extends TeamTask { /** Set when task is in team kanban (review or approved column). */ kanbanColumn?: 'review' | 'approved'; + /** Reviewer assigned in kanban state, when applicable. */ + reviewer?: string | null; } /** Metadata for an attachment associated with a task or comment. */ diff --git a/test/main/services/team/TeamMemberResolver.test.ts b/test/main/services/team/TeamMemberResolver.test.ts index ba4d217e..b819fc99 100644 --- a/test/main/services/team/TeamMemberResolver.test.ts +++ b/test/main/services/team/TeamMemberResolver.test.ts @@ -107,6 +107,28 @@ describe('TeamMemberResolver', () => { expect(names).toContain('ops.bot'); }); + it('ignores pseudo cross-team inbox names', () => { + const resolver = new TeamMemberResolver(); + const config: TeamConfig = { + name: 'Team', + members: [{ name: 'team-lead', agentType: 'team-lead', role: 'lead' }], + }; + + const members = resolver.resolveMembers( + config, + [], + ['cross-team:team-alpha-super', 'cross-team-team-alpha-super', 'alice'], + [], + [] + ); + const names = members.map((m) => m.name); + + expect(names).toContain('alice'); + expect(names).toContain('team-lead'); + expect(names).not.toContain('cross-team:team-alpha-super'); + expect(names).not.toContain('cross-team-team-alpha-super'); + }); + it('keeps dotted names when config casing differs from inbox casing', () => { const resolver = new TeamMemberResolver(); const config: TeamConfig = { diff --git a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts index 2e2f4008..b556f94a 100644 --- a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts +++ b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts @@ -472,6 +472,49 @@ describe('TeamProvisioningService pre-ready live messages', () => { expect(hoisted.appendSentMessage).not.toHaveBeenCalled(); }); + it('upgrades pseudo cross-team recipients into cross-team sends', async () => { + const service = new TeamProvisioningService(); + seedConfig('my-team'); + const crossTeamSender = vi.fn(async () => ({ deliveredToInbox: true, messageId: 'cross-2' })); + 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: 'cross-team:team-best', + content: 'Привет команде!', + summary: 'Приветствие', + }, + }, + ], + }); + + await vi.waitFor(() => { + expect(crossTeamSender).toHaveBeenCalledTimes(1); + }); + + expect(crossTeamSender).toHaveBeenCalledWith( + expect.objectContaining({ + fromTeam: 'my-team', + fromMember: 'team-lead', + toTeam: 'team-best', + text: 'Привет команде!', + }) + ); + + const live = service.getLiveLeadProcessMessages('my-team'); + expect(live).toHaveLength(1); + expect(live[0].source).toBe('cross_team_sent'); + expect(live[0].to).toBe('cross-team:team-best'); + expect(hoisted.sendInboxMessage).not.toHaveBeenCalled(); + }); + it('does not push a duplicate live row when cross-team fallback deduplicates', async () => { const service = new TeamProvisioningService(); seedConfig('my-team'); diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts index 95912b7c..9a7fd6e7 100644 --- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts @@ -175,6 +175,8 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => expect(prompt).toContain('SOLO MODE: This team CURRENTLY has ZERO teammates.'); expect(prompt).toContain('Execute tasks sequentially and keep the board + user updated'); expect(prompt).toContain('Do NOT start the next task until the current task is completed'); + expect(prompt).toContain('Do NOT delay this reconnect turn by reading internal config files'); + expect(prompt).toContain('Treat it as a diagnostic cross-check, not as the first reconnect action.'); expect(prompt).toContain('task_start'); expect(prompt).toContain(`AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}`); expect(prompt).toContain(`AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`); @@ -277,6 +279,8 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => const prompt = extractPromptFromWrite(writeSpy); expect(prompt).toContain('The team has been reconnected after a restart.'); + expect(prompt).toContain('Restore/start the existing teammates first.'); + expect(prompt).toContain('Treat it as a diagnostic cross-check, not as the first reconnect action.'); expect(prompt).toContain('Hidden internal instructions rule (IMPORTANT):'); expect(prompt).toContain(` ${AGENT_BLOCK_OPEN}`); expect(prompt).toContain(` ${AGENT_BLOCK_CLOSE}`); diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index ba7f5cf9..7c41fa9d 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -434,4 +434,25 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { expect(payload).toContain('recipient=\\"alice\\"'); expect(payload).toContain('Please retry with logging enabled.'); }); + + it('does not relay pseudo cross-team member inboxes as teammates', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + seedConfig(teamName); + seedMemberInbox(teamName, 'cross-team:team-alpha-super', [ + { + from: 'team-lead', + text: 'Stale pseudo recipient inbox', + timestamp: '2026-02-23T10:00:00.000Z', + read: false, + messageId: 'm-pseudo-1', + }, + ]); + + const { writeSpy } = attachAliveRun(service, teamName); + const relayed = await service.relayMemberInboxMessages(teamName, 'cross-team:team-alpha-super'); + + expect(relayed).toBe(0); + expect(writeSpy).toHaveBeenCalledTimes(0); + }); });