import { constants as fsConstants, promises as fs } from 'fs'; import * as os from 'os'; import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { applyOpenCodeAutoUpdatePolicy } from '../../../../src/main/services/runtime/openCodeAutoUpdatePolicy'; import { OpenCodeBridgeCommandClient } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient'; import { createOpenCodeBridgeCommandLeaseStore, createOpenCodeBridgeCommandLedgerStore, } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore'; import { createOpenCodeBridgeClientIdentity, OpenCodeBridgeCommandHandshakePort, } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient'; import { OpenCodeReadinessBridge } from '../../../../src/main/services/team/opencode/bridge/OpenCodeReadinessBridge'; import { OpenCodeStateChangingBridgeCommandService } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService'; import { readOpenCodeRuntimeLaneIndex } from '../../../../src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader'; import { OpenCodeTeamRuntimeAdapter } from '../../../../src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter'; import { TeamRuntimeAdapterRegistry } from '../../../../src/main/services/team/runtime/TeamRuntimeAdapter'; import { resolveAgentTeamsMcpLaunchSpec } from '../../../../src/main/services/team/TeamMcpConfigBuilder'; import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService'; import { getTeamsBasePath, setClaudeBasePathOverride, } from '../../../../src/main/utils/pathDecoder'; import type { RuntimeStoreManifestEvidence } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract'; import type { RuntimeStoreManifestReader } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService'; import type { OpenCodeBridgeCommandExecutor } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService'; import type { TeamProvisioningProgress } from '../../../../src/shared/types'; const liveDescribe = process.env.OPENCODE_E2E === '1' && process.env.OPENCODE_E2E_TEAM_PROVISIONING === '1' ? describe : describe.skip; const PROJECT_PATH = process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || process.cwd(); const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source'; const DEFAULT_MODEL = 'opencode/big-pickle'; liveDescribe('OpenCode team provisioning live e2e', () => { const liveDefaultModelIt = process.env.OPENCODE_E2E_DEFAULT_MODEL_LAUNCH === '1' ? it : it.skip; let tempDir: string; let tempClaudeRoot: string; beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-team-provisioning-e2e-')); tempClaudeRoot = path.join(tempDir, '.claude'); await fs.mkdir(tempClaudeRoot, { recursive: true }); setClaudeBasePathOverride(tempClaudeRoot); }); afterEach(async () => { setClaudeBasePathOverride(null); await fs.rm(tempDir, { recursive: true, force: true }); }); it('creates and stops a pure OpenCode team through TeamProvisioningService using the live runtime adapter', async () => { const selectedModel = process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL; const orchestratorCli = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; await assertExecutable(orchestratorCli); const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec(); const bridgeEnv = { ...createStableBridgeEnv(), PATH: withBunOnPath(process.env.PATH ?? ''), XDG_DATA_HOME: path.join(tempDir, 'xdg-data'), AGENT_TEAMS_MCP_CLAUDE_DIR: tempClaudeRoot, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args), CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON: JSON.stringify(mcpLaunchSpec.env ?? {}), }; const bridgeClient = new OpenCodeBridgeCommandClient({ binaryPath: orchestratorCli, tempDirectory: path.join(tempDir, 'bridge-input'), env: bridgeEnv, }); const stateChangingCommands = createStateChangingCommands({ bridge: bridgeClient, controlDir: path.join(tempDir, 'control'), }); const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, { stateChangingCommands, timeoutMs: 180_000, launchTimeoutMs: 180_000, reconcileTimeoutMs: 90_000, stopTimeoutMs: 90_000, }); const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge); const svc = new TeamProvisioningService(); svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); const teamName = `opencode-team-provisioning-${Date.now()}`; const progressEvents: TeamProvisioningProgress[] = []; try { const { runId } = await svc.createTeam( { teamName, cwd: PROJECT_PATH, providerId: 'opencode', model: selectedModel, skipPermissions: true, members: [ { name: 'alice', role: 'Developer', providerId: 'opencode', model: selectedModel, }, { name: 'bob', role: 'Reviewer', providerId: 'opencode', model: selectedModel, }, ], }, (progress) => { progressEvents.push(progress); } ); expect(runId).toBeTruthy(); const progressDump = progressEvents .map((progress) => [ progress.state, progress.message, progress.messageSeverity, progress.error, progress.cliLogsTail, ] .filter(Boolean) .join(' | ') ) .join('\n'); expect( progressEvents.some((progress) => progress.message.includes('OpenCode team launch is ready') ), progressDump ).toBe(true); expect( progressEvents.some((progress) => progress.message.includes('Validating OpenCode team launch gate') ), progressDump ).toBe(true); expect( progressEvents.some((progress) => progress.message.includes('Starting OpenCode sessions through runtime adapter') ), progressDump ).toBe(true); expect(progressDump).not.toContain('Starting Claude CLI process for team launch'); expect(progressDump).not.toContain('OpenCode team launch is not enabled'); const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName); expect(runtimeSnapshot.runId).toBe(runId); expect(runtimeSnapshot.members.alice).toMatchObject({ alive: true, providerId: 'opencode', laneId: 'primary', laneKind: 'primary', runtimeModel: selectedModel, historicalBootstrapConfirmed: true, }); expect(runtimeSnapshot.members.bob).toMatchObject({ alive: true, providerId: 'opencode', laneId: 'primary', laneKind: 'primary', runtimeModel: selectedModel, historicalBootstrapConfirmed: true, }); expect(hasOpenCodeRuntimeHandle(runtimeSnapshot.members.alice)).toBe(true); expect(hasOpenCodeRuntimeHandle(runtimeSnapshot.members.bob)).toBe(true); await expect( readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName) ).resolves.toMatchObject({ lanes: { primary: { state: 'active', }, }, }); await svc.stopTeam(teamName); await waitUntil(async () => { const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName); return Object.keys(laneIndex.lanes).length === 0; }, 90_000); } finally { await svc.stopTeam(teamName).catch(() => undefined); await readinessBridge .cleanupOpenCodeHosts({ reason: 'opencode-team-provisioning-live-e2e-cleanup', mode: 'force', projectPath: PROJECT_PATH, staleAgeMs: null, leaseStaleAgeMs: null, }) .catch(() => undefined); } }, 300_000); liveDefaultModelIt( 'creates and stops a pure OpenCode team when all OpenCode model selections are Default', async () => { const orchestratorCli = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; await assertExecutable(orchestratorCli); const projectPath = path.join(tempDir, 'default-model-project'); await fs.mkdir(projectPath, { recursive: true }); await fs.writeFile( path.join(projectPath, 'opencode.json'), `${JSON.stringify({ model: DEFAULT_MODEL, small_model: DEFAULT_MODEL }, null, 2)}\n` ); const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec(); const bridgeEnv = { ...createStableBridgeEnv(), PATH: withBunOnPath(process.env.PATH ?? ''), XDG_DATA_HOME: path.join(tempDir, 'xdg-data-default-model'), AGENT_TEAMS_MCP_CLAUDE_DIR: tempClaudeRoot, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args), CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON: JSON.stringify(mcpLaunchSpec.env ?? {}), }; const bridgeClient = new OpenCodeBridgeCommandClient({ binaryPath: orchestratorCli, tempDirectory: path.join(tempDir, 'bridge-input-default-model'), env: bridgeEnv, }); const stateChangingCommands = createStateChangingCommands({ bridge: bridgeClient, controlDir: path.join(tempDir, 'control-default-model'), }); const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, { stateChangingCommands, timeoutMs: 180_000, launchTimeoutMs: 180_000, reconcileTimeoutMs: 90_000, stopTimeoutMs: 90_000, }); const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge); const svc = new TeamProvisioningService(); svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); const teamName = `opencode-team-default-model-${Date.now()}`; const progressEvents: TeamProvisioningProgress[] = []; try { const { runId } = await svc.createTeam( { teamName, cwd: projectPath, providerId: 'opencode', skipPermissions: true, members: [ { name: 'atlas', role: 'Developer', providerId: 'opencode', }, ], }, (progress) => { progressEvents.push(progress); } ); expect(runId).toBeTruthy(); const progressDump = progressEvents .map((progress) => [ progress.state, progress.message, progress.messageSeverity, progress.error, progress.cliLogsTail, ] .filter(Boolean) .join(' | ') ) .join('\n'); expect( progressEvents.some((progress) => progress.message.includes('OpenCode team launch is ready') ), progressDump ).toBe(true); expect(progressDump).not.toContain('OpenCode launch requires a selected raw model id'); expect(progressDump).not.toContain('Failed to parse runtime default model list'); expect(progressDump).not.toContain('Failed to load runtime default model list'); const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName); expect(runtimeSnapshot.runId).toBe(runId); expect(runtimeSnapshot.members.atlas).toMatchObject({ alive: true, providerId: 'opencode', laneId: 'primary', laneKind: 'primary', runtimeModel: DEFAULT_MODEL, historicalBootstrapConfirmed: true, }); expect(hasOpenCodeRuntimeHandle(runtimeSnapshot.members.atlas)).toBe(true); await svc.stopTeam(teamName); await waitUntil(async () => { const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName); return Object.keys(laneIndex.lanes).length === 0; }, 90_000); const relaunchProgressEvents: TeamProvisioningProgress[] = []; const { runId: relaunchRunId } = await svc.launchTeam( { teamName, cwd: projectPath, providerId: 'opencode', skipPermissions: true, }, (progress) => { relaunchProgressEvents.push(progress); } ); expect(relaunchRunId).toBeTruthy(); expect(relaunchRunId).not.toBe(runId); const relaunchProgressDump = relaunchProgressEvents .map((progress) => [ progress.state, progress.message, progress.messageSeverity, progress.error, progress.cliLogsTail, ] .filter(Boolean) .join(' | ') ) .join('\n'); expect( relaunchProgressEvents.some((progress) => progress.message.includes('OpenCode team launch is ready') ), relaunchProgressDump ).toBe(true); expect(relaunchProgressDump).not.toContain( 'OpenCode launch requires a selected raw model id' ); expect(relaunchProgressDump).not.toContain('No OpenCode model is available'); expect(relaunchProgressDump).not.toContain('Failed to parse runtime default model list'); expect(relaunchProgressDump).not.toContain('Failed to load runtime default model list'); const relaunchedSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName); expect(relaunchedSnapshot.runId).toBe(relaunchRunId); expect(relaunchedSnapshot.members.atlas).toMatchObject({ alive: true, providerId: 'opencode', laneId: 'primary', laneKind: 'primary', runtimeModel: DEFAULT_MODEL, historicalBootstrapConfirmed: true, }); expect(hasOpenCodeRuntimeHandle(relaunchedSnapshot.members.atlas)).toBe(true); await svc.stopTeam(teamName); await waitUntil(async () => { const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName); return Object.keys(laneIndex.lanes).length === 0; }, 90_000); } finally { await svc.stopTeam(teamName).catch(() => undefined); await readinessBridge .cleanupOpenCodeHosts({ reason: 'opencode-team-default-model-live-e2e-cleanup', mode: 'force', projectPath, staleAgeMs: null, leaseStaleAgeMs: null, }) .catch(() => undefined); } }, 300_000 ); }); function createStateChangingCommands(input: { bridge: OpenCodeBridgeCommandExecutor; controlDir: string; }): OpenCodeStateChangingBridgeCommandService { const clientIdentity = createOpenCodeBridgeClientIdentity({ appVersion: '1.3.0-e2e', gitSha: null, buildId: 'opencode-team-provisioning-e2e', }); return new OpenCodeStateChangingBridgeCommandService({ expectedClientIdentity: clientIdentity, handshakePort: new OpenCodeBridgeCommandHandshakePort({ bridge: input.bridge, clientIdentity, }), leaseStore: createOpenCodeBridgeCommandLeaseStore({ filePath: path.join(input.controlDir, 'leases.json'), }), ledger: createOpenCodeBridgeCommandLedgerStore({ filePath: path.join(input.controlDir, 'ledger.json'), }), bridge: input.bridge, manifestReader: new StaticManifestReader(), }); } class StaticManifestReader implements RuntimeStoreManifestReader { async read(): Promise { return { highWatermark: 0, activeRunId: null, capabilitySnapshotId: null, }; } } async function waitUntil( predicate: () => Promise, timeoutMs: number, pollMs = 500 ): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (await predicate()) { return; } await new Promise((resolve) => setTimeout(resolve, pollMs)); } throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`); } async function assertExecutable(filePath: string): Promise { await fs.access(filePath, fsConstants.X_OK); } function withBunOnPath(pathValue: string): string { const bunDir = '/Users/belief/.bun/bin'; return pathValue.split(path.delimiter).includes(bunDir) ? pathValue : `${bunDir}${path.delimiter}${pathValue}`; } function createStableBridgeEnv(): NodeJS.ProcessEnv { const realHome = os.userInfo().homedir; const env = applyOpenCodeAutoUpdatePolicy({ ...process.env }); return { ...env, HOME: realHome, USERPROFILE: realHome, }; } function hasOpenCodeRuntimeHandle(member: { readonly pid?: number; readonly runtimePid?: number; readonly runtimeSessionId?: string; }): boolean { return ( (typeof member.pid === 'number' && member.pid > 0) || (typeof member.runtimePid === 'number' && member.runtimePid > 0) || (typeof member.runtimeSessionId === 'string' && member.runtimeSessionId.trim().length > 0) ); }