From fd50f736b8c064cb29fd24aac7cc881b7ee3aa6b Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 20 May 2026 22:49:42 +0300 Subject: [PATCH] fix(opencode): harden local provider launches --- .../runtime/OpenCodeTeamRuntimeAdapter.ts | 18 +- .../team/MemberWorkSyncOpenCode.live.test.ts | 6 +- ...odeDefaultModelResolution.live-e2e.test.ts | 182 ++++++++ ...penCodeLocalProviderAppLaunch.live.test.ts | 391 ++++++++++++++++++ .../OpenCodeTeamProvisioning.live.test.ts | 183 ++++++++ .../team/OpenCodeTeamRuntimeAdapter.test.ts | 99 ++++- 6 files changed, 868 insertions(+), 11 deletions(-) create mode 100644 test/main/services/team/OpenCodeDefaultModelResolution.live-e2e.test.ts create mode 100644 test/main/services/team/OpenCodeLocalProviderAppLaunch.live.test.ts diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index 52d21fa0..de4a649a 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -102,11 +102,12 @@ const SECRET_FLAG_PATTERN = const BEARER_TOKEN_PATTERN = /\bBearer\s+\S+/gi; const SECRET_KEY_PATTERN = /\bsk-[A-Za-z0-9_-]{16,}\b/g; const OPEN_CODE_CAPABILITY_SNAPSHOT_REFRESH_RETRY_WARNING = - 'OpenCode capability snapshot changed between readiness and launch; refreshed readiness and retried once.'; + 'OpenCode capability snapshot changed between readiness and launch; refreshed readiness and retried launch.'; const OPEN_CODE_CAPABILITY_SNAPSHOT_PRELAUNCH_MISMATCH_MARKERS = [ 'Bridge server capability snapshot mismatch', 'OpenCode bridge capability snapshot precondition mismatch', ]; +const OPEN_CODE_CAPABILITY_SNAPSHOT_REFRESH_RETRY_LIMIT = 3; const OPEN_CODE_READINESS_RETRY_DELAYS_MS = [750, 2_000] as const; type OpenCodeTeamLaunchReadinessInput = Parameters< @@ -303,7 +304,13 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { let data = await this.bridge.launchOpenCodeTeam( buildLaunchCommand(runtimeSnapshot, selectedModel) ); - if (!skipReadinessPreflight && isOpenCodePreLaunchCapabilitySnapshotMismatchData(data)) { + let capabilitySnapshotRefreshAttempts = 0; + while ( + !skipReadinessPreflight && + isOpenCodePreLaunchCapabilitySnapshotMismatchData(data) && + capabilitySnapshotRefreshAttempts < OPEN_CODE_CAPABILITY_SNAPSHOT_REFRESH_RETRY_LIMIT + ) { + capabilitySnapshotRefreshAttempts += 1; const refreshed = await this.prepare(input); if (!refreshed.ok) { return blockedLaunchResult( @@ -335,6 +342,8 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { `opencode-capability-recovery-${randomUUID()}` ) ); + } else { + break; } } @@ -689,6 +698,7 @@ function mapOpenCodeLaunchDataToRuntimeResult( member.name, fallbackLaunchState, bridgeMember?.sessionId, + bridgeMember?.model, bridgeMember?.runtimePid, bridgeMember?.pendingPermissionRequestIds, bridgeMember != null, @@ -792,6 +802,7 @@ function mapBridgeMemberToRuntimeEvidence( memberName: string, launchState: OpenCodeTeamMemberLaunchBridgeState, sessionId: string | undefined, + model: string | undefined, runtimePid: number | undefined, pendingPermissionRequestIds: string[] | undefined, runtimeMaterialized: boolean, @@ -855,6 +866,7 @@ function mapBridgeMemberToRuntimeEvidence( return { memberName, providerId: 'opencode', + ...(isNonEmptyString(model) ? { model: model.trim() } : {}), launchState: failed ? 'failed_to_start' : confirmed @@ -1074,6 +1086,7 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput) 'Do not mark the review complete from this prompt alone.', 'A visible agent-teams_message_send reply is optional. Concrete review progress, review tool usage, or agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) is sufficient response proof.', `If you cannot pick up the review now, call agent-teams_member_work_sync_status (or mcp__agent-teams__member_work_sync_status) with ${workSyncToolArgs}, then report state "blocked" or "still_working" only for the real current state.`, + 'Do not stop after member_work_sync_status. A status-only tool call is incomplete; member_work_sync_report is the required proof.', taskIds.length ? `Relevant taskIds: ${taskIds.map((id) => `"${id}"`).join(', ')}.` : null, `Do not use provider names, runtime names, or team names as memberName; use exactly "${input.memberName}".`, 'Do not reply only with acknowledgement.', @@ -1084,6 +1097,7 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput) 'A visible agent-teams_message_send reply is optional. Concrete task progress or agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) is sufficient response proof.', `Call agent-teams_member_work_sync_status (or mcp__agent-teams__member_work_sync_status) with ${workSyncToolArgs}.`, `Then call agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) with ${workSyncToolArgs}, the returned agendaFingerprint/reportToken, and state "still_working" or "blocked".`, + 'Do not stop after member_work_sync_status. A status-only tool call is incomplete; member_work_sync_report is the required proof.', taskIds.length ? `When reporting, include taskIds: ${taskIds.map((id) => `"${id}"`).join(', ')}.` : null, diff --git a/test/main/services/team/MemberWorkSyncOpenCode.live.test.ts b/test/main/services/team/MemberWorkSyncOpenCode.live.test.ts index 56d2ed58..92fea81f 100644 --- a/test/main/services/team/MemberWorkSyncOpenCode.live.test.ts +++ b/test/main/services/team/MemberWorkSyncOpenCode.live.test.ts @@ -1,7 +1,6 @@ import { promises as fs } from 'fs'; import * as os from 'os'; import * as path from 'path'; - import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { @@ -17,6 +16,7 @@ import { getTeamsBasePath, setClaudeBasePathOverride, } from '../../../../src/main/utils/pathDecoder'; + import { formatMemberWorkSyncDiagnostics, formatProgressDump, @@ -25,9 +25,9 @@ import { } from './memberWorkSyncLiveHarness'; import { createOpenCodeLiveHarness, + type OpenCodeLiveHarness, readInboxMessages, waitForOpenCodeLanesStopped, - type OpenCodeLiveHarness, } from './openCodeLiveTestHarness'; import type { TeamChangeEvent, TeamProvisioningProgress } from '../../../../src/shared/types'; @@ -69,7 +69,7 @@ liveDescribe('Member work sync OpenCode live e2e', () => { } else { await fs.rm(tempDir, { recursive: true, force: true }); } - }); + }, 90_000); it( 'delivers a work-sync nudge to a real OpenCode member and accepts its still-working report', diff --git a/test/main/services/team/OpenCodeDefaultModelResolution.live-e2e.test.ts b/test/main/services/team/OpenCodeDefaultModelResolution.live-e2e.test.ts new file mode 100644 index 00000000..b5ec4a49 --- /dev/null +++ b/test/main/services/team/OpenCodeDefaultModelResolution.live-e2e.test.ts @@ -0,0 +1,182 @@ +import { execFile } from 'child_process'; +import { constants as fsConstants, promises as fs } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { promisify } from 'util'; +import { describe, expect, it } from 'vitest'; + +import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService'; + +const liveDescribe = + process.env.OPENCODE_DEFAULT_MODEL_RESOLUTION_LIVE_E2E === '1' ? describe : describe.skip; + +const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli'; +const execFileAsync = promisify(execFile); + +interface ProviderModelListResponse { + providers?: { + opencode?: { + defaultModel?: string; + models?: Array<{ id?: string }>; + }; + }; +} + +interface RuntimeStatusResponse { + providers?: { + opencode?: { + models?: string[]; + modelCatalog?: { + defaultLaunchModel?: string | null; + models?: Array<{ id?: string; launchModel?: string; displayName?: string }>; + }; + }; + }; +} + +type DefaultModelResolver = { + resolveProviderDefaultModel: ( + claudePath: string, + cwd: string, + providerId: string, + env: NodeJS.ProcessEnv, + providerArgs: string[], + limitContext: boolean + ) => Promise; + materializeEffectiveTeamMemberSpecs: (params: { + claudePath: string; + cwd: string; + members: Array<{ name: string; providerId: 'opencode'; model?: string }>; + defaults: { providerId: 'anthropic' }; + primaryProviderId: 'opencode'; + primaryEnv: { + env: NodeJS.ProcessEnv; + authSource: string; + providerArgs: string[]; + geminiRuntimeAuth: null; + }; + providerArgsResolver: () => string[]; + limitContext: boolean; + }) => Promise>; +}; + +liveDescribe('OpenCode default model resolution live e2e', () => { + it('materializes an OpenCode Default teammate through the real model-list pipe', async () => { + const orchestratorCli = + process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; + await assertExecutable(orchestratorCli); + + const env: NodeJS.ProcessEnv = { + ...process.env, + PATH: withBunOnPath(process.env.PATH ?? ''), + OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1', + }; + const svc = new TeamProvisioningService() as unknown as DefaultModelResolver; + + const defaultModel = await svc.resolveProviderDefaultModel( + orchestratorCli, + process.cwd(), + 'opencode', + env, + [], + false + ); + + expect(defaultModel).toMatch(/^opencode\/.+/); + + await expect( + svc.materializeEffectiveTeamMemberSpecs({ + claudePath: orchestratorCli, + cwd: process.cwd(), + members: [{ name: 'atlas', providerId: 'opencode' }], + defaults: { providerId: 'anthropic' }, + primaryProviderId: 'opencode', + primaryEnv: { + env, + authSource: 'opencode_managed', + providerArgs: [], + geminiRuntimeAuth: null, + }, + providerArgsResolver: () => [], + limitContext: false, + }) + ).resolves.toEqual([{ name: 'atlas', providerId: 'opencode', model: defaultModel }]); + }, 60_000); + + it('keeps the real OpenCode catalog hydrated instead of summary-only big-pickle', async () => { + const orchestratorCli = + process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; + await assertExecutable(orchestratorCli); + + const env = buildOpenCodeLiveEnv(); + const modelList = await runJsonCommand( + orchestratorCli, + ['model', 'list', '--json', '--provider', 'opencode'], + env + ); + const modelListIds = + modelList.providers?.opencode?.models + ?.map((model) => model.id?.trim()) + .filter((id): id is string => Boolean(id)) ?? []; + + expect(modelList.providers?.opencode?.defaultModel).toBe('opencode/big-pickle'); + expect(modelListIds.length).toBeGreaterThan(50); + expect(modelListIds).toContain('opencode/big-pickle'); + expect(modelListIds.some((id) => id !== 'opencode/big-pickle')).toBe(true); + + const runtimeStatus = await runJsonCommand( + orchestratorCli, + ['runtime', 'status', '--json', '--provider', 'opencode'], + env + ); + const provider = runtimeStatus.providers?.opencode; + const catalogIds = + provider?.modelCatalog?.models + ?.map((model) => model.launchModel?.trim() || model.id?.trim()) + .filter((id): id is string => Boolean(id)) ?? []; + + expect(provider?.modelCatalog?.defaultLaunchModel).toBe('opencode/big-pickle'); + expect(provider?.models?.length ?? 0).toBeGreaterThan(50); + expect(catalogIds.length).toBeGreaterThan(50); + expect(catalogIds).toContain('opencode/big-pickle'); + }, 90_000); +}); + +async function assertExecutable(filePath: string): Promise { + await fs.access(filePath, fsConstants.X_OK); +} + +function withBunOnPath(value: string): string { + const candidates = [ + process.env.BUN_INSTALL ? path.join(process.env.BUN_INSTALL, 'bin') : null, + process.env.HOME ? path.join(process.env.HOME, '.bun', 'bin') : null, + '/opt/homebrew/bin', + '/usr/local/bin', + ].filter((candidate): candidate is string => Boolean(candidate)); + return [...candidates, value].join(path.delimiter); +} + +function buildOpenCodeLiveEnv(): NodeJS.ProcessEnv { + const realHome = os.userInfo().homedir; + return { + ...process.env, + HOME: realHome, + USERPROFILE: realHome, + PATH: withBunOnPath(process.env.PATH ?? ''), + OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1', + }; +} + +async function runJsonCommand( + binaryPath: string, + args: string[], + env: NodeJS.ProcessEnv +): Promise { + const { stdout } = await execFileAsync(binaryPath, args, { + cwd: process.cwd(), + env, + timeout: 60_000, + maxBuffer: 1024 * 1024, + }); + return JSON.parse(stdout) as T; +} diff --git a/test/main/services/team/OpenCodeLocalProviderAppLaunch.live.test.ts b/test/main/services/team/OpenCodeLocalProviderAppLaunch.live.test.ts new file mode 100644 index 00000000..82ff752e --- /dev/null +++ b/test/main/services/team/OpenCodeLocalProviderAppLaunch.live.test.ts @@ -0,0 +1,391 @@ +import { promises as fs } from 'node:fs'; +import * as http from 'node:http'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + getTeamsBasePath, + setClaudeBasePathOverride, +} from '../../../../src/main/utils/pathDecoder'; + +import { formatProgressDump } from './memberWorkSyncLiveHarness'; +import { + createOpenCodeLiveHarness, + type OpenCodeLiveHarness, + waitForOpenCodeLanesStopped, + waitUntil, +} from './openCodeLiveTestHarness'; + +import type { TeamProvisioningProgress } from '../../../../src/shared/types'; + +const liveDescribe = + process.env.OPENCODE_E2E === '1' && process.env.OPENCODE_E2E_LOCAL_PROVIDER_APP_LAUNCH === '1' + ? describe + : describe.skip; + +const LOCAL_MODEL = 'llama.cpp/qwen-test:0.5b'; + +liveDescribe('OpenCode local provider app launch live e2e', () => { + let tempDir: string; + let tempClaudeRoot: string; + let fakeServer: FakeOpenAiCompatibleServer | null; + let harness: OpenCodeLiveHarness | null; + let teamName: string | null; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-local-provider-app-launch-')); + tempClaudeRoot = path.join(tempDir, '.claude'); + await fs.mkdir(tempClaudeRoot, { recursive: true }); + setClaudeBasePathOverride(tempClaudeRoot); + fakeServer = null; + harness = null; + teamName = null; + }); + + afterEach(async () => { + if (harness && teamName) { + await harness.svc.stopTeam(teamName).catch(() => undefined); + await waitForOpenCodeLanesStopped(teamName); + } + await harness?.dispose().catch(() => undefined); + await fakeServer?.close().catch(() => undefined); + setClaudeBasePathOverride(null); + if (process.env.OPENCODE_E2E_KEEP_TEMP === '1') { + console.info(`[OpenCodeLocalProviderAppLaunch.live] preserved temp dir: ${tempDir}`); + } else { + await fs.rm(tempDir, { recursive: true, force: true }); + } + clearBenignSlowConfigReadWarnings(); + }, 90_000); + + it( + 'creates and stops an OpenCode team through the app service using a configured authless local provider', + async () => { + const projectPath = path.join(tempDir, 'project'); + await fs.mkdir(projectPath, { recursive: true }); + await fs.writeFile( + path.join(projectPath, 'README.md'), + '# OpenCode local provider app launch live e2e\n', + 'utf8' + ); + fakeServer = await startFakeOpenAiCompatibleServer(); + await writeFakeLocalOpenCodeConfig({ + projectPath, + baseUrl: fakeServer.baseUrl, + }); + + harness = await createOpenCodeLiveHarness({ + tempDir, + selectedModel: LOCAL_MODEL, + projectPath, + }); + + teamName = `opencode-local-provider-app-${Date.now()}`; + const progressEvents: TeamProvisioningProgress[] = []; + const { runId } = await harness.svc.createTeam( + { + teamName, + cwd: projectPath, + providerId: 'opencode', + model: LOCAL_MODEL, + skipPermissions: true, + members: [ + { + name: 'bob', + role: 'Developer', + providerId: 'opencode', + model: LOCAL_MODEL, + mcpPolicy: { mode: 'appOnly' }, + }, + ], + }, + (progress) => progressEvents.push(progress) + ); + + const progressDump = formatProgressDump(progressEvents); + expect(runId, progressDump).toBeTruthy(); + expect( + progressEvents.some((progress) => + progress.message.includes('OpenCode team launch is ready') + ), + progressDump + ).toBe(true); + expect(progressDump).not.toContain('provider not connected'); + expect(progressDump).not.toContain('not authenticated'); + expect(progressDump).not.toContain('OpenCode team launch is not enabled'); + expect(fakeServer.requests, progressDump).toContain('POST /v1/chat/completions'); + + const runtimeSnapshot = await harness.svc.getTeamAgentRuntimeSnapshot(teamName); + expect(runtimeSnapshot.runId).toBe(runId); + expect(runtimeSnapshot.members.bob).toMatchObject({ + alive: true, + providerId: 'opencode', + laneId: 'primary', + laneKind: 'primary', + runtimeModel: LOCAL_MODEL, + historicalBootstrapConfirmed: true, + }); + + const deliveryMarker = `local-provider-delivery-${Date.now()}`; + const chatBodyCountBeforeDelivery = fakeServer.chatBodies.length; + const delivery = await harness.svc.deliverOpenCodeMemberMessage(teamName, { + memberName: 'bob', + messageId: `local-provider-delivery-${Date.now()}`, + replyRecipient: 'user', + source: 'manual', + text: [ + `Local provider delivery marker: ${deliveryMarker}`, + 'Answer with PONG. Do not edit files.', + ].join('\n'), + }); + expect(delivery.delivered, JSON.stringify(delivery, null, 2)).toBe(true); + await waitUntil( + async () => + fakeServer!.chatBodies.length > chatBodyCountBeforeDelivery && + fakeServer!.chatBodies.some((body) => JSON.stringify(body).includes(deliveryMarker)), + 60_000, + 500 + ); + + await harness.svc.stopTeam(teamName); + await waitForOpenCodeLanesStopped(teamName); + clearBenignSlowConfigReadWarnings(); + }, + 300_000 + ); + + it( + 'fails app service launch for an unknown local model before creating OpenCode lanes', + async () => { + const projectPath = path.join(tempDir, 'unknown-model-project'); + await fs.mkdir(projectPath, { recursive: true }); + fakeServer = await startFakeOpenAiCompatibleServer(); + await writeFakeLocalOpenCodeConfig({ + projectPath, + baseUrl: fakeServer.baseUrl, + }); + + harness = await createOpenCodeLiveHarness({ + tempDir, + selectedModel: 'llama.cpp/missing-test:0.5b', + projectPath, + }); + + teamName = `opencode-local-provider-unknown-${Date.now()}`; + const progressEvents: TeamProvisioningProgress[] = []; + const { runId } = await harness.svc.createTeam( + { + teamName, + cwd: projectPath, + providerId: 'opencode', + model: 'llama.cpp/missing-test:0.5b', + skipPermissions: true, + members: [ + { + name: 'bob', + role: 'Developer', + providerId: 'opencode', + model: 'llama.cpp/missing-test:0.5b', + }, + ], + }, + (progress) => progressEvents.push(progress) + ); + expect(runId).toBeTruthy(); + await waitUntil( + async () => progressEvents.some((progress) => progress.state === 'failed'), + 30_000, + 500 + ); + + const progressDump = formatProgressDump(progressEvents); + expect(progressEvents.some((progress) => progress.state === 'failed'), progressDump).toBe( + true + ); + expect(progressDump).toMatch(/missing-test:0\.5b|not available|unavailable/i); + expect(fakeServer.requests, progressDump).not.toContain('POST /v1/chat/completions'); + await waitUntil( + async () => { + const laneIndexPath = path.join( + getTeamsBasePath(), + teamName!, + 'runtime', + 'opencode', + 'lanes.json' + ); + try { + const parsed = JSON.parse(await fs.readFile(laneIndexPath, 'utf8')) as { + lanes?: Record; + }; + return Object.keys(parsed.lanes ?? {}).length === 0; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return true; + } + throw error; + } + }, + 15_000, + 500 + ); + clearBenignSlowConfigReadWarnings(); + }, + 180_000 + ); +}); + +interface FakeOpenAiCompatibleServer { + baseUrl: string; + requests: string[]; + chatBodies: unknown[]; + close: () => Promise; +} + +async function startFakeOpenAiCompatibleServer(): Promise { + const requests: string[] = []; + const chatBodies: unknown[] = []; + const server = http.createServer(async (request, response) => { + requests.push(`${request.method ?? 'GET'} ${request.url ?? '/'}`); + if (request.url === '/v1/models') { + sendJson(response, 200, { + object: 'list', + data: [{ id: 'qwen-test:0.5b', object: 'model' }], + }); + return; + } + + if (request.method === 'POST' && request.url === '/v1/chat/completions') { + const body = JSON.parse((await readRequestBody(request)) || '{}') as { stream?: boolean }; + chatBodies.push(body); + if (body.stream) { + const created = Math.floor(Date.now() / 1000); + response.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + }); + response.write( + `data: ${JSON.stringify({ + id: 'chatcmpl-test', + object: 'chat.completion.chunk', + created, + model: 'qwen-test:0.5b', + choices: [ + { + index: 0, + delta: { role: 'assistant', content: 'PONG' }, + finish_reason: null, + }, + ], + })}\n\n` + ); + response.write( + `data: ${JSON.stringify({ + id: 'chatcmpl-test', + object: 'chat.completion.chunk', + created, + model: 'qwen-test:0.5b', + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + })}\n\n` + ); + response.end('data: [DONE]\n\n'); + return; + } + + sendJson(response, 200, { + id: 'chatcmpl-test', + object: 'chat.completion', + model: 'qwen-test:0.5b', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'PONG' }, + finish_reason: 'stop', + }, + ], + }); + return; + } + + sendJson(response, 404, { error: { message: 'not found' } }); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + await closeServer(server); + throw new Error('Fake OpenAI-compatible server did not bind to a TCP port'); + } + + return { + baseUrl: `http://127.0.0.1:${address.port}`, + requests, + chatBodies, + close: () => closeServer(server), + }; +} + +async function writeFakeLocalOpenCodeConfig(input: { + projectPath: string; + baseUrl: string; +}): Promise { + const configPath = path.join(input.projectPath, 'opencode.json'); + await fs.writeFile( + configPath, + `${JSON.stringify( + { + provider: { + 'llama.cpp': { + npm: '@ai-sdk/openai-compatible', + options: { + baseURL: `${input.baseUrl}/v1`, + }, + models: { + 'qwen-test:0.5b': {}, + }, + }, + }, + model: LOCAL_MODEL, + small_model: LOCAL_MODEL, + }, + null, + 2 + )}\n`, + 'utf8' + ); +} + +async function readRequestBody(request: http.IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString('utf8'); +} + +function sendJson(response: http.ServerResponse, status: number, body: unknown): void { + response.writeHead(status, { 'content-type': 'application/json' }); + response.end(JSON.stringify(body)); +} + +function closeServer(server: http.Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} + +function clearBenignSlowConfigReadWarnings(): void { + const warn = vi.mocked(console.warn); + if ( + warn.mock.calls.length > 0 && + warn.mock.calls.every((call) => + call.map((part) => String(part)).join(' ').includes('[getConfig] slow read diag=') + ) + ) { + warn.mockClear(); + } +} diff --git a/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts b/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts index a80f4837..57ed3cce 100644 --- a/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts +++ b/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts @@ -40,6 +40,8 @@ const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_ 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; @@ -205,6 +207,187 @@ liveDescribe('OpenCode team provisioning live e2e', () => { .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), + }; + 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: { diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index c95a6329..8250b9ff 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -1,15 +1,15 @@ import { describe, expect, it, vi } from 'vitest'; +import { REQUIRED_AGENT_TEAMS_APP_TOOL_IDS } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability'; import { OpenCodeTeamRuntimeAdapter, type OpenCodeTeamRuntimeBridgePort, type TeamRuntimeLaunchInput, } from '../../../../src/main/services/team/runtime'; -import type { OpenCodeTeamLaunchReadiness } from '../../../../src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness'; import type { OpenCodeLaunchTeamCommandData } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract'; +import type { OpenCodeTeamLaunchReadiness } from '../../../../src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness'; import type { PersistedTeamLaunchSnapshot } from '../../../../src/shared/types'; -import { REQUIRED_AGENT_TEAMS_APP_TOOL_IDS } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability'; describe('OpenCodeTeamRuntimeAdapter', () => { it('maps readiness failures to a structured prepare block', async () => { @@ -376,6 +376,44 @@ describe('OpenCodeTeamRuntimeAdapter', () => { ); }); + it('launches model-less Default selections with the readiness-resolved model', async () => { + const launchOpenCodeTeam = vi.fn< + NonNullable + >(async () => successfulOpenCodeLaunchData({ model: 'opencode/big-pickle' })); + const adapter = new OpenCodeTeamRuntimeAdapter( + bridgePort( + readiness({ + state: 'ready', + launchAllowed: true, + modelId: 'opencode/big-pickle', + availableModels: ['opencode/big-pickle'], + }), + { launchOpenCodeTeam } + ) + ); + + const result = await adapter.launch( + launchInput({ + model: undefined, + expectedMembers: [ + { + name: 'alice', + providerId: 'opencode', + cwd: '/repo', + }, + ], + }) + ); + + expect(result.teamLaunchState).toBe('clean_success'); + expect(launchOpenCodeTeam).toHaveBeenCalledWith( + expect.objectContaining({ + selectedModel: 'opencode/big-pickle', + }) + ); + expect(result.members.alice?.model).toBe('opencode/big-pickle'); + }); + it('uses concrete member diagnostics as failed OpenCode hard failure reasons', async () => { const concreteReason = 'Latest assistant message msg_123 failed with APIError - Insufficient credits. Add more using https://openrouter.ai/settings/credits'; @@ -607,7 +645,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { expect(result.teamLaunchState).toBe('clean_success'); expect(result.warnings).toContain( - 'OpenCode capability snapshot changed between readiness and launch; refreshed readiness and retried once.' + 'OpenCode capability snapshot changed between readiness and launch; refreshed readiness and retried launch.' ); expect(checkReadiness).toHaveBeenCalledTimes(2); expect(launchOpenCodeTeam).toHaveBeenCalledTimes(2); @@ -637,6 +675,50 @@ describe('OpenCodeTeamRuntimeAdapter', () => { ); }); + it('keeps refreshing bounded capability snapshot churn until launch observes the current snapshot', async () => { + let readinessCalls = 0; + const capabilitySnapshots = ['cap-1', 'cap-2', 'cap-3', 'cap-4']; + const checkReadiness = vi.fn< + OpenCodeTeamRuntimeBridgePort['checkOpenCodeTeamLaunchReadiness'] + >(() => { + readinessCalls += 1; + return Promise.resolve(readiness({ state: 'ready', launchAllowed: true })); + }); + const launchOpenCodeTeam = vi.fn< + NonNullable + >((input) => + Promise.resolve( + input.expectedCapabilitySnapshotId === 'cap-3' + ? successfulOpenCodeLaunchData() + : failedCapabilitySnapshotLaunchData('Bridge server capability snapshot mismatch') + ) + ); + const adapter = new OpenCodeTeamRuntimeAdapter({ + checkOpenCodeTeamLaunchReadiness: checkReadiness, + getLastOpenCodeRuntimeSnapshot: vi.fn( + () => runtimeSnapshot(capabilitySnapshots[Math.max(0, Math.min(readinessCalls - 1, 3))] ?? 'cap-4') + ), + launchOpenCodeTeam, + }); + + const result = await adapter.launch(launchInput()); + + expect(result.teamLaunchState).toBe('clean_success'); + expect(result.warnings).toContain( + 'OpenCode capability snapshot changed between readiness and launch; refreshed readiness and retried launch.' + ); + expect(checkReadiness).toHaveBeenCalledTimes(3); + expect(launchOpenCodeTeam).toHaveBeenCalledTimes(3); + expect(launchOpenCodeTeam.mock.calls.map((call) => call[0].expectedCapabilitySnapshotId)).toEqual( + ['cap-1', 'cap-2', 'cap-3'] + ); + expect( + launchOpenCodeTeam.mock.calls.slice(1).every((call) => + /^opencode-capability-recovery-/.test(call[0].capabilitySnapshotRecoveryAttemptId ?? '') + ) + ).toBe(true); + }); + it('uses a fresh recovery attempt id when capability refresh returns the same snapshot', async () => { let readinessCalls = 0; const checkReadiness = vi.fn< @@ -775,7 +857,8 @@ describe('OpenCodeTeamRuntimeAdapter', () => { const result = await adapter.launch(launchInput()); expect(result.teamLaunchState).toBe('partial_failure'); - expect(launchOpenCodeTeam).toHaveBeenCalledTimes(2); + expect(checkReadiness).toHaveBeenCalledTimes(4); + expect(launchOpenCodeTeam).toHaveBeenCalledTimes(4); expect(result.diagnostics).toContain( 'error:opencode_bridge: OpenCode bridge failed: OpenCode bridge capability snapshot precondition mismatch' ); @@ -1094,6 +1177,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { expect(sentText).toContain('agent-teams_member_work_sync_status'); expect(sentText).toContain('agent-teams_member_work_sync_report'); expect(sentText).toContain('mcp__agent-teams__member_work_sync_report'); + expect(sentText).toContain('A status-only tool call is incomplete'); expect(sentText).toContain('teamName="team-a"'); expect(sentText).toContain('memberName="bob"'); expect(sentText).toContain('controlUrl="http://127.0.0.1:43123"'); @@ -1143,6 +1227,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { expect(sentText).toContain('review workflow tools'); expect(sentText).toContain('Do not mark the review complete from this prompt alone.'); expect(sentText).toContain('agent-teams_member_work_sync_report'); + expect(sentText).toContain('A status-only tool call is incomplete'); expect(sentText).not.toContain('This delivered app message is a member-work-sync nudge.'); }); @@ -1544,7 +1629,9 @@ function runtimeSnapshot(capabilitySnapshotId: string) { }; } -function successfulOpenCodeLaunchData(): OpenCodeLaunchTeamCommandData { +function successfulOpenCodeLaunchData( + overrides: { model?: string } = {} +): OpenCodeLaunchTeamCommandData { return { runId: 'run-1', teamLaunchState: 'ready', @@ -1553,7 +1640,7 @@ function successfulOpenCodeLaunchData(): OpenCodeLaunchTeamCommandData { sessionId: 'oc-session-1', launchState: 'confirmed_alive', runtimePid: 123, - model: 'openai/gpt-5.4-mini', + model: overrides.model ?? 'openai/gpt-5.4-mini', evidence: [ { kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' }, { kind: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' },