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
|
// 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,
|
||||||
|
|
|
||||||
|
|
@ -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}" }
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue