import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks'; 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'; 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', () => ({ execCli: vi.fn(async (_binaryPath: string | null, args: string[]) => { if (args[0] === '-e' && args[1]?.includes('process.execPath')) { return { stdout: JSON.stringify({ execPath: process.execPath, version: process.versions.node }), stderr: '', }; } if (args.includes('model') && args.includes('list')) { return { stdout: JSON.stringify({ schemaVersion: 1, providers: { anthropic: { defaultModel: 'opus[1m]', models: [ { id: 'opus', label: 'Opus 4.7', description: 'Anthropic default family alias' }, { id: 'opus[1m]', label: 'Opus 4.7 (1M)', description: 'Anthropic long-context default', }, ], }, codex: { defaultModel: 'gpt-5.4', models: [{ id: 'gpt-5.4', label: 'GPT-5.4', description: 'Codex default' }], }, gemini: { defaultModel: 'gemini-2.5-pro', models: [{ id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', description: 'Default' }], }, }, }), stderr: '', }; } if (args.includes('runtime') && args.includes('status')) { return { stdout: JSON.stringify({ providers: { codex: { runtimeCapabilities: { modelCatalog: { dynamic: false, source: 'runtime' }, reasoningEffort: { supported: true, values: ['low', 'medium', 'high'], configPassthrough: false, }, }, }, }, }), stderr: '', }; } return { stdout: '', stderr: '' }; }), spawnCli: vi.fn(), killProcessTree: vi.fn(), })); vi.mock('@main/utils/shellEnv', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getCachedShellEnv: () => ({ PATH: process.env.PATH ?? '', HOME: hoisted.paths.claudeRoot }), getShellPreferredHome: () => hoisted.paths.claudeRoot || actual.getShellPreferredHome(), resolveInteractiveShellEnv: vi.fn(async () => ({ PATH: process.env.PATH ?? '', HOME: hoisted.paths.claudeRoot, })), }; }); vi.mock('@main/utils/pathDecoder', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getAutoDetectedClaudeBasePath: () => hoisted.paths.claudeRoot, getClaudeBasePath: () => hoisted.paths.claudeRoot, getTeamsBasePath: () => hoisted.paths.teamsBase, getTasksBasePath: () => hoisted.paths.tasksBase, }; }); import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; import { TeamRuntimeAdapterRegistry } from '@main/services/team/runtime/TeamRuntimeAdapter'; import { buildAddMemberSpawnMessage, buildRestartMemberSpawnMessage, TeamProvisioningService, } from '@main/services/team/TeamProvisioningService'; import { execCli, 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>; launch?: { bootstrapTimeoutMs?: number; continueOnPartialFailure?: boolean }; } { 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>; launch?: { bootstrapTimeoutMs?: number; continueOnPartialFailure?: boolean }; }; } function readRuntimeSettingsFromLaunchArgs(callIndex = 0): Record { const args = vi.mocked(spawnCli).mock.calls[callIndex]?.[1] as string[] | undefined; const settingsFlagIndex = args?.indexOf('--settings') ?? -1; const settingsValue = settingsFlagIndex >= 0 ? args?.[settingsFlagIndex + 1] : null; if (!settingsValue) { throw new Error('Failed to extract runtime settings from spawn args'); } if (settingsValue.trim().startsWith('{')) { return JSON.parse(settingsValue) as Record; } return JSON.parse(fs.readFileSync(settingsValue, 'utf8')) as Record; } function readRuntimeSettingsPathFromLaunchArgs(callIndex = 0): string { const args = vi.mocked(spawnCli).mock.calls[callIndex]?.[1] as string[] | undefined; const settingsFlagIndex = args?.indexOf('--settings') ?? -1; const settingsValue = settingsFlagIndex >= 0 ? args?.[settingsFlagIndex + 1] : null; if (!settingsValue || settingsValue.trim().startsWith('{')) { throw new Error('Failed to extract runtime settings path from spawn args'); } return settingsValue; } function registerNoopOpenCodeRuntimeAdapter(svc: TeamProvisioningService): void { svc.setRuntimeAdapterRegistry( new TeamRuntimeAdapterRegistry([ { providerId: 'opencode', prepare: vi.fn(async (input: { model?: string }) => ({ ok: true, providerId: 'opencode', modelId: input.model ?? null, diagnostics: [], warnings: [], })), launch: vi.fn(async () => { throw new Error('OpenCode side lane launch should not run in this test'); }), reconcile: vi.fn(async () => ({ members: {}, warnings: [], diagnostics: [] })), stop: vi.fn(async () => ({ stopped: true, members: {}, warnings: [], diagnostics: [] })), } as any, ]) ); } 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 launch 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).resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4'); (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 launch/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 review the current board snapshot and confirm operational readiness.' ); expect(prompt).toContain( 'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.' ); 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 reference formatting (CRITICAL)'); expect(prompt).toContain('Do NOT manually write [#abcd1234](task://...) in visible text'); 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(), }), ]); expect(bootstrapSpec.launch).toMatchObject({ bootstrapTimeoutMs: 120_000, continueOnPartialFailure: true, }); await svc.cancelProvisioning(runId); }); it('createTeam scales deterministic bootstrap timeout with member count', async () => { vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude'); const { child } = createFakeChild(); vi.mocked(spawnCli).mockReturnValue(child as any); const setTimeoutSpy = vi.spyOn(global, 'setTimeout'); 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); let runId: string | undefined; try { const created = await svc.createTeam( { teamName: 'large-team', cwd: process.cwd(), members: [ { name: 'alice' }, { name: 'atlas' }, { name: 'bob' }, { name: 'jack' }, { name: 'tom' }, ], }, () => {} ); runId = created.runId; expect(extractBootstrapSpec().launch).toMatchObject({ bootstrapTimeoutMs: 375_000, continueOnPartialFailure: true, }); expect(setTimeoutSpy.mock.calls.some((call) => call[1] === 405_000)).toBe(true); } finally { if (runId) { await svc.cancelProvisioning(runId); } setTimeoutSpy.mockRestore(); } }); it('createTeam bootstrap spec includes worktree isolation only for selected teammates', async () => { vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude'); const { child } = 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: 'worktree-mixed-team', cwd: process.cwd(), members: [ { name: 'alice', role: 'developer', isolation: 'worktree' }, { name: 'bob', role: 'reviewer' }, ], }, () => {} ); const bootstrapSpec = extractBootstrapSpec(); expect(bootstrapSpec.members?.[0]).toEqual( expect.objectContaining({ name: 'alice', isolation: 'worktree' }) ); expect(bootstrapSpec.members?.[1]).toEqual(expect.objectContaining({ name: 'bob' })); expect(bootstrapSpec.members?.[1]).not.toHaveProperty('isolation'); await svc.cancelProvisioning(runId); }); it('forwards codex provider launch overrides into createTeam runtime args', async () => { vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/codex'); const { child } = createFakeChild(); vi.mocked(spawnCli).mockReturnValue(child as any); const svc = new TeamProvisioningService(); (svc as any).buildProvisioningEnv = vi.fn(async () => ({ env: {}, authSource: 'codex_runtime', providerArgs: ['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'], })); (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: 'codex-team', cwd: process.cwd(), members: [], providerId: 'codex', }, () => {} ); const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[]; expect(launchArgs).toEqual( expect.arrayContaining(['--settings', '{"codex":{"forced_login_method":"chatgpt"}}']) ); await svc.cancelProvisioning(runId); }); it('coalesces codex cross-provider launch overrides into createTeam Anthropic runtime settings', async () => { vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude'); const { child } = createFakeChild(); vi.mocked(spawnCli).mockReturnValue(child as any); const svc = new TeamProvisioningService(); registerNoopOpenCodeRuntimeAdapter(svc); (svc as any).buildProvisioningEnv = vi.fn(async (providerId: string | undefined) => providerId === 'codex' ? { env: { CLAUDE_CODE_CODEX_BACKEND: 'codex-native', CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD: 'chatgpt', }, authSource: 'codex_runtime', geminiRuntimeAuth: null, providerArgs: ['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'], } : { env: {}, authSource: 'none', geminiRuntimeAuth: null, providerArgs: [], } ); (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: 'anthropic-codex-create-team', cwd: process.cwd(), members: [ { name: 'alice', role: 'developer', providerId: 'codex' }, { name: 'bob', role: 'reviewer', providerId: 'opencode', model: 'minimax-m2.5-free', }, ], providerId: 'anthropic', }, () => {} ); const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[]; expect(launchArgs).not.toContain('{"codex":{"forced_login_method":"chatgpt"}}'); expect(launchArgs.join(' ')).not.toContain('minimax-m2.5-free'); expect(extractBootstrapSpec().members).toEqual([ expect.objectContaining({ name: 'alice', provider: 'codex' }), ]); const settingsPath = readRuntimeSettingsPathFromLaunchArgs(); const launchEnv = vi.mocked(spawnCli).mock.calls[0]?.[2]?.env as NodeJS.ProcessEnv; expect(launchEnv.CLAUDE_TEAM_RUNTIME_SETTINGS_PATH).toBe(settingsPath); const settings = readRuntimeSettingsFromLaunchArgs(); expect((settings.codex as Record).forced_login_method).toBe('chatgpt'); await svc.cancelProvisioning(runId); }); it('blocks Codex xhigh launch effort until runtime exposes reasoning config passthrough', async () => { vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/codex'); vi.mocked(spawnCli).mockReset(); const svc = new TeamProvisioningService(); (svc as any).buildProvisioningEnv = vi.fn(async () => ({ env: {}, authSource: 'codex_runtime', providerArgs: [], })); (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {}); (svc as any).startFilesystemMonitor = vi.fn(); (svc as any).pathExists = vi.fn(async () => false); await expect( svc.createTeam( { teamName: 'codex-xhigh-blocked', cwd: process.cwd(), members: [], providerId: 'codex', effort: 'xhigh', }, () => {} ) ).rejects.toThrow('does not expose Codex reasoning config passthrough yet'); expect(spawnCli).not.toHaveBeenCalled(); }); it('blocks future Codex catalog models until runtime declares dynamic launch support', async () => { vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/codex'); vi.mocked(spawnCli).mockReset(); const svc = new TeamProvisioningService(); (svc as any).buildProvisioningEnv = vi.fn(async () => ({ env: {}, authSource: 'codex_runtime', providerArgs: [], })); (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {}); (svc as any).startFilesystemMonitor = vi.fn(); (svc as any).pathExists = vi.fn(async () => false); await expect( svc.createTeam( { teamName: 'codex-future-model-blocked', cwd: process.cwd(), members: [], providerId: 'codex', model: 'gpt-5.5', effort: 'medium', }, () => {} ) ).rejects.toThrow('does not declare dynamic Codex model launch support yet'); expect(execCli).toHaveBeenCalledWith( '/fake/codex', ['runtime', 'status', '--json', '--provider', 'codex'], expect.objectContaining({ cwd: process.cwd() }) ); expect(spawnCli).not.toHaveBeenCalled(); }); it('allows explicit Codex models when launch model list parsing is degraded', async () => { vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/codex'); const { child } = createFakeChild(); vi.mocked(spawnCli).mockReturnValue(child as any); const svc = new TeamProvisioningService(); (svc as any).buildProvisioningEnv = vi.fn(async () => ({ env: {}, authSource: 'codex_runtime', providerArgs: [], })); (svc as any).readRuntimeProviderLaunchFacts = vi.fn(async () => ({ defaultModel: null, modelIds: new Set(), modelListParsed: false, modelCatalog: null, runtimeCapabilities: { modelCatalog: { dynamic: false, source: 'runtime' } }, providerStatus: null, })); (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: 'codex-future-model-launchable', cwd: process.cwd(), members: [], providerId: 'codex', model: 'gpt-5.5', effort: 'medium', }, () => {} ); expect(spawnCli).toHaveBeenCalled(); await svc.cancelProvisioning(runId); }); it('restart teammate message keeps the exact teammate identity and avoids duplicate semantics', () => { const message = buildRestartMemberSpawnMessage('forge-labs', 'Forge Labs', 'lead', { name: 'alice', role: 'Reviewer', providerId: 'codex', model: 'gpt-5.4-mini', effort: 'medium', }); expect(message).toContain('Teammate "alice" with role "Reviewer" was restarted from the UI.'); expect(message).toContain('team_name="forge-labs", name="alice"'); expect(message).toContain('provider="codex", model="gpt-5.4-mini", effort="medium"'); expect(message).toContain( 'This is a restart of an existing persistent teammate, not a new teammate.' ); expect(message).toContain( 'If the Agent tool returns duplicate_skipped with reason bootstrap_pending, treat that as a pending restart and wait for teammate check-in.' ); expect(message).toContain( 'If it returns duplicate_skipped with reason already_running, do not report success - it means the previous runtime still appears active and the restart may not have applied.' ); }); it('add and restart teammate prompts include worktree isolation only when selected', () => { const addMessage = buildAddMemberSpawnMessage('forge-labs', 'Forge Labs', 'lead', { name: 'alice', isolation: 'worktree', }); const normalAddMessage = buildAddMemberSpawnMessage('forge-labs', 'Forge Labs', 'lead', { name: 'bob', }); const restartMessage = buildRestartMemberSpawnMessage('forge-labs', 'Forge Labs', 'lead', { name: 'alice', isolation: 'worktree', }); expect(addMessage).toContain('isolation="worktree"'); expect(restartMessage).toContain('isolation="worktree"'); expect(normalAddMessage).not.toContain('isolation="worktree"'); }); it('add and restart teammate prompts can carry strict MCP launch overrides for Agent tool spawns', () => { const mcpLaunchConfig = { mcpConfigPath: '/tmp/team path/alice-mcp.json', mcpSettingSources: 'user,project,local', strictMcpConfig: true, }; const addMessage = buildAddMemberSpawnMessage( 'forge-labs', 'Forge Labs', 'lead', { name: 'alice', providerId: 'codex', }, mcpLaunchConfig ); const restartMessage = buildRestartMemberSpawnMessage( 'forge-labs', 'Forge Labs', 'lead', { name: 'alice', providerId: 'codex', }, mcpLaunchConfig ); expect(addMessage).toContain( 'mcp_config="/tmp/team path/alice-mcp.json", mcp_setting_sources="user,project,local", strict_mcp_config=true' ); expect(restartMessage).toContain( 'mcp_config="/tmp/team path/alice-mcp.json", mcp_setting_sources="user,project,local", strict_mcp_config=true' ); }); it('createTeam materializes an explicit Codex default model for teammates before bootstrap spawn', async () => { vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude'); const { child } = createFakeChild(); vi.mocked(spawnCli).mockReturnValue(child as any); const svc = new TeamProvisioningService(); (svc as any).buildProvisioningEnv = vi.fn(async () => ({ env: { PATH: '/usr/bin' }, authSource: 'codex_runtime', geminiRuntimeAuth: null, })); (svc as any).resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4'); (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: 'codex-default-team', cwd: process.cwd(), providerId: 'codex', members: [{ name: 'alice', role: 'developer', providerId: 'codex' }], }, () => {} ); const bootstrapSpec = extractBootstrapSpec(); expect(bootstrapSpec.members).toEqual([ expect.objectContaining({ name: 'alice', provider: 'codex', model: 'gpt-5.4', }), ]); await svc.cancelProvisioning(runId); }); it('createTeam fails fast when a Codex teammate default model cannot be resolved', async () => { vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude'); vi.mocked(spawnCli).mockReset(); const svc = new TeamProvisioningService(); (svc as any).buildProvisioningEnv = vi.fn(async () => ({ env: { PATH: '/usr/bin' }, authSource: 'codex_runtime', geminiRuntimeAuth: null, })); (svc as any).resolveProviderDefaultModel = vi.fn(async () => null); (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {}); (svc as any).startFilesystemMonitor = vi.fn(); (svc as any).pathExists = vi.fn(async () => false); await expect( svc.createTeam( { teamName: 'codex-default-missing', cwd: process.cwd(), providerId: 'codex', members: [{ name: 'alice', providerId: 'codex' }], }, () => {} ) ).rejects.toThrow( 'Could not resolve the runtime default model for Codex teammates. Select an explicit model and retry.' ); expect(spawnCli).not.toHaveBeenCalled(); }); 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('teammate spawn prompts forbid manual task markdown links in visible messages', () => { const addPrompt = buildAddMemberSpawnMessage('my-team', 'My Team', 'team-lead', { name: 'alice', role: 'developer', }); const restartPrompt = buildRestartMemberSpawnMessage('my-team', 'My Team', 'team-lead', { name: 'alice', role: 'developer', }); for (const prompt of [addPrompt, restartPrompt]) { expect(prompt).toContain('Task reference formatting (CRITICAL)'); expect(prompt).toContain('write task refs as plain # text'); expect(prompt).toContain( 'Never wrap task refs or Markdown task links in backticks/code spans' ); expect(prompt).toContain('Do NOT manually write [#abcd1234](task://...) in visible text'); expect(prompt).toContain('include structured taskRefs metadata'); } }); it('add-member spawn prompt explicitly forbids no-task bootstrap chatter', () => { const prompt = buildAddMemberSpawnMessage('my-team', 'My Team', 'team-lead', { name: 'alice', role: 'developer', }); expect(prompt).toContain( 'When you later receive work or reconnect after a restart, use task_briefing as your primary working queue.' ); expect(prompt).toContain( 'Use task_list only to search/browse inventory rows, not as your working queue.' ); expect(prompt).toContain( 'Awareness items are watch-only context unless the lead reroutes the task or you become the actionOwner.' ); expect(prompt).toContain( 'If bootstrap succeeded and you have no task, produce ZERO assistant text for that turn and end it immediately after the successful tool result.' ); expect(prompt).toContain( 'Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after bootstrap.' ); expect(prompt).toContain('retry tool search at most once'); expect(prompt).toContain('Do NOT keep searching for member_briefing'); }); 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).resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4'); (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 bootstrap 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).resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4'); (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 launch/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 review the current board snapshot and teammate readiness.' ); expect(prompt).toContain( 'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.' ); expect(prompt).toContain('DELEGATION-FIRST (behavior rule for ALL future lead 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( 'lead_briefing is the primary lead queue. Decisions about what to act on now come from lead_briefing, not from raw task_list rows.' ); expect(prompt).toContain('Browse/search compact inventory rows only: task_list'); expect(prompt).toContain( `Browse/search compact inventory rows only: task_list { teamName: "${teamName}", owner?: "", status?: "pending|in_progress|completed"` ); expect(prompt).not.toContain( `Browse/search compact inventory rows only: task_list { teamName: "${teamName}", owner?: "", status?: "pending|in_progress|completed|deleted"` ); expect(prompt).toContain( "task_list is inventory/search/drill-down only. Do NOT treat task_list as the lead's working queue." ); 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); }); it('launchTeam materializes an explicit Codex default model for launch teammates before bootstrap spawn', async () => { const teamName = 'codex-default-launch'; const teamDir = path.join(tempTeamsBase, teamName); fs.mkdirSync(teamDir, { recursive: true }); fs.writeFileSync( path.join(teamDir, 'config.json'), JSON.stringify({ name: teamName, members: [ { name: 'team-lead', agentType: 'team-lead', providerId: 'codex' }, { name: 'alice', agentType: 'teammate', role: 'developer', providerId: 'codex' }, ], }), 'utf8' ); vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude'); const { child } = createFakeChild(); vi.mocked(spawnCli).mockReturnValue(child as any); const svc = new TeamProvisioningService(); (svc as any).buildProvisioningEnv = vi.fn(async () => ({ env: { PATH: '/usr/bin' }, authSource: 'codex_runtime', geminiRuntimeAuth: null, })); (svc as any).resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4'); (svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {}); (svc as any).resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4'); (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).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(), providerId: 'codex', clearContext: true, } as any, () => {} ); const bootstrapSpec = extractBootstrapSpec(); expect(bootstrapSpec.members).toEqual([ expect.objectContaining({ name: 'alice', provider: 'codex', model: 'gpt-5.4', }), ]); await svc.cancelProvisioning(runId); }); it('forwards codex provider launch overrides into launchTeam runtime args', async () => { const teamName = 'codex-launch-forced-login'; const teamDir = path.join(tempTeamsBase, teamName); fs.mkdirSync(teamDir, { recursive: true }); fs.writeFileSync( path.join(teamDir, 'config.json'), JSON.stringify({ name: teamName, members: [ { name: 'team-lead', agentType: 'team-lead', providerId: 'codex' }, { name: 'alice', agentType: 'teammate', role: 'developer', providerId: 'codex' }, ], }), 'utf8' ); vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/codex'); const { child } = createFakeChild(); vi.mocked(spawnCli).mockReturnValue(child as any); const svc = new TeamProvisioningService(); (svc as any).buildProvisioningEnv = vi.fn(async () => ({ env: {}, authSource: 'codex_runtime', geminiRuntimeAuth: null, providerArgs: ['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'], })); (svc as any).resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4'); (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', providerId: 'codex', isolation: 'worktree' }], 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(), providerId: 'codex', clearContext: true, } as any, () => {} ); const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[]; expect(launchArgs).toEqual( expect.arrayContaining(['--settings', '{"codex":{"forced_login_method":"chatgpt"}}']) ); expect(extractBootstrapSpec().members?.[0]).toEqual( expect.objectContaining({ name: 'alice', provider: 'codex', }) ); await svc.cancelProvisioning(runId); }); it('coalesces codex cross-provider launch overrides into launchTeam Anthropic runtime settings', async () => { const teamName = 'anthropic-codex-launch-team'; const teamDir = path.join(tempTeamsBase, teamName); fs.mkdirSync(teamDir, { recursive: true }); fs.writeFileSync( path.join(teamDir, 'config.json'), JSON.stringify({ name: teamName, members: [ { name: 'team-lead', agentType: 'team-lead', providerId: 'anthropic' }, { name: 'alice', agentType: 'teammate', role: 'developer', providerId: 'codex', model: 'gpt-5.4', }, { name: 'bob', agentType: 'teammate', role: 'reviewer', providerId: 'opencode', model: 'minimax-m2.5-free', }, ], }), 'utf8' ); vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude'); const { child } = createFakeChild(); vi.mocked(spawnCli).mockReturnValue(child as any); const svc = new TeamProvisioningService(); registerNoopOpenCodeRuntimeAdapter(svc); (svc as any).buildProvisioningEnv = vi.fn(async (providerId: string | undefined) => providerId === 'codex' ? { env: { CLAUDE_CODE_CODEX_BACKEND: 'codex-native', CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD: 'chatgpt', }, authSource: 'codex_runtime', geminiRuntimeAuth: null, providerArgs: ['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'], } : { env: {}, authSource: 'none', geminiRuntimeAuth: null, providerArgs: [], } ); (svc as any).resolveProviderDefaultModel = vi.fn(async (providerId: string | undefined) => providerId === 'codex' ? 'gpt-5.4' : 'opus' ); (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', providerId: 'codex', model: 'gpt-5.4', isolation: 'worktree', }, { name: 'bob', role: 'reviewer', providerId: 'opencode', model: 'minimax-m2.5-free', isolation: 'worktree', }, ], 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(), providerId: 'anthropic', clearContext: true, } as any, () => {} ); const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[]; expect(launchArgs).not.toContain('{"codex":{"forced_login_method":"chatgpt"}}'); expect(launchArgs.join(' ')).not.toContain('minimax-m2.5-free'); expect(extractBootstrapSpec().members).toEqual([ expect.objectContaining({ name: 'alice', provider: 'codex' }), ]); const settingsPath = readRuntimeSettingsPathFromLaunchArgs(); const launchEnv = vi.mocked(spawnCli).mock.calls[0]?.[2]?.env as NodeJS.ProcessEnv; expect(launchEnv.CLAUDE_TEAM_RUNTIME_SETTINGS_PATH).toBe(settingsPath); const settings = readRuntimeSettingsFromLaunchArgs(); expect((settings.codex as Record).forced_login_method).toBe('chatgpt'); await svc.cancelProvisioning(runId); }); });