feat: implement stable message ID generation and enhance agent block reminders

- Added `ensureStableMessageIds` method to assign deterministic IDs to messages lacking a messageId, ensuring stability across UI updates.
- Introduced `normalizeMessageIdPart` for consistent message ID formatting.
- Enhanced `buildTeammateAgentBlockReminder` function to provide clear internal instructions for handling agent-only messages.
- Updated tests to verify the inclusion of hidden-instruction block rules in team prompts.
This commit is contained in:
iliya 2026-03-07 22:13:26 +02:00
parent a3844a085f
commit 085ec144ac
5 changed files with 213 additions and 23 deletions

View file

@ -339,6 +339,8 @@ export class TeamDataService {
}); });
} }
this.ensureStableMessageIds(messages);
// Enrich inbox messages without leadSessionId by assigning the nearest neighbor's // Enrich inbox messages without leadSessionId by assigning the nearest neighbor's
// session ID (by timestamp). This avoids the old forward-only propagation bug where // session ID (by timestamp). This avoids the old forward-only propagation bug where
// messages between two sessions always inherited the *earlier* session, causing a // 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'; 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<string>();
const missingIdOccurrences = new Map<string, number>();
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( async sendDirectToLead(
teamName: string, teamName: string,
leadName: string, leadName: string,

View file

@ -431,6 +431,18 @@ function buildCompactMembersRoster(members: TeamCreateRequest['members']): strin
.join('\n'); .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( function buildMemberSpawnPrompt(
member: TeamCreateRequest['members'][number], member: TeamCreateRequest['members'][number],
displayName: string, displayName: string,
@ -447,6 +459,7 @@ function buildMemberSpawnPrompt(
${getAgentLanguageInstruction()} ${getAgentLanguageInstruction()}
Introduce yourself briefly (name and role) and confirm you are ready. Introduce yourself briefly (name and role) and confirm you are ready.
Then wait for task assignments. Then wait for task assignments.
${buildTeammateAgentBlockReminder()}
Include the following agent-only instructions verbatim in the prompt: Include the following agent-only instructions verbatim in the prompt:
${taskProtocol} ${taskProtocol}
@ -898,6 +911,7 @@ function buildLaunchPrompt(
${languageInstruction} ${languageInstruction}
The team has been reconnected after a restart. The team has been reconnected after a restart.
${hasTasks ? `You have pending tasks from the previous session.` : 'You have no pending tasks currently.'} ${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: Your FIRST action: call MCP tool task_briefing with:
{ teamName: "${request.teamName}", memberName: "${m.name}" } { teamName: "${request.teamName}", memberName: "${m.name}" }

View file

@ -6,7 +6,12 @@ import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { ActivityItem, isNoiseMessage } from './ActivityItem'; import { ActivityItem, isNoiseMessage } from './ActivityItem';
import { AnimatedHeightReveal } from './AnimatedHeightReveal'; import { AnimatedHeightReveal } from './AnimatedHeightReveal';
import { findNewestMessageIndex, resolveTimelineCollapseState } from './collapseState'; import { findNewestMessageIndex, resolveTimelineCollapseState } from './collapseState';
import { groupTimelineItems, isLeadThought, LeadThoughtsGroupRow } from './LeadThoughtsGroup'; import {
getThoughtGroupKey,
groupTimelineItems,
isLeadThought,
LeadThoughtsGroupRow,
} from './LeadThoughtsGroup';
import { useNewItemKeys } from './useNewItemKeys'; import { useNewItemKeys } from './useNewItemKeys';
import type { ActivityCollapseState } from './collapseState'; import type { ActivityCollapseState } from './collapseState';
@ -207,11 +212,12 @@ export const ActivityTimeline = ({
// Group consecutive lead thoughts into collapsible blocks. // Group consecutive lead thoughts into collapsible blocks.
const timelineItems = useMemo(() => groupTimelineItems(visibleMessages), [visibleMessages]); 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 zebraShadeSet = useMemo(() => {
const result = new Set<number>(); const result = new Set<number>();
let cardCount = 0; let cardCount = 0;
for (let i = 0; i < timelineItems.length; i++) { for (let i = timelineItems.length - 1; i >= 0; i--) {
const item = timelineItems[i]; const item = timelineItems[i];
if (item.type === 'lead-thoughts') { if (item.type === 'lead-thoughts') {
// Thought groups count as one card for striping // Thought groups count as one card for striping
@ -229,11 +235,9 @@ export const ActivityTimeline = ({
const timelineItemKeys = useMemo(() => { const timelineItemKeys = useMemo(() => {
const getItemKey = (item: TimelineItem): string => { const getItemKey = (item: TimelineItem): string => {
if (item.type === 'lead-thoughts') { if (item.type === 'lead-thoughts') {
// Stable key: identify group by its first thought, not by count (which changes) return getThoughtGroupKey(item.group);
return `thoughts-${item.group.thoughts[0].messageId ?? item.originalIndices[0]}`;
} }
const msg = item.message; return toMessageKey(item.message);
return `${msg.messageId ?? item.originalIndex}-${msg.timestamp}-${msg.from}`;
}; };
return timelineItems.map(getItemKey); return timelineItems.map(getItemKey);
@ -245,6 +249,22 @@ export const ActivityTimeline = ({
resetKey: teamName, resetKey: teamName,
}); });
useEffect(() => {
if (process.env.NODE_ENV === 'production') return;
const seen = new Set<string>();
const duplicates = new Set<string>();
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 => { const handleShowMore = (): void => {
setVisibleCount((prev) => prev + MESSAGES_PAGE_SIZE); setVisibleCount((prev) => prev + MESSAGES_PAGE_SIZE);
}; };
@ -304,8 +324,8 @@ export const ActivityTimeline = ({
const { group } = pinnedThoughtGroup; const { group } = pinnedThoughtGroup;
const firstThought = group.thoughts[0]; const firstThought = group.thoughts[0];
const info = memberInfo.get(firstThought.from); const info = memberInfo.get(firstThought.from);
const itemKey = `thoughts-${firstThought.messageId ?? pinnedThoughtGroup.originalIndices[0]}`; const itemKey = getThoughtGroupKey(group);
const stableKey = toMessageKey(firstThought); const stableKey = itemKey;
const collapseState = getItemCollapseState(stableKey, 0); const collapseState = getItemCollapseState(stableKey, 0);
return ( return (
<LeadThoughtsGroupRow <LeadThoughtsGroupRow
@ -353,8 +373,8 @@ export const ActivityTimeline = ({
const { group } = item; const { group } = item;
const firstThought = group.thoughts[0]; const firstThought = group.thoughts[0];
const info = memberInfo.get(firstThought.from); const info = memberInfo.get(firstThought.from);
const itemKey = `thoughts-${firstThought.messageId ?? item.originalIndices[0]}`; const itemKey = getThoughtGroupKey(group);
const stableKey = toMessageKey(firstThought); const stableKey = itemKey;
const collapseState = getItemCollapseState(stableKey, realIndex); const collapseState = getItemCollapseState(stableKey, realIndex);
return ( return (
<React.Fragment key={itemKey}> <React.Fragment key={itemKey}>
@ -380,8 +400,8 @@ export const ActivityTimeline = ({
const recipientInfo = message.to ? memberInfo.get(message.to) : undefined; const recipientInfo = message.to ? memberInfo.get(message.to) : undefined;
const recipientColor = const recipientColor =
recipientInfo?.color ?? (message.to ? colorMap.get(message.to) : undefined); recipientInfo?.color ?? (message.to ? colorMap.get(message.to) : undefined);
const messageKey = `${message.messageId ?? item.originalIndex}-${message.timestamp}-${message.from}`; const messageKey = toMessageKey(message);
const stableKey = toMessageKey(message); const stableKey = messageKey;
const collapseState = getItemCollapseState(stableKey, realIndex); const collapseState = getItemCollapseState(stableKey, realIndex);
const isUnread = readState const isUnread = readState
? !message.read && !readState.readSet.has(readState.getMessageKey(message)) ? !message.read && !readState.readSet.has(readState.getMessageKey(message))

View file

@ -14,6 +14,7 @@ import {
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary'; import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
import { ChevronDown, ChevronRight, ChevronUp, Reply } from 'lucide-react'; import { ChevronDown, ChevronRight, ChevronUp, Reply } from 'lucide-react';
@ -44,8 +45,17 @@ export function isLeadThought(msg: InboxMessage): boolean {
} }
export type TimelineItem = export type TimelineItem =
| { type: 'message'; message: InboxMessage; originalIndex: number } | { type: 'message'; message: InboxMessage }
| { type: 'lead-thoughts'; group: LeadThoughtGroup; originalIndices: number[] }; | { 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. * Group consecutive lead thoughts into compact blocks.
@ -54,7 +64,6 @@ export type TimelineItem =
export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] { export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] {
const result: TimelineItem[] = []; const result: TimelineItem[] = [];
let pendingThoughts: InboxMessage[] = []; let pendingThoughts: InboxMessage[] = [];
let pendingIndices: number[] = [];
const hasSameLeadSession = (a: InboxMessage, b: InboxMessage): boolean => const hasSameLeadSession = (a: InboxMessage, b: InboxMessage): boolean =>
(a.leadSessionId ?? null) === (b.leadSessionId ?? null); (a.leadSessionId ?? null) === (b.leadSessionId ?? null);
@ -63,24 +72,20 @@ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] {
result.push({ result.push({
type: 'lead-thoughts', type: 'lead-thoughts',
group: { type: 'lead-thoughts', thoughts: pendingThoughts }, group: { type: 'lead-thoughts', thoughts: pendingThoughts },
originalIndices: pendingIndices,
}); });
pendingThoughts = []; pendingThoughts = [];
pendingIndices = [];
}; };
for (let i = 0; i < messages.length; i++) { for (const msg of messages) {
const msg = messages[i];
if (isLeadThought(msg)) { if (isLeadThought(msg)) {
const previousThought = pendingThoughts[pendingThoughts.length - 1]; const previousThought = pendingThoughts[pendingThoughts.length - 1];
if (previousThought && !hasSameLeadSession(previousThought, msg)) { if (previousThought && !hasSameLeadSession(previousThought, msg)) {
flushThoughts(); flushThoughts();
} }
pendingThoughts.push(msg); pendingThoughts.push(msg);
pendingIndices.push(i);
} else { } else {
flushThoughts(); flushThoughts();
result.push({ type: 'message', message: msg, originalIndex: i }); result.push({ type: 'message', message: msg });
} }
} }
flushThoughts(); flushThoughts();
@ -756,7 +761,7 @@ export const LeadThoughtsGroupRow = ({
<div ref={contentRef}> <div ref={contentRef}>
{chronologicalThoughts.map((thought, idx) => ( {chronologicalThoughts.map((thought, idx) => (
<LeadThoughtItem <LeadThoughtItem
key={thought.messageId ?? idx} key={toMessageKey(thought)}
thought={thought} thought={thought}
showDivider={idx > 0} showDivider={idx > 0}
shouldAnimate={isLive && idx === chronologicalThoughts.length - 1} shouldAnimate={isLive && idx === chronologicalThoughts.length - 1}

View file

@ -187,4 +187,93 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
await svc.cancelProvisioning(runId); 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);
});
}); });