agent-ecosystem/test/main/services/team/AgentTeamsMcpHttpServer.integration.test.ts
777genius 6e4f8ff8c4 fix: stabilize opencode mcp transport refresh
Keep OpenCode app MCP transport evidence durable, refresh stale sessions without consuming normal delivery attempts, and keep recoverable runtime diagnostics out of member card errors.

Cover stable MCP restart/fallback, forced session refresh, resolved_behavior_changed recovery, and renderer diagnostics with regression and safe e2e tests.
2026-05-18 13:08:34 +03:00

481 lines
15 KiB
TypeScript

// @vitest-environment node
/* eslint-disable security/detect-non-literal-fs-filename, sonarjs/publicly-writable-directories */
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import http from 'node:http';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AgentTeamsMcpHttpServer } from '@main/services/team/AgentTeamsMcpHttpServer';
import { OpenCodeBridgeCommandClient } from '@main/services/team/opencode/bridge/OpenCodeBridgeCommandClient';
const FAKE_MCP_HTTP_SERVER_SOURCE = String.raw`
const fs = require('node:fs');
const http = require('node:http');
function readArg(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : null;
}
const host = readArg('--host') || '127.0.0.1';
const endpoint = readArg('--endpoint') || '/mcp';
const port = Number(readArg('--port'));
const controlFile = process.env.AGENT_TEAMS_MCP_TEST_CONTROL_FILE;
function readControl() {
if (!controlFile) {
return 'healthy';
}
try {
return fs.readFileSync(controlFile, 'utf8').trim() || 'healthy';
} catch {
return 'healthy';
}
}
function isUnhealthy() {
const control = readControl();
if (control === 'unhealthy-once:' + port) {
try {
fs.writeFileSync(controlFile, 'healthy');
} catch {}
return true;
}
if (control === 'unhealthy-pid:' + process.pid) {
return true;
}
return control === 'unhealthy-all' || control === 'unhealthy-port:' + port;
}
const server = http.createServer((request, response) => {
if (request.url === '/health') {
if (isUnhealthy()) {
response.writeHead(503, { 'content-type': 'text/plain' });
response.end('unhealthy');
return;
}
response.writeHead(200, { 'content-type': 'text/plain' });
response.end('ok');
return;
}
if (request.url === endpoint) {
response.writeHead(200, { 'content-type': 'application/json' });
response.end('{"jsonrpc":"2.0","result":{}}');
return;
}
response.writeHead(404, { 'content-type': 'text/plain' });
response.end('not found');
});
server.listen(port, host);
function shutdown() {
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 500).unref();
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
`;
const FAKE_OPENCODE_BRIDGE_BINARY_SOURCE = String.raw`#!/usr/bin/env node
const fs = require('node:fs');
const http = require('node:http');
function readArg(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : null;
}
function readHealthStatus(url) {
return new Promise((resolve) => {
if (!url) {
resolve(null);
return;
}
const target = new URL(url);
target.pathname = '/health';
target.search = '';
target.hash = '';
const request = http.get(
{
host: target.hostname,
port: Number(target.port),
path: target.pathname,
timeout: 750,
},
(response) => {
response.resume();
resolve(response.statusCode || null);
}
);
request.once('timeout', () => {
request.destroy();
resolve(null);
});
request.once('error', () => resolve(null));
});
}
async function main() {
const inputPath = readArg('--input');
if (!inputPath) {
console.error('missing --input');
process.exit(64);
}
const envelope = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
const mcpUrl = process.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL || null;
const healthStatus = await readHealthStatus(mcpUrl);
if (healthStatus !== 200) {
console.error(
JSON.stringify({
kind: 'mcp_unreachable',
mcpUrl,
healthStatus,
})
);
process.exit(7);
}
process.stdout.write(
JSON.stringify({
ok: true,
schemaVersion: envelope.schemaVersion,
requestId: envelope.requestId,
command: envelope.command,
completedAt: new Date().toISOString(),
durationMs: 1,
runtime: {
providerId: 'opencode',
binaryPath: process.argv[1],
binaryFingerprint: 'fake-runtime',
version: 'fake-opencode-bridge-e2e',
capabilitySnapshotId: 'fake-capabilities',
},
diagnostics: [],
data: {
runId: envelope.body && envelope.body.runId ? envelope.body.runId : null,
observedMcpUrl: mcpUrl,
healthStatus,
},
}) + '\n'
);
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : String(error));
process.exit(1);
});
`;
const describePosix = process.platform === 'win32' ? describe.skip : describe;
async function allocateLoopbackPort(excluded: Set<number> = new Set<number>()): Promise<number> {
while (true) {
const port = await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (!address || typeof address === 'string') {
server.close(() => reject(new Error('Failed to allocate test port')));
return;
}
server.close(() => resolve(address.port));
});
});
if (!excluded.has(port)) {
excluded.add(port);
return port;
}
}
}
async function readHealthStatus(url: string): Promise<number | null> {
const target = new URL(url);
target.pathname = '/health';
target.search = '';
target.hash = '';
return new Promise((resolve) => {
const request = http.get(
{
host: target.hostname,
port: Number(target.port),
path: target.pathname,
timeout: 500,
},
(response) => {
response.resume();
resolve(response.statusCode ?? null);
}
);
request.once('timeout', () => {
request.destroy();
resolve(null);
});
request.once('error', () => resolve(null));
});
}
async function waitForHealthDown(url: string): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < 5_000) {
if ((await readHealthStatus(url)) !== 200) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`Expected ${url} health endpoint to go down`);
}
async function writeFakeMcpHttpServer(tempDir: string): Promise<string> {
const scriptDir = path.join(tempDir, 'fake-mcp');
const scriptPath = path.join(scriptDir, 'server.cjs');
await mkdir(scriptDir, { recursive: true });
await writeFile(scriptPath, FAKE_MCP_HTTP_SERVER_SOURCE, 'utf8');
return scriptPath;
}
async function writeFakeOpenCodeBridgeBinary(tempDir: string): Promise<string> {
const scriptDir = path.join(tempDir, 'fake-runtime');
const scriptPath = path.join(scriptDir, 'claude-multimodel-fake');
await mkdir(scriptDir, { recursive: true });
await writeFile(scriptPath, FAKE_OPENCODE_BRIDGE_BINARY_SOURCE, 'utf8');
await chmod(scriptPath, 0o755);
return scriptPath;
}
describePosix('AgentTeamsMcpHttpServer integration', () => {
let tempDir: string | null = null;
let originalControlFileEnv: string | undefined;
const servers: AgentTeamsMcpHttpServer[] = [];
beforeEach(async () => {
tempDir = await mkdtemp(path.join(os.tmpdir(), 'agent-teams-mcp-http-integration-'));
originalControlFileEnv = process.env.AGENT_TEAMS_MCP_TEST_CONTROL_FILE;
});
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => server.stop()));
vi.mocked(console.warn).mockClear();
if (originalControlFileEnv === undefined) {
delete process.env.AGENT_TEAMS_MCP_TEST_CONTROL_FILE;
} else {
process.env.AGENT_TEAMS_MCP_TEST_CONTROL_FILE = originalControlFileEnv;
}
if (tempDir) {
await rm(tempDir, { recursive: true, force: true });
tempDir = null;
}
});
function createControlledServer(input: {
scriptPath: string;
controlFile: string;
allocatePort?: () => Promise<number>;
}): AgentTeamsMcpHttpServer {
const server = new AgentTeamsMcpHttpServer({
resolveLaunchSpec: () =>
Promise.resolve({
command: process.execPath,
args: [input.scriptPath],
}),
allocatePort: input.allocatePort,
});
servers.push(server);
process.env.AGENT_TEAMS_MCP_TEST_CONTROL_FILE = input.controlFile;
return server;
}
it('starts the actual Agent Teams MCP HTTP server and proves its health endpoint', async () => {
const server = new AgentTeamsMcpHttpServer();
servers.push(server);
const handle = await server.ensureStarted();
expect(handle.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/mcp$/);
expect(handle.pid).toEqual(expect.any(Number));
expect(await readHealthStatus(handle.url)).toBe(200);
});
it('reuses a healthy cached bridge URL after a real loopback health recheck', async () => {
const scriptPath = await writeFakeMcpHttpServer(tempDir!);
const controlFile = path.join(tempDir!, 'health-control.txt');
await writeFile(controlFile, 'healthy', 'utf8');
const server = createControlledServer({ scriptPath, controlFile });
const first = await server.ensureStarted();
const warnCountAfterFirstStart = vi.mocked(console.warn).mock.calls.length;
const second = await server.ensureStarted();
expect(second).toEqual(first);
expect(await readHealthStatus(first.url)).toBe(200);
expect(vi.mocked(console.warn).mock.calls.slice(warnCountAfterFirstStart)).toEqual([]);
});
it('restarts a stale but still-running MCP HTTP child when cached URL health turns unhealthy', async () => {
const scriptPath = await writeFakeMcpHttpServer(tempDir!);
const controlFile = path.join(tempDir!, 'health-control.txt');
const usedPorts = new Set<number>();
await writeFile(controlFile, 'healthy', 'utf8');
const server = createControlledServer({
scriptPath,
controlFile,
allocatePort: () => allocateLoopbackPort(usedPorts),
});
const first = await server.ensureStarted();
expect(first.pid).toEqual(expect.any(Number));
await writeFile(controlFile, `unhealthy-pid:${first.pid}`, 'utf8');
const second = await server.ensureStarted();
expect(second.port).toBe(first.port);
expect(second.url).toBe(first.url);
expect(second.pid).not.toBe(first.pid);
expect(await readHealthStatus(second.url)).toBe(200);
expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain('failed health reuse check');
vi.mocked(console.warn).mockClear();
});
it('recovers when the cached MCP HTTP child dies and the old URL refuses connections', async () => {
const scriptPath = await writeFakeMcpHttpServer(tempDir!);
const controlFile = path.join(tempDir!, 'health-control.txt');
const usedPorts = new Set<number>();
await writeFile(controlFile, 'healthy', 'utf8');
const server = createControlledServer({
scriptPath,
controlFile,
allocatePort: () => allocateLoopbackPort(usedPorts),
});
const first = await server.ensureStarted();
expect(first.pid).toEqual(expect.any(Number));
process.kill(first.pid!, 'SIGTERM');
await waitForHealthDown(first.url);
const second = await server.ensureStarted();
expect(second.port).not.toBe(first.port);
expect(second.pid).not.toBe(first.pid);
expect(await readHealthStatus(second.url)).toBe(200);
});
it('passes a refreshed healthy MCP URL into a real bridge child process after cached health goes stale', async () => {
const scriptPath = await writeFakeMcpHttpServer(tempDir!);
const bridgeBinaryPath = await writeFakeOpenCodeBridgeBinary(tempDir!);
const controlFile = path.join(tempDir!, 'health-control.txt');
const usedPorts = new Set<number>();
await writeFile(controlFile, 'healthy', 'utf8');
const server = createControlledServer({
scriptPath,
controlFile,
allocatePort: () => allocateLoopbackPort(usedPorts),
});
const bridgeEnv: NodeJS.ProcessEnv = {
PATH: process.env.PATH,
};
let requestIdCounter = 0;
const client = new OpenCodeBridgeCommandClient({
binaryPath: bridgeBinaryPath,
tempDirectory: path.join(tempDir!, 'bridge-input'),
env: bridgeEnv,
envProvider: async () => {
const mcpHttpServer = await server.ensureStarted();
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url;
return {
...bridgeEnv,
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL: mcpHttpServer.url,
};
},
requestIdFactory: () => {
requestIdCounter += 1;
return `req-refresh-${requestIdCounter}`;
},
});
const firstResult = await client.execute<{ runId: string }, { observedMcpUrl: string }>(
'opencode.launchTeam',
{ runId: 'run-1' },
{
cwd: tempDir!,
timeoutMs: 5_000,
}
);
expect(firstResult.ok).toBe(true);
if (!firstResult.ok) {
throw new Error(firstResult.error.message);
}
const firstHandle = server.getCurrentHandle();
expect(firstHandle?.pid).toEqual(expect.any(Number));
await writeFile(
controlFile,
`unhealthy-pid:${firstHandle?.pid}`,
'utf8'
);
const secondResult = await client.execute<{ runId: string }, { observedMcpUrl: string }>(
'opencode.launchTeam',
{ runId: 'run-2' },
{
cwd: tempDir!,
timeoutMs: 5_000,
}
);
expect(secondResult.ok).toBe(true);
if (!secondResult.ok) {
throw new Error(secondResult.error.message);
}
expect(secondResult.data.observedMcpUrl).toBe(firstResult.data.observedMcpUrl);
expect(await readHealthStatus(secondResult.data.observedMcpUrl)).toBe(200);
});
it('fails closed when a bridge child receives an unreachable MCP URL without env refresh', async () => {
const scriptPath = await writeFakeMcpHttpServer(tempDir!);
const bridgeBinaryPath = await writeFakeOpenCodeBridgeBinary(tempDir!);
const controlFile = path.join(tempDir!, 'health-control.txt');
await writeFile(controlFile, 'healthy', 'utf8');
const server = createControlledServer({ scriptPath, controlFile });
const first = await server.ensureStarted();
await server.stop();
await waitForHealthDown(first.url);
const client = new OpenCodeBridgeCommandClient({
binaryPath: bridgeBinaryPath,
tempDirectory: path.join(tempDir!, 'bridge-input-stale'),
env: {
PATH: process.env.PATH,
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL: first.url,
},
requestIdFactory: () => 'req-stale-mcp',
});
const result = await client.execute<{ runId: string }, { observedMcpUrl: string }>(
'opencode.launchTeam',
{ runId: 'run-stale' },
{
cwd: tempDir!,
timeoutMs: 5_000,
}
);
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error('Expected stale MCP URL to fail');
}
expect(result.error.kind).toBe('provider_error');
expect(result.error.details?.stderr).toContain('mcp_unreachable');
expect(result.error.details?.stderr).toContain(first.url);
});
});