432 lines
16 KiB
TypeScript
432 lines
16 KiB
TypeScript
import { EventEmitter } from 'events';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
|
|
|
|
const hoisted = vi.hoisted(() => ({
|
|
paths: {
|
|
claudeRoot: '',
|
|
teamsBase: '',
|
|
tasksBase: '',
|
|
},
|
|
}));
|
|
|
|
let tempClaudeRoot = '';
|
|
let tempTeamsBase = '';
|
|
let tempTasksBase = '';
|
|
|
|
vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
|
|
ClaudeBinaryResolver: { resolve: vi.fn() },
|
|
}));
|
|
|
|
vi.mock('@main/utils/childProcess', () => ({
|
|
spawnCli: vi.fn(),
|
|
killProcessTree: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@main/utils/pathDecoder')>();
|
|
return {
|
|
...actual,
|
|
getAutoDetectedClaudeBasePath: () => hoisted.paths.claudeRoot,
|
|
getClaudeBasePath: () => hoisted.paths.claudeRoot,
|
|
getTeamsBasePath: () => hoisted.paths.teamsBase,
|
|
getTasksBasePath: () => hoisted.paths.tasksBase,
|
|
};
|
|
});
|
|
|
|
import {
|
|
buildAddMemberSpawnMessage,
|
|
TeamProvisioningService,
|
|
} from '@main/services/team/TeamProvisioningService';
|
|
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
|
import { spawnCli } from '@main/utils/childProcess';
|
|
import { setAppDataBasePath } from '@main/utils/pathDecoder';
|
|
|
|
function createFakeChild() {
|
|
const writeSpy = vi.fn((_data: unknown, cb?: (err?: Error | null) => void) => {
|
|
if (typeof cb === 'function') cb(null);
|
|
return true;
|
|
});
|
|
const endSpy = vi.fn();
|
|
const child = Object.assign(new EventEmitter(), {
|
|
pid: 12345,
|
|
stdin: { writable: true, write: writeSpy, end: endSpy },
|
|
stdout: new EventEmitter(),
|
|
stderr: new EventEmitter(),
|
|
kill: vi.fn(),
|
|
});
|
|
return { child, writeSpy };
|
|
}
|
|
|
|
function extractPromptFromBootstrapFile(callIndex = 0): string {
|
|
const args = vi.mocked(spawnCli).mock.calls[callIndex]?.[1] as string[] | undefined;
|
|
const promptFlagIndex = args?.indexOf('--team-bootstrap-user-prompt-file') ?? -1;
|
|
const promptPath = promptFlagIndex >= 0 ? args?.[promptFlagIndex + 1] : null;
|
|
if (!promptPath) {
|
|
throw new Error('Failed to extract bootstrap prompt file path from spawn args');
|
|
}
|
|
return fs.readFileSync(promptPath, 'utf8');
|
|
}
|
|
|
|
function extractBootstrapSpec(callIndex = 0): {
|
|
mode?: string;
|
|
team?: { name?: string; cwd?: string };
|
|
lead?: { permissionSeedTools?: string[] };
|
|
members?: Array<Record<string, unknown>>;
|
|
} {
|
|
const args = vi.mocked(spawnCli).mock.calls[callIndex]?.[1] as string[] | undefined;
|
|
const specFlagIndex = args?.indexOf('--team-bootstrap-spec') ?? -1;
|
|
const specPath = specFlagIndex >= 0 ? args?.[specFlagIndex + 1] : null;
|
|
if (!specPath) {
|
|
throw new Error('Failed to extract bootstrap spec path from spawn args');
|
|
}
|
|
return JSON.parse(fs.readFileSync(specPath, 'utf8')) as {
|
|
mode?: string;
|
|
team?: { name?: string; cwd?: string };
|
|
lead?: { permissionSeedTools?: string[] };
|
|
members?: Array<Record<string, unknown>>;
|
|
};
|
|
}
|
|
|
|
describe('TeamProvisioningService prompt content (solo mode discipline)', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
tempClaudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-prompts-'));
|
|
tempTeamsBase = path.join(tempClaudeRoot, 'teams');
|
|
tempTasksBase = path.join(tempClaudeRoot, 'tasks');
|
|
hoisted.paths.claudeRoot = tempClaudeRoot;
|
|
hoisted.paths.teamsBase = tempTeamsBase;
|
|
hoisted.paths.tasksBase = tempTasksBase;
|
|
setAppDataBasePath(tempClaudeRoot);
|
|
fs.mkdirSync(tempTeamsBase, { recursive: true });
|
|
fs.mkdirSync(tempTasksBase, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
setAppDataBasePath(null);
|
|
// Best-effort cleanup of temp dir (per-test)
|
|
try {
|
|
fs.rmSync(tempClaudeRoot, { recursive: true, force: true });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
});
|
|
|
|
it('createTeam uses deterministic bootstrap spec and safe flags in solo mode', 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).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
|
(svc as any).startFilesystemMonitor = vi.fn();
|
|
(svc as any).pathExists = vi.fn(async () => false);
|
|
|
|
const { runId } = await svc.createTeam(
|
|
{
|
|
teamName: 'solo-team',
|
|
cwd: process.cwd(),
|
|
members: [],
|
|
description: 'Solo team for prompt test',
|
|
},
|
|
() => {}
|
|
);
|
|
|
|
expect(writeSpy).not.toHaveBeenCalled();
|
|
const bootstrapSpec = extractBootstrapSpec();
|
|
expect(bootstrapSpec.mode).toBe('create');
|
|
expect(bootstrapSpec.team).toMatchObject({
|
|
name: 'solo-team',
|
|
cwd: process.cwd(),
|
|
});
|
|
expect(bootstrapSpec.members).toEqual([]);
|
|
|
|
const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
|
|
expect(launchArgs).toContain('--mcp-config');
|
|
expect(launchArgs).toContain('--team-bootstrap-spec');
|
|
expect(launchArgs).not.toContain('--team-bootstrap-user-prompt-file');
|
|
expect(launchArgs).not.toContain('--strict-mcp-config');
|
|
expect(launchArgs).toContain('--disallowedTools');
|
|
const disallowed = launchArgs[launchArgs.indexOf('--disallowedTools') + 1] ?? '';
|
|
expect(disallowed).not.toContain('Agent');
|
|
expect(disallowed).toContain('mcp__agent-teams__team_launch');
|
|
|
|
await svc.cancelProvisioning(runId);
|
|
});
|
|
|
|
it('launchTeam prompt (solo) uses deterministic refresh-only reconnect instructions', async () => {
|
|
// Seed config.json so launchTeam can validate team existence.
|
|
const teamName = 'solo-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: 'Solo team for prompt test',
|
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
|
}),
|
|
'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).persistLaunchStateSnapshot = vi.fn(async () => {});
|
|
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
|
|
members: [],
|
|
source: 'config-fallback',
|
|
warning: undefined,
|
|
}));
|
|
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
|
(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,
|
|
() => {}
|
|
);
|
|
|
|
expect(writeSpy).not.toHaveBeenCalled();
|
|
const prompt = extractPromptFromBootstrapFile();
|
|
expect(prompt).toContain('SOLO MODE: This team CURRENTLY has ZERO teammates.');
|
|
expect(prompt).toContain('This reconnect/bootstrap step has already been completed deterministically by the runtime.');
|
|
expect(prompt).toContain('Do NOT start implementation in this turn.');
|
|
expect(prompt).toContain('Use this turn only to refresh context, review the current board snapshot, and confirm you are ready.');
|
|
expect(prompt).toContain(
|
|
'review_request already notifies the reviewer, so do NOT send a second manual SendMessage for the same review request'
|
|
);
|
|
expect(prompt).toContain(
|
|
'Review is a state transition on the EXISTING work task.'
|
|
);
|
|
expect(prompt).toContain(
|
|
'The REVIEW column is for the same task #X moving through review. It is NOT a signal to create another task for review.'
|
|
);
|
|
expect(prompt).toContain('task_create_from_message');
|
|
expect(prompt).toContain(`AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}`);
|
|
expect(prompt).toContain(`AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`);
|
|
expect(prompt).not.toContain('teamctl.js');
|
|
expect(prompt).not.toContain('.claude/tools');
|
|
|
|
const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
|
|
expect(launchArgs).toContain('--mcp-config');
|
|
expect(launchArgs).not.toContain('--strict-mcp-config');
|
|
|
|
await svc.cancelProvisioning(runId);
|
|
});
|
|
|
|
it('createTeam bootstrap spec carries teammate descriptors for deterministic startup', 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).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
|
(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',
|
|
},
|
|
() => {}
|
|
);
|
|
|
|
expect(writeSpy).not.toHaveBeenCalled();
|
|
const bootstrapSpec = extractBootstrapSpec();
|
|
expect(bootstrapSpec.mode).toBe('create');
|
|
expect(bootstrapSpec.members).toEqual([
|
|
expect.objectContaining({
|
|
name: 'alice',
|
|
role: 'developer',
|
|
description: 'developer',
|
|
cwd: process.cwd(),
|
|
}),
|
|
]);
|
|
|
|
await svc.cancelProvisioning(runId);
|
|
});
|
|
|
|
it('add-member spawn prompt tells teammates to keep review on the same task', () => {
|
|
const prompt = buildAddMemberSpawnMessage('my-team', 'My Team', 'team-lead', {
|
|
name: 'alice',
|
|
role: 'developer',
|
|
});
|
|
|
|
expect(prompt).toContain('Review flow rule: review is a state transition on the SAME work task');
|
|
expect(prompt).toContain('Do NOT create a separate "review task"');
|
|
expect(prompt).toContain(
|
|
'If no reviewer exists, leave #X completed.'
|
|
);
|
|
expect(prompt).toContain(
|
|
'If you are the reviewer for task #X, call review_start on #X first, then review_approve or review_request_changes on #X itself.'
|
|
);
|
|
});
|
|
|
|
it('launchTeam hydration prompt includes task-comment handling guidance by default', async () => {
|
|
const teamName = 'forward-live-team';
|
|
const teamDir = path.join(tempTeamsBase, teamName);
|
|
fs.mkdirSync(teamDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(teamDir, 'config.json'),
|
|
JSON.stringify({
|
|
name: teamName,
|
|
description: 'Task comment forwarding live 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).persistLaunchStateSnapshot = vi.fn(async () => {});
|
|
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
|
|
members: [{ name: 'alice', role: 'developer' }],
|
|
source: 'config-fallback',
|
|
warning: undefined,
|
|
}));
|
|
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
|
(svc as any).startFilesystemMonitor = vi.fn();
|
|
(svc as any).pathExists = vi.fn(async () => false);
|
|
|
|
const { runId } = await svc.launchTeam(
|
|
{
|
|
teamName,
|
|
cwd: process.cwd(),
|
|
clearContext: true,
|
|
},
|
|
() => {}
|
|
);
|
|
|
|
expect(writeSpy).not.toHaveBeenCalled();
|
|
const prompt = extractPromptFromBootstrapFile();
|
|
expect(prompt).toContain(
|
|
'Teammate task comments are auto-forwarded to you.'
|
|
);
|
|
|
|
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).persistLaunchStateSnapshot = vi.fn(async () => {});
|
|
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
|
|
members: [{ name: 'alice', role: 'developer' }],
|
|
source: 'config-fallback',
|
|
warning: undefined,
|
|
}));
|
|
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
|
(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,
|
|
() => {}
|
|
);
|
|
|
|
expect(writeSpy).not.toHaveBeenCalled();
|
|
const prompt = extractPromptFromBootstrapFile();
|
|
expect(prompt).toContain('This reconnect/bootstrap step has already been completed deterministically by the runtime.');
|
|
expect(prompt).toContain('Do NOT use Agent to spawn or restore teammates.');
|
|
expect(prompt).toContain('Use this turn only to refresh context, review the current board snapshot, and prepare the next delegation step.');
|
|
expect(prompt).toContain('DELEGATION-FIRST (behavior rule for ALL future turns):');
|
|
expect(prompt).toContain(`AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}`);
|
|
expect(prompt).toContain(`AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`);
|
|
expect(prompt).toContain('Messages to "user" (the human) must NEVER contain agent-only blocks.');
|
|
expect(prompt).toContain('task_create_from_message');
|
|
expect(prompt).toContain('task_set_owner');
|
|
expect(prompt).toContain('cross_team_send');
|
|
expect(prompt).toContain(
|
|
'review_request already notifies the reviewer'
|
|
);
|
|
expect(prompt).toContain(
|
|
'By default, NEVER create a separate "review task".'
|
|
);
|
|
expect(prompt).toContain(
|
|
'Only move #X into REVIEW when a real reviewer exists for #X.'
|
|
);
|
|
expect(prompt).not.toContain(
|
|
'Only create a separate review reminder/assignment task'
|
|
);
|
|
expect(prompt).toContain(
|
|
'Correct flow: finish implementation on #X -> task_complete #X -> review_request #X -> reviewer runs review_start #X -> reviewer runs review_approve or review_request_changes on #X.'
|
|
);
|
|
|
|
await svc.cancelProvisioning(runId);
|
|
});
|
|
});
|