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:
parent
a3844a085f
commit
085ec144ac
5 changed files with 213 additions and 23 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}" }
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue