diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index f9bf1e3b..0712460b 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -339,6 +339,8 @@ export class TeamDataService { }); } + this.ensureStableMessageIds(messages); + // Enrich inbox messages without leadSessionId by assigning the nearest neighbor's // session ID (by timestamp). This avoids the old forward-only propagation bug where // messages between two sessions always inherited the *earlier* session, causing a @@ -1114,6 +1116,66 @@ export class TeamDataService { return normalized === leadName.trim().toLowerCase() || normalized === 'team-lead'; } + private normalizeMessageIdPart(value: string | undefined, fallback = 'unknown'): string { + const normalized = (value ?? '') + .trim() + .replace(/\r\n/g, '\n') + .replace(/\s+/g, '-') + .replace(/[^\p{L}\p{N}_-]/gu, '') + .slice(0, 40); + return normalized || fallback; + } + + /** + * Older inbox/sent-message records may not include messageId. Assign deterministic ids + * so renderer keys remain stable across refreshes, filtering, and live updates. + */ + private ensureStableMessageIds(messages: InboxMessage[]): void { + const seenAssignedIds = new Set(); + const missingIdOccurrences = new Map(); + + for (const message of messages) { + const existingId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + if (existingId) { + seenAssignedIds.add(existingId); + continue; + } + + const textPrefix = this.normalizeMessageIdPart(message.text?.slice(0, 80), 'empty'); + const fingerprint = [ + message.source ?? 'unknown', + message.timestamp, + message.from, + message.to ?? '', + message.leadSessionId ?? '', + textPrefix, + ].join('\0'); + const occurrence = (missingIdOccurrences.get(fingerprint) ?? 0) + 1; + missingIdOccurrences.set(fingerprint, occurrence); + + let syntheticId = [ + 'synthetic-msg', + this.normalizeMessageIdPart(message.source, 'unknown'), + this.normalizeMessageIdPart(message.timestamp, 'unknown'), + this.normalizeMessageIdPart(message.from, 'unknown'), + this.normalizeMessageIdPart(message.to, 'none'), + textPrefix, + occurrence, + ].join('-'); + + if (seenAssignedIds.has(syntheticId)) { + let collision = 2; + while (seenAssignedIds.has(`${syntheticId}-dup${collision}`)) { + collision++; + } + syntheticId = `${syntheticId}-dup${collision}`; + } + + message.messageId = syntheticId; + seenAssignedIds.add(syntheticId); + } + } + async sendDirectToLead( teamName: string, leadName: string, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index e74ebc23..4fa391b8 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -431,6 +431,18 @@ function buildCompactMembersRoster(members: TeamCreateRequest['members']): strin .join('\n'); } +function buildTeammateAgentBlockReminder(): string { + return [ + `Hidden internal instructions rule (IMPORTANT):`, + `- If you send internal operational instructions to another agent/teammate that the human user must NOT see in the UI, wrap ONLY that hidden part in:`, + ` ${AGENT_BLOCK_OPEN}`, + ` ... hidden instructions only ...`, + ` ${AGENT_BLOCK_CLOSE}`, + `- Keep normal human-readable coordination outside the block.`, + `- NEVER use agent-only blocks in messages to "user".`, + ].join('\n'); +} + function buildMemberSpawnPrompt( member: TeamCreateRequest['members'][number], displayName: string, @@ -447,6 +459,7 @@ function buildMemberSpawnPrompt( ${getAgentLanguageInstruction()} Introduce yourself briefly (name and role) and confirm you are ready. Then wait for task assignments. +${buildTeammateAgentBlockReminder()} Include the following agent-only instructions verbatim in the prompt: ${taskProtocol} @@ -898,6 +911,7 @@ function buildLaunchPrompt( ${languageInstruction} The team has been reconnected after a restart. ${hasTasks ? `You have pending tasks from the previous session.` : 'You have no pending tasks currently.'} + ${buildTeammateAgentBlockReminder()} Your FIRST action: call MCP tool task_briefing with: { teamName: "${request.teamName}", memberName: "${m.name}" } diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index 5f3d965e..227366b0 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -6,7 +6,12 @@ import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { ActivityItem, isNoiseMessage } from './ActivityItem'; import { AnimatedHeightReveal } from './AnimatedHeightReveal'; import { findNewestMessageIndex, resolveTimelineCollapseState } from './collapseState'; -import { groupTimelineItems, isLeadThought, LeadThoughtsGroupRow } from './LeadThoughtsGroup'; +import { + getThoughtGroupKey, + groupTimelineItems, + isLeadThought, + LeadThoughtsGroupRow, +} from './LeadThoughtsGroup'; import { useNewItemKeys } from './useNewItemKeys'; import type { ActivityCollapseState } from './collapseState'; @@ -207,11 +212,12 @@ export const ActivityTimeline = ({ // Group consecutive lead thoughts into collapsible blocks. const timelineItems = useMemo(() => groupTimelineItems(visibleMessages), [visibleMessages]); - // Zebra striping: alternate shade on non-noise (full card) items only. + // Zebra striping is anchored from the bottom of the visible list so prepending + // new live messages at the top does not recolor every existing card. const zebraShadeSet = useMemo(() => { const result = new Set(); let cardCount = 0; - for (let i = 0; i < timelineItems.length; i++) { + for (let i = timelineItems.length - 1; i >= 0; i--) { const item = timelineItems[i]; if (item.type === 'lead-thoughts') { // Thought groups count as one card for striping @@ -229,11 +235,9 @@ export const ActivityTimeline = ({ const timelineItemKeys = useMemo(() => { const getItemKey = (item: TimelineItem): string => { if (item.type === 'lead-thoughts') { - // Stable key: identify group by its first thought, not by count (which changes) - return `thoughts-${item.group.thoughts[0].messageId ?? item.originalIndices[0]}`; + return getThoughtGroupKey(item.group); } - const msg = item.message; - return `${msg.messageId ?? item.originalIndex}-${msg.timestamp}-${msg.from}`; + return toMessageKey(item.message); }; return timelineItems.map(getItemKey); @@ -245,6 +249,22 @@ export const ActivityTimeline = ({ resetKey: teamName, }); + useEffect(() => { + if (process.env.NODE_ENV === 'production') return; + const seen = new Set(); + const duplicates = new Set(); + for (const key of timelineItemKeys) { + if (seen.has(key)) duplicates.add(key); + seen.add(key); + } + if (duplicates.size > 0) { + console.warn('[ActivityTimeline] Duplicate timeline item keys detected', { + teamName, + duplicates: [...duplicates], + }); + } + }, [teamName, timelineItemKeys]); + const handleShowMore = (): void => { setVisibleCount((prev) => prev + MESSAGES_PAGE_SIZE); }; @@ -304,8 +324,8 @@ export const ActivityTimeline = ({ const { group } = pinnedThoughtGroup; const firstThought = group.thoughts[0]; const info = memberInfo.get(firstThought.from); - const itemKey = `thoughts-${firstThought.messageId ?? pinnedThoughtGroup.originalIndices[0]}`; - const stableKey = toMessageKey(firstThought); + const itemKey = getThoughtGroupKey(group); + const stableKey = itemKey; const collapseState = getItemCollapseState(stableKey, 0); return ( @@ -380,8 +400,8 @@ export const ActivityTimeline = ({ const recipientInfo = message.to ? memberInfo.get(message.to) : undefined; const recipientColor = recipientInfo?.color ?? (message.to ? colorMap.get(message.to) : undefined); - const messageKey = `${message.messageId ?? item.originalIndex}-${message.timestamp}-${message.from}`; - const stableKey = toMessageKey(message); + const messageKey = toMessageKey(message); + const stableKey = messageKey; const collapseState = getItemCollapseState(stableKey, realIndex); const isUnread = readState ? !message.read && !readState.readSet.has(readState.getMessageKey(message)) diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 5e4caf43..83025af7 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -14,6 +14,7 @@ import { import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useStore } from '@renderer/store'; import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; +import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary'; import { ChevronDown, ChevronRight, ChevronUp, Reply } from 'lucide-react'; @@ -44,8 +45,17 @@ export function isLeadThought(msg: InboxMessage): boolean { } export type TimelineItem = - | { type: 'message'; message: InboxMessage; originalIndex: number } - | { type: 'lead-thoughts'; group: LeadThoughtGroup; originalIndices: number[] }; + | { type: 'message'; message: InboxMessage } + | { type: 'lead-thoughts'; group: LeadThoughtGroup }; + +/** + * Use the oldest thought as the group's stable identity so live thoughts can prepend + * without remounting the whole group on every update. + */ +export function getThoughtGroupKey(group: LeadThoughtGroup): string { + const oldestThought = group.thoughts[group.thoughts.length - 1]; + return `thoughts-${toMessageKey(oldestThought)}`; +} /** * Group consecutive lead thoughts into compact blocks. @@ -54,7 +64,6 @@ export type TimelineItem = export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] { const result: TimelineItem[] = []; let pendingThoughts: InboxMessage[] = []; - let pendingIndices: number[] = []; const hasSameLeadSession = (a: InboxMessage, b: InboxMessage): boolean => (a.leadSessionId ?? null) === (b.leadSessionId ?? null); @@ -63,24 +72,20 @@ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] { result.push({ type: 'lead-thoughts', group: { type: 'lead-thoughts', thoughts: pendingThoughts }, - originalIndices: pendingIndices, }); pendingThoughts = []; - pendingIndices = []; }; - for (let i = 0; i < messages.length; i++) { - const msg = messages[i]; + for (const msg of messages) { if (isLeadThought(msg)) { const previousThought = pendingThoughts[pendingThoughts.length - 1]; if (previousThought && !hasSameLeadSession(previousThought, msg)) { flushThoughts(); } pendingThoughts.push(msg); - pendingIndices.push(i); } else { flushThoughts(); - result.push({ type: 'message', message: msg, originalIndex: i }); + result.push({ type: 'message', message: msg }); } } flushThoughts(); @@ -756,7 +761,7 @@ export const LeadThoughtsGroupRow = ({
{chronologicalThoughts.map((thought, idx) => ( 0} shouldAnimate={isLive && idx === chronologicalThoughts.length - 1} diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts index bdb5956b..9d366bbd 100644 --- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts @@ -187,4 +187,93 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => await svc.cancelProvisioning(runId); }); + + it('createTeam prompt for teammates includes explicit hidden-instruction block rules', async () => { + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude'); + const { child, writeSpy } = createFakeChild(); + vi.mocked(spawnCli).mockReturnValue(child as any); + + const svc = new TeamProvisioningService(); + (svc as any).buildProvisioningEnv = vi.fn(async () => ({ + env: { ANTHROPIC_API_KEY: 'test' }, + authSource: 'anthropic_api_key', + })); + (svc as any).startFilesystemMonitor = vi.fn(); + (svc as any).pathExists = vi.fn(async () => false); + + const { runId } = await svc.createTeam( + { + teamName: 'multi-team', + cwd: process.cwd(), + members: [{ name: 'alice', role: 'developer' }], + description: 'Multi team prompt test', + }, + () => {} + ); + + const prompt = extractPromptFromWrite(writeSpy); + expect(prompt).toContain('Hidden internal instructions rule (IMPORTANT):'); + expect(prompt).toContain(` ${AGENT_BLOCK_OPEN}`); + expect(prompt).toContain(` ${AGENT_BLOCK_CLOSE}`); + expect(prompt).toContain('NEVER use agent-only blocks in messages to "user".'); + + await svc.cancelProvisioning(runId); + }); + + it('launchTeam reconnect prompt for teammates includes explicit hidden-instruction block rules', async () => { + const teamName = 'multi-team-launch'; + const teamDir = path.join(tempTeamsBase, teamName); + fs.mkdirSync(teamDir, { recursive: true }); + fs.writeFileSync( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: teamName, + description: 'Multi team prompt test', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'alice', agentType: 'teammate', role: 'developer' }, + ], + }), + 'utf8' + ); + + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude'); + const { child, writeSpy } = createFakeChild(); + vi.mocked(spawnCli).mockReturnValue(child as any); + + const svc = new TeamProvisioningService(); + (svc as any).buildProvisioningEnv = vi.fn(async () => ({ + env: { ANTHROPIC_API_KEY: 'test' }, + authSource: 'anthropic_api_key', + })); + (svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {}); + (svc as any).updateConfigProjectPath = vi.fn(async () => {}); + (svc as any).restorePrelaunchConfig = vi.fn(async () => {}); + (svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {}); + (svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({ + members: [{ name: 'alice', role: 'developer' }], + source: 'config-fallback', + warning: undefined, + })); + (svc as any).pathExists = vi.fn(async () => false); + (svc as any).startFilesystemMonitor = vi.fn(); + + const { runId } = await svc.launchTeam( + { + teamName, + cwd: process.cwd(), + clearContext: true, + } as any, + () => {} + ); + + const prompt = extractPromptFromWrite(writeSpy); + expect(prompt).toContain('The team has been reconnected after a restart.'); + expect(prompt).toContain('Hidden internal instructions rule (IMPORTANT):'); + expect(prompt).toContain(` ${AGENT_BLOCK_OPEN}`); + expect(prompt).toContain(` ${AGENT_BLOCK_CLOSE}`); + expect(prompt).toContain('NEVER use agent-only blocks in messages to "user".'); + + await svc.cancelProvisioning(runId); + }); });