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
// 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<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(
teamName: string,
leadName: string,

View file

@ -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}" }

View file

@ -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<number>();
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<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 => {
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 (
<LeadThoughtsGroupRow
@ -353,8 +373,8 @@ export const ActivityTimeline = ({
const { group } = item;
const firstThought = group.thoughts[0];
const info = memberInfo.get(firstThought.from);
const itemKey = `thoughts-${firstThought.messageId ?? item.originalIndices[0]}`;
const stableKey = toMessageKey(firstThought);
const itemKey = getThoughtGroupKey(group);
const stableKey = itemKey;
const collapseState = getItemCollapseState(stableKey, realIndex);
return (
<React.Fragment key={itemKey}>
@ -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))

View file

@ -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 = ({
<div ref={contentRef}>
{chronologicalThoughts.map((thought, idx) => (
<LeadThoughtItem
key={thought.messageId ?? idx}
key={toMessageKey(thought)}
thought={thought}
showDivider={idx > 0}
shouldAnimate={isLive && idx === chronologicalThoughts.length - 1}

View file

@ -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);
});
});