agent-ecosystem/test/main/services/team/OpenCodeLocalProviderAppLaunch.live.test.ts
2026-05-20 22:49:42 +03:00

391 lines
12 KiB
TypeScript

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<string, unknown>;
};
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<void>;
}
async function startFakeOpenAiCompatibleServer(): Promise<FakeOpenAiCompatibleServer> {
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<void>((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<void> {
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<string> {
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<void> {
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();
}
}