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(); } }