diff --git a/scripts/lib/opencode-live-preflight.mjs b/scripts/lib/opencode-live-preflight.mjs index 176720fa..379e5463 100644 --- a/scripts/lib/opencode-live-preflight.mjs +++ b/scripts/lib/opencode-live-preflight.mjs @@ -11,6 +11,9 @@ const OPENCODE_HEALTH_FETCH_TIMEOUT_MS = 1_000; export async function preflightOpenCodeLiveEnvironment(input) { const repoRoot = input.repoRoot; + const requiredModels = Array.isArray(input.requiredModels) + ? input.requiredModels.map((model) => String(model).trim()).filter(Boolean) + : []; const opencodeBin = process.env.OPENCODE_BIN?.trim() || '/opt/homebrew/bin/opencode'; const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-live-preflight-')); const xdgDataHome = path.join(tempRoot, 'xdg-data'); @@ -29,6 +32,14 @@ export async function preflightOpenCodeLiveEnvironment(input) { if (!models.ok) { return skip(`opencode models failed: ${models.output}`); } + const missingModels = findMissingOpenCodeModels(models.output, requiredModels); + if (missingModels.length > 0) { + return skip( + `opencode models missing selected model(s): ${missingModels.join(', ')}. Available: ${compactOutput( + parseOpenCodeModels(models.output).join(', ') || 'none' + )}` + ); + } const agents = runOpenCodeCommand(opencodeBin, ['agent', 'list'], repoRoot, env); if (!agents.ok) { @@ -68,7 +79,7 @@ function runOpenCodeCommand(opencodeBin, args, cwd, env) { maxBuffer: 256_000, }); if (result.status === 0) { - return { ok: true, output: '' }; + return { ok: true, output: result.stdout || '' }; } return { ok: false, @@ -76,6 +87,19 @@ function runOpenCodeCommand(opencodeBin, args, cwd, env) { }; } +function parseOpenCodeModels(output) { + return output + .split(/\s+/) + .map((model) => model.trim()) + .filter(Boolean); +} + +function findMissingOpenCodeModels(output, requiredModels) { + if (requiredModels.length === 0) return []; + const available = new Set(parseOpenCodeModels(output)); + return requiredModels.filter((model) => !available.has(model)); +} + function canBindLoopback() { return new Promise((resolve) => { const server = net.createServer(); @@ -287,7 +311,9 @@ function compactOutput(value) { } export const __opencodeLivePreflightTestHooks = { + findMissingOpenCodeModels, isHealthyOpenCodeHostResponse, + parseOpenCodeModels, stopChild, taskkillProcessTree, }; diff --git a/scripts/prove-provider-launch-stress.mjs b/scripts/prove-provider-launch-stress.mjs index b757e964..eaea932b 100644 --- a/scripts/prove-provider-launch-stress.mjs +++ b/scripts/prove-provider-launch-stress.mjs @@ -12,6 +12,7 @@ import { preflightOpenCodeLiveEnvironment } from './lib/opencode-live-preflight. const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, '..'); +const DEFAULT_OPENCODE_MODEL = 'opencode/big-pickle'; const requestedOrder = process.env.PROVIDER_LAUNCH_STRESS_ORDER?.trim() || 'anthropic,codex,opencode,mixed'; @@ -28,6 +29,8 @@ const env = { process.env.CLAUDE_TEAM_PROCESS_RUNTIME_READY_TIMEOUT_MS?.trim() || '90000', CLAUDE_TEAM_PROCESS_INBOX_POLLER_READY_TIMEOUT_MS: process.env.CLAUDE_TEAM_PROCESS_INBOX_POLLER_READY_TIMEOUT_MS?.trim() || '30000', + PROVIDER_LAUNCH_STRESS_OPENCODE_MODEL: + process.env.PROVIDER_LAUNCH_STRESS_OPENCODE_MODEL?.trim() || DEFAULT_OPENCODE_MODEL, OPENCODE_E2E: '1', OPENCODE_E2E_USE_REAL_APP_CREDENTIALS: '1', OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1', @@ -47,7 +50,7 @@ console.log(`Anthropic auth: ${env.PROVIDER_LAUNCH_STRESS_ANTHROPIC_AUTH}`); console.log( `Models: anthropic=${env.PROVIDER_LAUNCH_STRESS_ANTHROPIC_MODEL || 'haiku'}, codex=${ env.PROVIDER_LAUNCH_STRESS_CODEX_MODEL || 'gpt-5.4-mini' - }, opencode=${env.PROVIDER_LAUNCH_STRESS_OPENCODE_MODEL || 'openai/gpt-5.4-mini'}` + }, opencode=${env.PROVIDER_LAUNCH_STRESS_OPENCODE_MODEL}` ); console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`); @@ -109,7 +112,10 @@ async function preflightProviderLaunchStress(input) { anthropic: needs.anthropic ? await preflightAnthropic(input.repoRoot) : { ok: true }, codex: needs.codex ? preflightCodex() : { ok: true }, opencode: needs.opencode - ? await preflightOpenCodeLiveEnvironment({ repoRoot: input.repoRoot }) + ? await preflightOpenCodeLiveEnvironment({ + repoRoot: input.repoRoot, + requiredModels: [env.PROVIDER_LAUNCH_STRESS_OPENCODE_MODEL], + }) : { ok: true }, }; const skipped = []; diff --git a/src/main/services/runtime/teamRuntimeSettingsBundle.ts b/src/main/services/runtime/teamRuntimeSettingsBundle.ts index 99bda9d9..8a01ba3c 100644 --- a/src/main/services/runtime/teamRuntimeSettingsBundle.ts +++ b/src/main/services/runtime/teamRuntimeSettingsBundle.ts @@ -123,6 +123,7 @@ export async function materializeTeamRuntimeSettingsBundle(input: { providerId: TeamProviderId; baseSettings?: (TeamRuntimeSettingsJson | null | undefined)[]; anthropicHelper?: AnthropicTeamApiKeyHelperMaterial | null; + settingsDirectory?: string | null; }): Promise { const fragments = [...(input.baseSettings ?? [])].filter( (fragment): fragment is TeamRuntimeSettingsJson => @@ -145,7 +146,7 @@ export async function materializeTeamRuntimeSettingsBundle(input: { return null; } - const baseDirectory = input.anthropicHelper?.directory; + const baseDirectory = input.anthropicHelper?.directory ?? input.settingsDirectory; if (!baseDirectory) { return null; } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 4add31bf..b6779c3a 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1588,6 +1588,18 @@ function buildAnthropicSettingsArgs( return ['--settings', JSON.stringify(settings)]; } +function sanitizeRuntimeSettingsTeamName(teamName: string): string { + return teamName.replace(/[^a-zA-Z0-9._-]+/g, '_') || 'team'; +} + +function buildRuntimeSettingsTempDirectory(teamName: string): string { + return path.join( + os.tmpdir(), + 'agent-teams-runtime-settings', + `${sanitizeRuntimeSettingsTeamName(teamName)}-${randomUUID()}` + ); +} + function buildProviderFastModeArgs( providerId: TeamProviderId, launchIdentity?: ProviderModelLaunchIdentity | null @@ -7124,7 +7136,7 @@ export class TeamProvisioningService { const rawProviderArgs = input.envResolution.providerArgs ?? []; const rawExtraArgs = input.extraArgs ?? []; - if (!helper) { + if (!helper && resolvedProviderId !== 'anthropic') { return { settingsArgs: [], fastModeArgs: buildProviderFastModeArgs(resolvedProviderId, input.launchIdentity), @@ -7137,13 +7149,14 @@ export class TeamProvisioningService { const providerArgsWithoutHelper = filterOutSettingsPathArgs( rawProviderArgs, - helper.settingsPath + helper?.settingsPath ); const splitProviderArgs = splitSettingsJsonArgs(providerArgsWithoutHelper); const splitExtraArgs = splitSettingsJsonArgs(rawExtraArgs); if ( - hasPathBasedSettingsArgs(splitProviderArgs.passthroughArgs) || - hasPathBasedSettingsArgs(splitExtraArgs.passthroughArgs) + helper && + (hasPathBasedSettingsArgs(splitProviderArgs.passthroughArgs) || + hasPathBasedSettingsArgs(splitExtraArgs.passthroughArgs)) ) { throw new Error( `${input.contextLabel}: app-managed Anthropic API-key helper cannot be combined with path-based --settings. Use inline JSON settings or remove the custom --settings path.` @@ -7160,6 +7173,7 @@ export class TeamProvisioningService { ...splitExtraArgs.settingsFragments, ], anthropicHelper: helper, + settingsDirectory: helper ? null : buildRuntimeSettingsTempDirectory(input.teamName), }); return { diff --git a/test/main/services/team/MixedProviderTeamLaunch.live.test.ts b/test/main/services/team/MixedProviderTeamLaunch.live.test.ts index db246ebd..76a42071 100644 --- a/test/main/services/team/MixedProviderTeamLaunch.live.test.ts +++ b/test/main/services/team/MixedProviderTeamLaunch.live.test.ts @@ -40,7 +40,7 @@ const liveDescribe = const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source'; const DEFAULT_ANTHROPIC_MODEL = 'haiku'; const DEFAULT_CODEX_MODEL = 'gpt-5.4-mini'; -const DEFAULT_OPENCODE_MODEL = 'openai/gpt-5.4-mini'; +const DEFAULT_OPENCODE_MODEL = 'opencode/big-pickle'; liveDescribe('Mixed provider team launch live e2e', () => { let tempDir: string; diff --git a/test/main/services/team/ProviderLaunchStress.live-e2e.test.ts b/test/main/services/team/ProviderLaunchStress.live-e2e.test.ts index c0863de4..6c8763f4 100644 --- a/test/main/services/team/ProviderLaunchStress.live-e2e.test.ts +++ b/test/main/services/team/ProviderLaunchStress.live-e2e.test.ts @@ -42,7 +42,7 @@ const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_ const DEFAULT_ANTHROPIC_MODEL = 'haiku'; const DEFAULT_CODEX_MODEL = 'gpt-5.4-mini'; const DEFAULT_CODEX_EFFORT = 'low' as const; -const DEFAULT_OPENCODE_MODEL = 'openai/gpt-5.4-mini'; +const DEFAULT_OPENCODE_MODEL = 'opencode/big-pickle'; const DEFAULT_ORDER: ProviderLaunchStressScenario[] = ['anthropic', 'codex', 'opencode', 'mixed']; const MEMBER_NAMES = [ 'alice', diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index deee5f14..b7e2a31c 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -2864,6 +2864,42 @@ describe('TeamProvisioningService prepare/auth behavior', () => { expect(result.env.AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT).toBe('/tmp/runtime-hooks'); }); + it('materializes Anthropic turn-settled hook settings instead of passing inline JSON', async () => { + const svc = new TeamProvisioningService(); + svc.setRuntimeTurnSettledHookSettingsProvider(async ({ provider }) => + provider === 'claude' + ? { + hooks: { + Stop: [ + { + matcher: '', + hooks: [{ type: 'command', command: '/bin/true # test-hook' }], + }, + ], + }, + } + : null + ); + + const result = await (svc as any).buildTeamRuntimeLaunchArgsPlan({ + teamName: 'anthropic-hook-settings-team', + providerId: 'anthropic', + launchIdentity: null, + envResolution: { providerArgs: [] }, + extraArgs: [], + includeAnthropicHelper: false, + contextLabel: 'Team launch', + }); + + expect(result.fastModeArgs).toEqual([]); + expect(result.runtimeTurnSettledHookArgs).toEqual([]); + expect(result.settingsArgs[0]).toBe('--settings'); + const settingsPath = result.settingsArgs[1]; + expect(settingsPath).toContain('agent-teams-runtime-settings'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + expect(settings.hooks.Stop[0].hooks[0].command).toBe('/bin/true # test-hook'); + }); + it('adds Codex turn-settled env when Codex is only a secondary member provider', async () => { const svc = new TeamProvisioningService(); svc.setRuntimeTurnSettledEnvironmentProvider(async ({ provider }) => diff --git a/test/scripts/opencodeLivePreflight.test.ts b/test/scripts/opencodeLivePreflight.test.ts index 32f768fc..12621d2a 100644 --- a/test/scripts/opencodeLivePreflight.test.ts +++ b/test/scripts/opencodeLivePreflight.test.ts @@ -5,7 +5,6 @@ import { promises as fs } from 'fs'; import * as os from 'os'; import * as path from 'path'; import { pathToFileURL } from 'url'; - import { afterEach, describe, expect, it, vi } from 'vitest'; interface StopChildOptions { @@ -17,7 +16,9 @@ interface StopChildOptions { interface OpenCodeLivePreflightTestHooks { __opencodeLivePreflightTestHooks: { + findMissingOpenCodeModels(output: string, requiredModels: string[]): string[]; isHealthyOpenCodeHostResponse(response: { ok: boolean }): boolean; + parseOpenCodeModels(output: string): string[]; stopChild(child: FakeChild, options?: StopChildOptions): Promise; taskkillProcessTree(pid: number): Promise; }; @@ -47,6 +48,21 @@ describe('opencode live preflight cleanup', () => { expect(isHealthyOpenCodeHostResponse({ ok: false })).toBe(false); }); + it('detects selected OpenCode models missing from preflight output', async () => { + const { findMissingOpenCodeModels, parseOpenCodeModels } = (await loadTestHooks()) + .__opencodeLivePreflightTestHooks; + const output = 'opencode/big-pickle\nopencode/minimax-m2.5-free\n'; + + expect(parseOpenCodeModels(output)).toEqual([ + 'opencode/big-pickle', + 'opencode/minimax-m2.5-free', + ]); + expect(findMissingOpenCodeModels(output, ['opencode/big-pickle'])).toEqual([]); + expect(findMissingOpenCodeModels(output, ['openai/gpt-5.4-mini'])).toEqual([ + 'openai/gpt-5.4-mini', + ]); + }); + it('waits for child close after Windows process-tree cleanup', async () => { const { stopChild } = (await loadTestHooks()).__opencodeLivePreflightTestHooks; const child = new FakeChild({ pid: 1234 });