Merge pull request #118 from 777genius/fix/hide-cli-child-windows-dev
fix(runtime): hide cli child windows by default
This commit is contained in:
commit
69572150c9
23 changed files with 1386 additions and 95 deletions
|
|
@ -1,10 +1,28 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { pathToFileURL } from 'node:url';
|
import { pathToFileURL } from 'url';
|
||||||
|
|
||||||
import { FastMCP } from 'fastmcp';
|
import { FastMCP } from 'fastmcp';
|
||||||
|
|
||||||
import { registerTools } from './tools';
|
import { registerTools } from './tools';
|
||||||
|
|
||||||
|
const HTTP_TRANSPORT = 'httpStream';
|
||||||
|
const STDIO_TRANSPORT = 'stdio';
|
||||||
|
const DEFAULT_HTTP_HOST = '127.0.0.1';
|
||||||
|
const DEFAULT_HTTP_ENDPOINT = '/mcp';
|
||||||
|
|
||||||
|
export type AgentTeamsMcpStartOptions =
|
||||||
|
| {
|
||||||
|
transportType: typeof STDIO_TRANSPORT;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
transportType: typeof HTTP_TRANSPORT;
|
||||||
|
httpStream: {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
endpoint: `/${string}`;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export function createServer() {
|
export function createServer() {
|
||||||
const server = new FastMCP({
|
const server = new FastMCP({
|
||||||
name: 'agent-teams-mcp',
|
name: 'agent-teams-mcp',
|
||||||
|
|
@ -16,9 +34,64 @@ export function createServer() {
|
||||||
return server;
|
return server;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getArgValue(argv: string[], name: string): string | null {
|
||||||
|
const directPrefix = `${name}=`;
|
||||||
|
for (let index = 2; index < argv.length; index += 1) {
|
||||||
|
const value = argv[index];
|
||||||
|
if (value === name) {
|
||||||
|
return argv[index + 1] ?? null;
|
||||||
|
}
|
||||||
|
if (value.startsWith(directPrefix)) {
|
||||||
|
return value.slice(directPrefix.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEndpoint(value: string | null | undefined): `/${string}` {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return DEFAULT_HTTP_ENDPOINT;
|
||||||
|
}
|
||||||
|
return (trimmed.startsWith('/') ? trimmed : `/${trimmed}`) as `/${string}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePort(value: string | null | undefined): number {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
|
||||||
|
throw new Error(`Invalid agent-teams MCP HTTP port: ${value ?? '<empty>'}`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveStartOptions(
|
||||||
|
argv: string[] = process.argv,
|
||||||
|
env: NodeJS.ProcessEnv = process.env
|
||||||
|
): AgentTeamsMcpStartOptions {
|
||||||
|
const transport =
|
||||||
|
getArgValue(argv, '--transport') ??
|
||||||
|
getArgValue(argv, '--transportType') ??
|
||||||
|
env.AGENT_TEAMS_MCP_TRANSPORT ??
|
||||||
|
STDIO_TRANSPORT;
|
||||||
|
|
||||||
|
if (transport !== HTTP_TRANSPORT) {
|
||||||
|
return { transportType: STDIO_TRANSPORT };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
transportType: HTTP_TRANSPORT,
|
||||||
|
httpStream: {
|
||||||
|
host:
|
||||||
|
getArgValue(argv, '--host')?.trim() ??
|
||||||
|
env.AGENT_TEAMS_MCP_HTTP_HOST?.trim() ??
|
||||||
|
DEFAULT_HTTP_HOST,
|
||||||
|
port: parsePort(getArgValue(argv, '--port') ?? env.AGENT_TEAMS_MCP_HTTP_PORT),
|
||||||
|
endpoint: normalizeEndpoint(getArgValue(argv, '--endpoint') ?? env.AGENT_TEAMS_MCP_HTTP_ENDPOINT),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
const server = createServer();
|
const server = createServer();
|
||||||
void server.start({
|
void server.start(resolveStartOptions());
|
||||||
transportType: 'stdio',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
54
mcp-server/test/startOptions.test.ts
Normal file
54
mcp-server/test/startOptions.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { resolveStartOptions } from '../src/index';
|
||||||
|
|
||||||
|
describe('agent-teams MCP start options', () => {
|
||||||
|
it('defaults to stdio transport', () => {
|
||||||
|
expect(resolveStartOptions(['node', 'index.js'], {})).toEqual({
|
||||||
|
transportType: 'stdio',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves HTTP stream transport from CLI args', () => {
|
||||||
|
expect(
|
||||||
|
resolveStartOptions(
|
||||||
|
[
|
||||||
|
'node',
|
||||||
|
'index.js',
|
||||||
|
'--transport',
|
||||||
|
'httpStream',
|
||||||
|
'--host',
|
||||||
|
'127.0.0.1',
|
||||||
|
'--port',
|
||||||
|
'43123',
|
||||||
|
'--endpoint',
|
||||||
|
'mcp',
|
||||||
|
],
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
transportType: 'httpStream',
|
||||||
|
httpStream: {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 43123,
|
||||||
|
endpoint: '/mcp',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves HTTP stream transport from environment', () => {
|
||||||
|
expect(
|
||||||
|
resolveStartOptions(['node', 'index.js'], {
|
||||||
|
AGENT_TEAMS_MCP_TRANSPORT: 'httpStream',
|
||||||
|
AGENT_TEAMS_MCP_HTTP_PORT: '43124',
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
transportType: 'httpStream',
|
||||||
|
httpStream: {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 43124,
|
||||||
|
endpoint: '/mcp',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -133,6 +133,7 @@ import {
|
||||||
import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor';
|
import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor';
|
||||||
import { HttpServer } from './services/infrastructure/HttpServer';
|
import { HttpServer } from './services/infrastructure/HttpServer';
|
||||||
import { clearAutoResumeService } from './services/team/AutoResumeService';
|
import { clearAutoResumeService } from './services/team/AutoResumeService';
|
||||||
|
import { agentTeamsMcpHttpServer } from './services/team/AgentTeamsMcpHttpServer';
|
||||||
import { LaunchIoGovernor } from './services/team/LaunchIoGovernor';
|
import { LaunchIoGovernor } from './services/team/LaunchIoGovernor';
|
||||||
import { OpenCodeBridgeCommandClient } from './services/team/opencode/bridge/OpenCodeBridgeCommandClient';
|
import { OpenCodeBridgeCommandClient } from './services/team/opencode/bridge/OpenCodeBridgeCommandClient';
|
||||||
import {
|
import {
|
||||||
|
|
@ -352,6 +353,33 @@ async function createOpenCodeRuntimeAdapterRegistry(
|
||||||
const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env });
|
const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env });
|
||||||
bridgeEnv.CLAUDE_TEAM_APP_INSTANCE_ID = openCodeManagedHostInstanceId;
|
bridgeEnv.CLAUDE_TEAM_APP_INSTANCE_ID = openCodeManagedHostInstanceId;
|
||||||
bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath();
|
bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath();
|
||||||
|
const applyMcpLaunchSpecEnv = async (
|
||||||
|
targetEnv: NodeJS.ProcessEnv,
|
||||||
|
options: { emitProgress?: boolean } = {}
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (options.emitProgress) {
|
||||||
|
reportProgress('runtime-mcp', 'Resolving Agent Teams MCP server...');
|
||||||
|
}
|
||||||
|
const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec({
|
||||||
|
onProgress: options.emitProgress
|
||||||
|
? ({ phase, message }) => reportProgress(`mcp-${phase}`, message)
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
const mcpEntry = mcpLaunchSpec.args[0];
|
||||||
|
if (mcpEntry) {
|
||||||
|
targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = mcpLaunchSpec.command;
|
||||||
|
targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY = mcpEntry;
|
||||||
|
targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON = JSON.stringify(mcpLaunchSpec.args);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
`[OpenCode] Runtime adapter bridge MCP entrypoint unresolved: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const appManagedOpenCodeBinary = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath();
|
const appManagedOpenCodeBinary = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath();
|
||||||
if (appManagedOpenCodeBinary && !bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH) {
|
if (appManagedOpenCodeBinary && !bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH) {
|
||||||
|
|
@ -381,29 +409,69 @@ async function createOpenCodeRuntimeAdapterRegistry(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
reportProgress('runtime-mcp', 'Resolving Agent Teams MCP server...');
|
reportProgress('runtime-mcp-http', 'Starting Agent Teams MCP server...');
|
||||||
const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec({
|
const mcpHttpServer = await agentTeamsMcpHttpServer.ensureStarted();
|
||||||
onProgress: ({ phase, message }) => reportProgress(`mcp-${phase}`, message),
|
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url;
|
||||||
});
|
reportProgress('runtime-mcp-http-ready', 'Agent Teams MCP server is ready...');
|
||||||
const mcpEntry = mcpLaunchSpec.args[0];
|
|
||||||
if (mcpEntry) {
|
|
||||||
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = mcpLaunchSpec.command;
|
|
||||||
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY = mcpEntry;
|
|
||||||
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON = JSON.stringify(mcpLaunchSpec.args);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[OpenCode] Runtime adapter bridge MCP entrypoint unresolved: ${
|
`[OpenCode] Runtime adapter bridge MCP HTTP server unavailable: ${
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
}`
|
}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (!bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL) {
|
||||||
|
await applyMcpLaunchSpecEnv(bridgeEnv, { emitProgress: true });
|
||||||
|
}
|
||||||
|
|
||||||
reportProgress('runtime-bridge', 'Preparing OpenCode bridge...');
|
reportProgress('runtime-bridge', 'Preparing OpenCode bridge...');
|
||||||
|
const resolveBridgeCommandEnv = async (): Promise<NodeJS.ProcessEnv> => {
|
||||||
|
const nextEnv = { ...bridgeEnv };
|
||||||
|
if (!bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL) {
|
||||||
|
return nextEnv;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const mcpHttpServer = await agentTeamsMcpHttpServer.ensureStarted();
|
||||||
|
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url;
|
||||||
|
nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url;
|
||||||
|
delete nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND;
|
||||||
|
delete nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY;
|
||||||
|
delete nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON;
|
||||||
|
} catch (error) {
|
||||||
|
delete nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL;
|
||||||
|
if (
|
||||||
|
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND &&
|
||||||
|
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY &&
|
||||||
|
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON
|
||||||
|
) {
|
||||||
|
nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND =
|
||||||
|
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND;
|
||||||
|
nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY =
|
||||||
|
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY;
|
||||||
|
nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON =
|
||||||
|
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON;
|
||||||
|
} else {
|
||||||
|
await applyMcpLaunchSpecEnv(nextEnv);
|
||||||
|
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND =
|
||||||
|
nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND;
|
||||||
|
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY =
|
||||||
|
nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY;
|
||||||
|
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON =
|
||||||
|
nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON;
|
||||||
|
}
|
||||||
|
logger.warn(
|
||||||
|
`[OpenCode] Runtime adapter bridge MCP HTTP server refresh failed: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return nextEnv;
|
||||||
|
};
|
||||||
const bridgeClient = new OpenCodeBridgeCommandClient({
|
const bridgeClient = new OpenCodeBridgeCommandClient({
|
||||||
binaryPath,
|
binaryPath,
|
||||||
tempDirectory: join(app.getPath('temp'), 'claude-team-opencode-bridge'),
|
tempDirectory: join(app.getPath('temp'), 'claude-team-opencode-bridge'),
|
||||||
env: bridgeEnv,
|
env: bridgeEnv,
|
||||||
|
envProvider: resolveBridgeCommandEnv,
|
||||||
});
|
});
|
||||||
const bridgeControlDir = join(app.getPath('userData'), 'opencode-bridge');
|
const bridgeControlDir = join(app.getPath('userData'), 'opencode-bridge');
|
||||||
const clientIdentity = createOpenCodeBridgeClientIdentity({
|
const clientIdentity = createOpenCodeBridgeClientIdentity({
|
||||||
|
|
@ -2081,6 +2149,9 @@ async function shutdownServices(): Promise<void> {
|
||||||
() => cleanupOpenCodeHostsForLifecycle('shutdown'),
|
() => cleanupOpenCodeHostsForLifecycle('shutdown'),
|
||||||
10_000
|
10_000
|
||||||
);
|
);
|
||||||
|
await runShutdownStep('Agent Teams MCP HTTP server cleanup', () =>
|
||||||
|
agentTeamsMcpHttpServer.stop()
|
||||||
|
);
|
||||||
await runShutdownStep('tracked CLI subprocess cleanup', () =>
|
await runShutdownStep('tracked CLI subprocess cleanup', () =>
|
||||||
killTrackedCliProcesses('SIGKILL')
|
killTrackedCliProcesses('SIGKILL')
|
||||||
);
|
);
|
||||||
|
|
|
||||||
232
src/main/services/team/AgentTeamsMcpHttpServer.ts
Normal file
232
src/main/services/team/AgentTeamsMcpHttpServer.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
import { killProcessTree, spawnCli } from '@main/utils/childProcess';
|
||||||
|
import { getClaudeBasePath } from '@main/utils/pathDecoder';
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { type ChildProcess } from 'child_process';
|
||||||
|
import http from 'http';
|
||||||
|
import net from 'net';
|
||||||
|
|
||||||
|
import { type McpLaunchSpec, resolveAgentTeamsMcpLaunchSpec } from './TeamMcpConfigBuilder';
|
||||||
|
|
||||||
|
const logger = createLogger('Service:AgentTeamsMcpHttpServer');
|
||||||
|
const MCP_HTTP_HOST = '127.0.0.1';
|
||||||
|
const MCP_HTTP_ENDPOINT = '/mcp';
|
||||||
|
const MCP_HTTP_READY_TIMEOUT_MS = 5_000;
|
||||||
|
const MCP_HTTP_READY_POLL_MS = 100;
|
||||||
|
|
||||||
|
export interface AgentTeamsMcpHttpServerHandle {
|
||||||
|
url: string;
|
||||||
|
port: number;
|
||||||
|
pid: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentTeamsMcpHttpServerDeps {
|
||||||
|
resolveLaunchSpec?: () => Promise<McpLaunchSpec>;
|
||||||
|
allocatePort?: () => Promise<number>;
|
||||||
|
spawnProcess?: (command: string, args: string[], env: NodeJS.ProcessEnv) => ChildProcess;
|
||||||
|
waitForPort?: (host: string, port: number, timeoutMs: number) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function allocateLoopbackPort(): Promise<number> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, MCP_HTTP_HOST, () => {
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === 'string') {
|
||||||
|
server.close(() => reject(new Error('Failed to allocate Agent Teams MCP HTTP port')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
server.close((error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(address.port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sleep(ms: number): Promise<void> {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isHealthReady(host: string, port: number): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const request = http.get(
|
||||||
|
{
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
path: '/health',
|
||||||
|
timeout: MCP_HTTP_READY_POLL_MS,
|
||||||
|
},
|
||||||
|
(response) => {
|
||||||
|
response.resume();
|
||||||
|
resolve((response.statusCode ?? 500) >= 200 && (response.statusCode ?? 500) < 300);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
request.once('timeout', () => {
|
||||||
|
request.destroy();
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
request.once('error', () => {
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForLoopbackPort(host: string, port: number, timeoutMs: number): Promise<void> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
while (Date.now() - startedAt < timeoutMs) {
|
||||||
|
if (await isHealthReady(host, port)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sleep(MCP_HTTP_READY_POLL_MS);
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Agent Teams MCP HTTP server did not become healthy at ${host}:${port} in ${timeoutMs}ms`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultSpawnProcess(
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
env: NodeJS.ProcessEnv
|
||||||
|
): ChildProcess {
|
||||||
|
return spawnCli(command, args, {
|
||||||
|
env,
|
||||||
|
stdio: ['ignore', 'ignore', 'pipe'],
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHttpServerArgs(launchSpec: McpLaunchSpec, port: number): string[] {
|
||||||
|
return [
|
||||||
|
...launchSpec.args,
|
||||||
|
'--transport',
|
||||||
|
'httpStream',
|
||||||
|
'--host',
|
||||||
|
MCP_HTTP_HOST,
|
||||||
|
'--port',
|
||||||
|
String(port),
|
||||||
|
'--endpoint',
|
||||||
|
MCP_HTTP_ENDPOINT,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AgentTeamsMcpHttpServer {
|
||||||
|
private startPromise: Promise<AgentTeamsMcpHttpServerHandle> | null = null;
|
||||||
|
private child: ChildProcess | null = null;
|
||||||
|
private handle: AgentTeamsMcpHttpServerHandle | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly deps: AgentTeamsMcpHttpServerDeps = {}) {}
|
||||||
|
|
||||||
|
async ensureStarted(): Promise<AgentTeamsMcpHttpServerHandle> {
|
||||||
|
if (this.handle) {
|
||||||
|
return this.handle;
|
||||||
|
}
|
||||||
|
if (this.startPromise) {
|
||||||
|
return this.startPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.startPromise = this.startOnce().finally(() => {
|
||||||
|
this.startPromise = null;
|
||||||
|
});
|
||||||
|
return this.startPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
const child = this.child;
|
||||||
|
this.child = null;
|
||||||
|
this.handle = null;
|
||||||
|
if (child) {
|
||||||
|
killProcessTree(child, 'SIGKILL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startOnce(): Promise<AgentTeamsMcpHttpServerHandle> {
|
||||||
|
const resolveLaunchSpec = this.deps.resolveLaunchSpec ?? resolveAgentTeamsMcpLaunchSpec;
|
||||||
|
const allocatePort = this.deps.allocatePort ?? allocateLoopbackPort;
|
||||||
|
const spawnProcess = this.deps.spawnProcess ?? defaultSpawnProcess;
|
||||||
|
const waitForPort = this.deps.waitForPort ?? waitForLoopbackPort;
|
||||||
|
const launchSpec = await resolveLaunchSpec();
|
||||||
|
const port = await allocatePort();
|
||||||
|
const args = buildHttpServerArgs(launchSpec, port);
|
||||||
|
const child = spawnProcess(launchSpec.command, args, {
|
||||||
|
...process.env,
|
||||||
|
AGENT_TEAMS_MCP_CLAUDE_DIR: getClaudeBasePath(),
|
||||||
|
AGENT_TEAMS_MCP_TRANSPORT: 'httpStream',
|
||||||
|
AGENT_TEAMS_MCP_HTTP_HOST: MCP_HTTP_HOST,
|
||||||
|
AGENT_TEAMS_MCP_HTTP_PORT: String(port),
|
||||||
|
AGENT_TEAMS_MCP_HTTP_ENDPOINT: MCP_HTTP_ENDPOINT,
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearIfCurrent = (): void => {
|
||||||
|
if (this.child === child) {
|
||||||
|
this.child = null;
|
||||||
|
this.handle = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
child.stderr?.on('data', (chunk: Buffer) => {
|
||||||
|
const text = chunk.toString('utf8').trim();
|
||||||
|
if (text) {
|
||||||
|
logger.debug(`Agent Teams MCP HTTP stderr: ${text.slice(0, 1000)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.child = child;
|
||||||
|
|
||||||
|
let startupSettled = false;
|
||||||
|
const startupFailure = new Promise<never>((_, reject) => {
|
||||||
|
child.once('exit', (code, signal) => {
|
||||||
|
clearIfCurrent();
|
||||||
|
const codeSuffix = typeof code === 'number' ? ` with code ${code}` : '';
|
||||||
|
const signalSuffix = signal ? ` (${signal})` : '';
|
||||||
|
const message = `Agent Teams MCP HTTP server exited before startup completed${codeSuffix}${signalSuffix}`;
|
||||||
|
if (!startupSettled) {
|
||||||
|
reject(new Error(message));
|
||||||
|
}
|
||||||
|
logger.warn(message);
|
||||||
|
});
|
||||||
|
child.once('error', (error) => {
|
||||||
|
clearIfCurrent();
|
||||||
|
const message = `Agent Teams MCP HTTP server process error: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`;
|
||||||
|
if (!startupSettled) {
|
||||||
|
reject(error instanceof Error ? error : new Error(message));
|
||||||
|
}
|
||||||
|
logger.warn(message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
waitForPort(MCP_HTTP_HOST, port, MCP_HTTP_READY_TIMEOUT_MS),
|
||||||
|
startupFailure,
|
||||||
|
]);
|
||||||
|
if (this.child !== child) {
|
||||||
|
throw new Error('Agent Teams MCP HTTP server exited before startup completed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
startupSettled = true;
|
||||||
|
if (this.child === child) {
|
||||||
|
this.child = null;
|
||||||
|
this.handle = null;
|
||||||
|
}
|
||||||
|
killProcessTree(child, 'SIGKILL');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
startupSettled = true;
|
||||||
|
this.handle = {
|
||||||
|
url: `http://${MCP_HTTP_HOST}:${port}${MCP_HTTP_ENDPOINT}`,
|
||||||
|
port,
|
||||||
|
pid: child.pid ?? null,
|
||||||
|
};
|
||||||
|
logger.info(`Agent Teams MCP HTTP server running at ${this.handle.url}`);
|
||||||
|
return this.handle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const agentTeamsMcpHttpServer = new AgentTeamsMcpHttpServer();
|
||||||
|
|
@ -54,6 +54,7 @@ export class GitDiffFallback {
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
maxBuffer: GIT_MAX_BUFFER,
|
maxBuffer: GIT_MAX_BUFFER,
|
||||||
timeout: GIT_TIMEOUT,
|
timeout: GIT_TIMEOUT,
|
||||||
|
windowsHide: true,
|
||||||
});
|
});
|
||||||
return stdout;
|
return stdout;
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -75,7 +76,7 @@ export class GitDiffFallback {
|
||||||
const { stdout } = await execFileAsync(
|
const { stdout } = await execFileAsync(
|
||||||
'git',
|
'git',
|
||||||
['log', '--format=%H', '--before', timestamp, '-1', '--', relativePath],
|
['log', '--format=%H', '--before', timestamp, '-1', '--', relativePath],
|
||||||
{ cwd: projectPath, timeout: GIT_TIMEOUT }
|
{ cwd: projectPath, timeout: GIT_TIMEOUT, windowsHide: true }
|
||||||
);
|
);
|
||||||
return stdout.trim() || null;
|
return stdout.trim() || null;
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -98,7 +99,7 @@ export class GitDiffFallback {
|
||||||
const { stdout } = await execFileAsync(
|
const { stdout } = await execFileAsync(
|
||||||
'git',
|
'git',
|
||||||
['diff', fromCommit, toCommit, '--', relativePath],
|
['diff', fromCommit, toCommit, '--', relativePath],
|
||||||
{ cwd: projectPath, timeout: GIT_TIMEOUT }
|
{ cwd: projectPath, timeout: GIT_TIMEOUT, windowsHide: true }
|
||||||
);
|
);
|
||||||
return stdout || null;
|
return stdout || null;
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -120,7 +121,7 @@ export class GitDiffFallback {
|
||||||
const { stdout } = await execFileAsync(
|
const { stdout } = await execFileAsync(
|
||||||
'git',
|
'git',
|
||||||
['log', `--max-count=${maxCount}`, '--format=%H|%aI|%s', '--', relativePath],
|
['log', `--max-count=${maxCount}`, '--format=%H|%aI|%s', '--', relativePath],
|
||||||
{ cwd: projectPath, timeout: GIT_TIMEOUT }
|
{ cwd: projectPath, timeout: GIT_TIMEOUT, windowsHide: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
return stdout
|
return stdout
|
||||||
|
|
@ -148,6 +149,7 @@ export class GitDiffFallback {
|
||||||
await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], {
|
await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], {
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
timeout: GIT_TIMEOUT,
|
timeout: GIT_TIMEOUT,
|
||||||
|
windowsHide: true,
|
||||||
});
|
});
|
||||||
this.gitRepoCache.set(projectPath, true);
|
this.gitRepoCache.set(projectPath, true);
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
|
import { execCli } from '@main/utils/childProcess';
|
||||||
import {
|
import {
|
||||||
getClaudeBasePath,
|
getClaudeBasePath,
|
||||||
getMcpConfigsBasePath,
|
getMcpConfigsBasePath,
|
||||||
getMcpServerBasePath,
|
getMcpServerBasePath,
|
||||||
} from '@main/utils/pathDecoder';
|
} from '@main/utils/pathDecoder';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { execFile } from 'child_process';
|
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
@ -185,17 +185,11 @@ async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise<s
|
||||||
|
|
||||||
try {
|
try {
|
||||||
emitProgress(options, 'node-runtime', 'Resolving Node.js runtime for MCP server...');
|
emitProgress(options, 'node-runtime', 'Resolving Node.js runtime for MCP server...');
|
||||||
const resolved = await new Promise<string>((resolve, reject) => {
|
const { stdout } = await execCli('node', ['-e', 'process.stdout.write(process.execPath)'], {
|
||||||
execFile(
|
encoding: 'utf-8',
|
||||||
'node',
|
timeout: 5000,
|
||||||
['-e', 'process.stdout.write(process.execPath)'],
|
|
||||||
{
|
|
||||||
encoding: 'utf-8',
|
|
||||||
timeout: 5000,
|
|
||||||
},
|
|
||||||
(err, stdout) => (err ? reject(err) : resolve(stdout.trim()))
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
const resolved = stdout.trim();
|
||||||
if (resolved) {
|
if (resolved) {
|
||||||
_resolvedNodePath = resolved;
|
_resolvedNodePath = resolved;
|
||||||
emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...');
|
emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...');
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ function execGit(args: string[], cwd: string): Promise<string> {
|
||||||
execFile(
|
execFile(
|
||||||
'git',
|
'git',
|
||||||
args,
|
args,
|
||||||
{ cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: 1024 * 1024 },
|
{ cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: 1024 * 1024, windowsHide: true },
|
||||||
(error, stdout, stderr) => {
|
(error, stdout, stderr) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
const message = String(stderr || error.message || 'git command failed').trim();
|
const message = String(stderr || error.message || 'git command failed').trim();
|
||||||
|
|
|
||||||
|
|
@ -5996,6 +5996,10 @@ export class TeamProvisioningService {
|
||||||
private toolApprovalSettingsByTeam = new Map<string, ToolApprovalSettings>();
|
private toolApprovalSettingsByTeam = new Map<string, ToolApprovalSettings>();
|
||||||
private pendingTimeouts = new Map<string, NodeJS.Timeout>();
|
private pendingTimeouts = new Map<string, NodeJS.Timeout>();
|
||||||
private inFlightResponses = new Set<string>();
|
private inFlightResponses = new Set<string>();
|
||||||
|
private readonly prepareForProvisioningInFlight = new Map<
|
||||||
|
string,
|
||||||
|
Promise<TeamProvisioningPrepareResult>
|
||||||
|
>();
|
||||||
private runtimeAdapterRegistry: TeamRuntimeAdapterRegistry | null = null;
|
private runtimeAdapterRegistry: TeamRuntimeAdapterRegistry | null = null;
|
||||||
private controlApiBaseUrlResolver: (() => Promise<string | null>) | null = null;
|
private controlApiBaseUrlResolver: (() => Promise<string | null>) | null = null;
|
||||||
private workspaceTrustCoordinator: WorkspaceTrustCoordinator | null = null;
|
private workspaceTrustCoordinator: WorkspaceTrustCoordinator | null = null;
|
||||||
|
|
@ -6426,6 +6430,7 @@ export class TeamProvisioningService {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
maxBuffer: 16 * 1024,
|
maxBuffer: 16 * 1024,
|
||||||
timeout: 1000,
|
timeout: 1000,
|
||||||
|
windowsHide: true,
|
||||||
},
|
},
|
||||||
(error, stdout) => {
|
(error, stdout) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
@ -18063,6 +18068,74 @@ export class TeamProvisioningService {
|
||||||
limitContext?: boolean;
|
limitContext?: boolean;
|
||||||
modelVerificationMode?: TeamProvisioningModelVerificationMode;
|
modelVerificationMode?: TeamProvisioningModelVerificationMode;
|
||||||
}
|
}
|
||||||
|
): Promise<TeamProvisioningPrepareResult> {
|
||||||
|
const inFlightKey = this.createPrepareForProvisioningInFlightKey(cwd, opts);
|
||||||
|
const inFlight = this.prepareForProvisioningInFlight.get(inFlightKey);
|
||||||
|
if (inFlight) {
|
||||||
|
return this.clonePrepareForProvisioningResult(await inFlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = this.prepareForProvisioningOnce(cwd, opts).finally(() => {
|
||||||
|
if (this.prepareForProvisioningInFlight.get(inFlightKey) === request) {
|
||||||
|
this.prepareForProvisioningInFlight.delete(inFlightKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.prepareForProvisioningInFlight.set(inFlightKey, request);
|
||||||
|
return this.clonePrepareForProvisioningResult(await request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createPrepareForProvisioningInFlightKey(
|
||||||
|
cwd?: string,
|
||||||
|
opts?: {
|
||||||
|
forceFresh?: boolean;
|
||||||
|
providerId?: TeamProviderId;
|
||||||
|
providerIds?: TeamProviderId[];
|
||||||
|
modelIds?: string[];
|
||||||
|
limitContext?: boolean;
|
||||||
|
modelVerificationMode?: TeamProvisioningModelVerificationMode;
|
||||||
|
}
|
||||||
|
): string {
|
||||||
|
const providerIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
[opts?.providerId, ...(opts?.providerIds ?? [])]
|
||||||
|
.map((providerId) => resolveTeamProviderId(providerId))
|
||||||
|
.filter((providerId): providerId is TeamProviderId => Boolean(providerId))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const modelIds = Array.from(
|
||||||
|
new Set((opts?.modelIds ?? []).map((modelId) => modelId.trim()).filter(Boolean))
|
||||||
|
);
|
||||||
|
return JSON.stringify({
|
||||||
|
cwd: cwd?.trim() || process.cwd(),
|
||||||
|
forceFresh: opts?.forceFresh === true,
|
||||||
|
providerIds,
|
||||||
|
modelIds,
|
||||||
|
limitContext: opts?.limitContext === true,
|
||||||
|
modelVerificationMode: opts?.modelVerificationMode ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private clonePrepareForProvisioningResult(
|
||||||
|
result: TeamProvisioningPrepareResult
|
||||||
|
): TeamProvisioningPrepareResult {
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
details: result.details ? [...result.details] : undefined,
|
||||||
|
warnings: result.warnings ? [...result.warnings] : undefined,
|
||||||
|
issues: result.issues?.map((issue) => ({ ...issue })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async prepareForProvisioningOnce(
|
||||||
|
cwd?: string,
|
||||||
|
opts?: {
|
||||||
|
forceFresh?: boolean;
|
||||||
|
providerId?: TeamProviderId;
|
||||||
|
providerIds?: TeamProviderId[];
|
||||||
|
modelIds?: string[];
|
||||||
|
limitContext?: boolean;
|
||||||
|
modelVerificationMode?: TeamProvisioningModelVerificationMode;
|
||||||
|
}
|
||||||
): Promise<TeamProvisioningPrepareResult> {
|
): Promise<TeamProvisioningPrepareResult> {
|
||||||
const targetCwdForValidation = cwd?.trim() || process.cwd();
|
const targetCwdForValidation = cwd?.trim() || process.cwd();
|
||||||
await this.validatePrepareCwd(targetCwdForValidation);
|
await this.validatePrepareCwd(targetCwdForValidation);
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ function execGit(args: string[], cwd: string): Promise<string> {
|
||||||
execFile(
|
execFile(
|
||||||
'git',
|
'git',
|
||||||
args,
|
args,
|
||||||
{ cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: 1024 * 1024 },
|
{ cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: 1024 * 1024, windowsHide: true },
|
||||||
(error, stdout, stderr) => {
|
(error, stdout, stderr) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
const message = String(stderr || error.message || 'git command failed').trim();
|
const message = String(stderr || error.message || 'git command failed').trim();
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ export interface OpenCodeBridgeCommandClientOptions {
|
||||||
diagnosticIdFactory?: () => string;
|
diagnosticIdFactory?: () => string;
|
||||||
clock?: () => Date;
|
clock?: () => Date;
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
|
envProvider?: () => NodeJS.ProcessEnv | Promise<NodeJS.ProcessEnv>;
|
||||||
keepInputFile?: boolean;
|
keepInputFile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,6 +103,7 @@ export class OpenCodeBridgeCommandClient {
|
||||||
private readonly diagnosticIdFactory: () => string;
|
private readonly diagnosticIdFactory: () => string;
|
||||||
private readonly clock: () => Date;
|
private readonly clock: () => Date;
|
||||||
private readonly env: NodeJS.ProcessEnv;
|
private readonly env: NodeJS.ProcessEnv;
|
||||||
|
private readonly envProvider: (() => NodeJS.ProcessEnv | Promise<NodeJS.ProcessEnv>) | null;
|
||||||
private readonly keepInputFile: boolean;
|
private readonly keepInputFile: boolean;
|
||||||
|
|
||||||
constructor(options: OpenCodeBridgeCommandClientOptions) {
|
constructor(options: OpenCodeBridgeCommandClientOptions) {
|
||||||
|
|
@ -114,6 +116,7 @@ export class OpenCodeBridgeCommandClient {
|
||||||
options.diagnosticIdFactory ?? (() => `opencode-bridge-diagnostic-${randomUUID()}`);
|
options.diagnosticIdFactory ?? (() => `opencode-bridge-diagnostic-${randomUUID()}`);
|
||||||
this.clock = options.clock ?? (() => new Date());
|
this.clock = options.clock ?? (() => new Date());
|
||||||
this.env = applyOpenCodeAutoUpdatePolicy(options.env ?? process.env);
|
this.env = applyOpenCodeAutoUpdatePolicy(options.env ?? process.env);
|
||||||
|
this.envProvider = options.envProvider ?? null;
|
||||||
this.keepInputFile = options.keepInputFile ?? false;
|
this.keepInputFile = options.keepInputFile ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,7 +150,7 @@ export class OpenCodeBridgeCommandClient {
|
||||||
timeoutMs: options.timeoutMs,
|
timeoutMs: options.timeoutMs,
|
||||||
stdoutLimitBytes: options.stdoutLimitBytes ?? DEFAULT_STDOUT_LIMIT_BYTES,
|
stdoutLimitBytes: options.stdoutLimitBytes ?? DEFAULT_STDOUT_LIMIT_BYTES,
|
||||||
stderrLimitBytes: options.stderrLimitBytes ?? DEFAULT_STDERR_LIMIT_BYTES,
|
stderrLimitBytes: options.stderrLimitBytes ?? DEFAULT_STDERR_LIMIT_BYTES,
|
||||||
env: this.env,
|
env: await this.resolveEnv(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (processResult.timedOut) {
|
if (processResult.timedOut) {
|
||||||
|
|
@ -195,6 +198,13 @@ export class OpenCodeBridgeCommandClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveEnv(): Promise<NodeJS.ProcessEnv> {
|
||||||
|
if (!this.envProvider) {
|
||||||
|
return this.env;
|
||||||
|
}
|
||||||
|
return applyOpenCodeAutoUpdatePolicy(await this.envProvider());
|
||||||
|
}
|
||||||
|
|
||||||
private async writeInputFile<TBody>(
|
private async writeInputFile<TBody>(
|
||||||
envelope: OpenCodeBridgeCommandEnvelope<TBody>
|
envelope: OpenCodeBridgeCommandEnvelope<TBody>
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
type RuntimeProcessTableRow,
|
type RuntimeProcessTableRow,
|
||||||
} from '@features/tmux-installer/main';
|
} from '@features/tmux-installer/main';
|
||||||
import { killProcessByPid } from '@main/utils/processKill';
|
import { killProcessByPid } from '@main/utils/processKill';
|
||||||
|
import { listWindowsProcessTable } from '@main/utils/windowsProcessTable';
|
||||||
import { execFile, type ExecFileException } from 'child_process';
|
import { execFile, type ExecFileException } from 'child_process';
|
||||||
|
|
||||||
export type OpenCodeManagedHostCleanupMode = 'orphaned' | 'force';
|
export type OpenCodeManagedHostCleanupMode = 'orphaned' | 'force';
|
||||||
|
|
@ -37,11 +38,15 @@ export interface OpenCodeManagedHostProcessCleanupOptions {
|
||||||
sleepMs?: (ms: number) => Promise<void>;
|
sleepMs?: (ms: number) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const OPENCODE_SERVE_COMMAND_RE = /(^|[/\\\s])opencode(?:\.exe)?(?=\s|$).*?(?:^|\s)serve(?=\s|$)/i;
|
const OPENCODE_SERVE_COMMAND_RE =
|
||||||
|
/(^|[/\\\s"])opencode(?:\.exe)?(?:"?)(?=\s|$).*?(?:^|\s)serve(?=\s|$)/i;
|
||||||
|
const WINDOWS_APP_MANAGED_OPENCODE_SERVE_RE =
|
||||||
|
/[\\/]runtimes[\\/]opencode[\\/]versions[\\/][^"'\s]+[\\/]opencode-windows-[^"'\s]+[\\/]opencode\.exe(?:"|\s|$)/i;
|
||||||
const MANAGED_ENV_MARKERS = ['CLAUDE_MULTIMODEL_DATA_HOME=', 'OPENCODE_CONFIG_CONTENT='] as const;
|
const MANAGED_ENV_MARKERS = ['CLAUDE_MULTIMODEL_DATA_HOME=', 'OPENCODE_CONFIG_CONTENT='] as const;
|
||||||
const MANAGED_ENV_IDENTITY_MARKERS = [
|
const MANAGED_ENV_IDENTITY_MARKERS = [
|
||||||
'AGENT_TEAMS_MCP_CLAUDE_DIR=',
|
'AGENT_TEAMS_MCP_CLAUDE_DIR=',
|
||||||
'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY=',
|
'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY=',
|
||||||
|
'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL=',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export async function cleanupManagedOpenCodeServeProcesses(
|
export async function cleanupManagedOpenCodeServeProcesses(
|
||||||
|
|
@ -55,18 +60,18 @@ export async function cleanupManagedOpenCodeServeProcesses(
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (platform === 'win32') {
|
const rows = await (
|
||||||
result.diagnostics.push(
|
options.listProcessRows ??
|
||||||
'Managed OpenCode serve process fallback cleanup is skipped on Windows.'
|
(platform === 'win32' ? listWindowsProcessTable : listRuntimeProcessesForCurrentTmuxPlatform)
|
||||||
);
|
)();
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = await (options.listProcessRows ?? listRuntimeProcessesForCurrentTmuxPlatform)();
|
|
||||||
const excludePids = options.excludePids ?? new Set<number>();
|
const excludePids = options.excludePids ?? new Set<number>();
|
||||||
const requiredDetailsMarkers = options.requiredDetailsMarkers ?? [];
|
const requiredDetailsMarkers = options.requiredDetailsMarkers ?? [];
|
||||||
const readDetails = options.readProcessDetails ?? readNativeProcessCommandWithEnv;
|
const readDetails =
|
||||||
const readStartTimeMs = options.readProcessStartTimeMs ?? readNativeProcessStartTimeMs;
|
options.readProcessDetails ??
|
||||||
|
(platform === 'win32' ? async () => null : readNativeProcessCommandWithEnv);
|
||||||
|
const readStartTimeMs =
|
||||||
|
options.readProcessStartTimeMs ??
|
||||||
|
(platform === 'win32' ? readWindowsProcessStartTimeMs : readNativeProcessStartTimeMs);
|
||||||
const disposeServeHost = options.disposeServeHost ?? disposeOpenCodeServeHost;
|
const disposeServeHost = options.disposeServeHost ?? disposeOpenCodeServeHost;
|
||||||
const killProcess = options.killProcess ?? killProcessByPid;
|
const killProcess = options.killProcess ?? killProcessByPid;
|
||||||
const forceKillProcess =
|
const forceKillProcess =
|
||||||
|
|
@ -91,16 +96,22 @@ export async function cleanupManagedOpenCodeServeProcesses(
|
||||||
}
|
}
|
||||||
|
|
||||||
const details = await readDetails(row.pid);
|
const details = await readDetails(row.pid);
|
||||||
if (
|
const isManagedByWindowsCommand =
|
||||||
!details ||
|
platform === 'win32' && isAppManagedWindowsOpenCodeServeCommand(row.command);
|
||||||
!isManagedOpenCodeServeProcessDetails(details) ||
|
const isManaged =
|
||||||
!processDetailsIncludeMarkers(details, requiredDetailsMarkers)
|
isManagedByWindowsCommand || Boolean(details && isManagedOpenCodeServeProcessDetails(details));
|
||||||
) {
|
const hasRequiredDetailsMarkers =
|
||||||
|
requiredDetailsMarkers.length === 0 ||
|
||||||
|
Boolean(details && processDetailsIncludeMarkers(details, requiredDetailsMarkers));
|
||||||
|
if (!isManaged || !hasRequiredDetailsMarkers) {
|
||||||
result.candidates.push({
|
result.candidates.push({
|
||||||
pid: row.pid,
|
pid: row.pid,
|
||||||
ppid: row.ppid,
|
ppid: row.ppid,
|
||||||
action: 'kept_unmanaged',
|
action: 'kept_unmanaged',
|
||||||
reason: 'process does not carry Agent Teams managed OpenCode environment markers',
|
reason:
|
||||||
|
platform === 'win32'
|
||||||
|
? 'process is not an app-managed Windows OpenCode serve command'
|
||||||
|
: 'process does not carry Agent Teams managed OpenCode environment markers',
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -122,7 +133,9 @@ export async function cleanupManagedOpenCodeServeProcesses(
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (row.ppid !== 1) {
|
const parentMayStillOwnProcess =
|
||||||
|
platform === 'win32' ? row.ppid > 0 && isProcessAlive(row.ppid) : row.ppid !== 1;
|
||||||
|
if (parentMayStillOwnProcess) {
|
||||||
result.candidates.push({
|
result.candidates.push({
|
||||||
pid: row.pid,
|
pid: row.pid,
|
||||||
ppid: row.ppid,
|
ppid: row.ppid,
|
||||||
|
|
@ -177,6 +190,14 @@ export function isOpenCodeServeCommand(command: string): boolean {
|
||||||
return OPENCODE_SERVE_COMMAND_RE.test(command.trim());
|
return OPENCODE_SERVE_COMMAND_RE.test(command.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isAppManagedWindowsOpenCodeServeCommand(command: string): boolean {
|
||||||
|
const normalizedCommand = command.trim().replace(/\//g, '\\');
|
||||||
|
return (
|
||||||
|
isOpenCodeServeCommand(normalizedCommand) &&
|
||||||
|
WINDOWS_APP_MANAGED_OPENCODE_SERVE_RE.test(normalizedCommand)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function isManagedOpenCodeServeProcessDetails(details: string): boolean {
|
export function isManagedOpenCodeServeProcessDetails(details: string): boolean {
|
||||||
return (
|
return (
|
||||||
processDetailsIncludeMarkers(details, MANAGED_ENV_MARKERS) &&
|
processDetailsIncludeMarkers(details, MANAGED_ENV_MARKERS) &&
|
||||||
|
|
@ -251,6 +272,30 @@ async function readNativeProcessStartTimeMs(pid: number): Promise<number | null>
|
||||||
return Number.isFinite(parsed) ? parsed : null;
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readWindowsProcessStartTimeMs(pid: number): Promise<number | null> {
|
||||||
|
const normalizedPid = Math.trunc(pid);
|
||||||
|
if (!Number.isFinite(normalizedPid) || normalizedPid <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = [
|
||||||
|
'$ErrorActionPreference = "Stop"',
|
||||||
|
`$process = Get-Process -Id ${normalizedPid} -ErrorAction Stop`,
|
||||||
|
'$process.StartTime.ToUniversalTime().ToString("o")',
|
||||||
|
].join('; ');
|
||||||
|
const output = await execFileText(
|
||||||
|
'powershell.exe',
|
||||||
|
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
|
||||||
|
2_000,
|
||||||
|
64 * 1024
|
||||||
|
);
|
||||||
|
if (!output) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = Date.parse(output.trim());
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
function isNativeProcessAlive(pid: number): boolean {
|
function isNativeProcessAlive(pid: number): boolean {
|
||||||
try {
|
try {
|
||||||
process.kill(pid, 0);
|
process.kill(pid, 0);
|
||||||
|
|
@ -278,6 +323,7 @@ function execFileText(
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
timeout,
|
timeout,
|
||||||
maxBuffer,
|
maxBuffer,
|
||||||
|
windowsHide: true,
|
||||||
},
|
},
|
||||||
(error: ExecFileException | null, stdout: string | Buffer) => {
|
(error: ExecFileException | null, stdout: string | Buffer) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
|
||||||
|
|
@ -242,12 +242,16 @@ export function killTrackedCliProcesses(signal: NodeJS.Signals = 'SIGKILL'): voi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Merge CLI_ENV_DEFAULTS into spawn/exec options.env (or process.env if absent). */
|
/** Apply shared CLI process defaults without overriding explicit caller choices. */
|
||||||
function withCliEnv<T extends { env?: NodeJS.ProcessEnv | Record<string, string | undefined> }>(
|
function withCliProcessDefaults<
|
||||||
options: T
|
T extends {
|
||||||
): T {
|
env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
|
||||||
|
windowsHide?: boolean;
|
||||||
|
},
|
||||||
|
>(options: T): T & { windowsHide: boolean } {
|
||||||
return {
|
return {
|
||||||
...options,
|
...options,
|
||||||
|
windowsHide: options.windowsHide ?? true,
|
||||||
env: { ...(options.env ?? process.env), ...CLI_ENV_DEFAULTS },
|
env: { ...(options.env ?? process.env), ...CLI_ENV_DEFAULTS },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -270,7 +274,7 @@ export async function execCli(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const target = binaryPath;
|
const target = binaryPath;
|
||||||
const opts = withCliEnv(options);
|
const opts = withCliProcessDefaults(options);
|
||||||
const directLauncher = resolveDirectWindowsLauncher(target);
|
const directLauncher = resolveDirectWindowsLauncher(target);
|
||||||
if (directLauncher) {
|
if (directLauncher) {
|
||||||
const result = await execFileAsync(
|
const result = await execFileAsync(
|
||||||
|
|
@ -316,7 +320,7 @@ export function spawnCli(
|
||||||
args: string[],
|
args: string[],
|
||||||
options: SpawnOptions = {}
|
options: SpawnOptions = {}
|
||||||
): ReturnType<typeof spawn> {
|
): ReturnType<typeof spawn> {
|
||||||
const opts = withCliEnv(options);
|
const opts = withCliProcessDefaults(options);
|
||||||
const directLauncher = resolveDirectWindowsLauncher(binaryPath);
|
const directLauncher = resolveDirectWindowsLauncher(binaryPath);
|
||||||
if (directLauncher) {
|
if (directLauncher) {
|
||||||
const directOpts = { ...opts };
|
const directOpts = { ...opts };
|
||||||
|
|
@ -372,8 +376,8 @@ export function killProcessTree(
|
||||||
'System32',
|
'System32',
|
||||||
'taskkill.exe'
|
'taskkill.exe'
|
||||||
);
|
);
|
||||||
execFile(taskkillPath, ['/T', '/F', '/PID', String(child.pid)], () => {
|
execFile(taskkillPath, ['/T', '/F', '/PID', String(child.pid)], { windowsHide: true }, () => {
|
||||||
// Best-effort — ignore errors (process may have already exited)
|
// Best-effort - ignore errors (process may have already exited)
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ export function killProcessByPid(pid: number): void {
|
||||||
'System32',
|
'System32',
|
||||||
'taskkill.exe'
|
'taskkill.exe'
|
||||||
);
|
);
|
||||||
execFile(taskkillPath, ['/T', '/F', '/PID', String(pid)], () => {
|
execFile(taskkillPath, ['/T', '/F', '/PID', String(pid)], { windowsHide: true }, () => {
|
||||||
// Best-effort — ignore errors (process may have already exited)
|
// Best-effort - ignore errors (process may have already exited)
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// taskkill failed to spawn, fall through to process.kill()
|
// taskkill failed to spawn, fall through to process.kill()
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,7 @@ async function readShellEnv(shellPath: string, args: string[]): Promise<NodeJS.P
|
||||||
const child = spawn(shellPath, args, {
|
const child = spawn(shellPath, args, {
|
||||||
env: process.env,
|
env: process.env,
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
windowsHide: true,
|
||||||
});
|
});
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
let settled = false;
|
let settled = false;
|
||||||
|
|
|
||||||
|
|
@ -361,6 +361,10 @@ function cancelScheduledIdle(handle: ScheduledIdleHandle | null): void {
|
||||||
window.clearTimeout(handle.id);
|
window.clearTimeout(handle.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCurrentPrepareGeneration(ref: { current: number }, generation: number): boolean {
|
||||||
|
return ref.current === generation;
|
||||||
|
}
|
||||||
|
|
||||||
export const CreateTeamDialog = ({
|
export const CreateTeamDialog = ({
|
||||||
open,
|
open,
|
||||||
canCreate,
|
canCreate,
|
||||||
|
|
@ -445,6 +449,7 @@ export const CreateTeamDialog = ({
|
||||||
const [prepareChecks, setPrepareChecks] = useState<ProvisioningProviderCheck[]>([]);
|
const [prepareChecks, setPrepareChecks] = useState<ProvisioningProviderCheck[]>([]);
|
||||||
const prepareRequestSeqRef = useRef(0);
|
const prepareRequestSeqRef = useRef(0);
|
||||||
const prepareIdleHandleRef = useRef<ScheduledIdleHandle | null>(null);
|
const prepareIdleHandleRef = useRef<ScheduledIdleHandle | null>(null);
|
||||||
|
const prepareUnmountGenerationRef = useRef(0);
|
||||||
const appliedDefaultProjectPathRef = useRef<string | null>(null);
|
const appliedDefaultProjectPathRef = useRef<string | null>(null);
|
||||||
const lastAutoDescriptionRef = useRef<string | null>(null);
|
const lastAutoDescriptionRef = useRef<string | null>(null);
|
||||||
const [fieldErrors, setFieldErrors] = useState<{
|
const [fieldErrors, setFieldErrors] = useState<{
|
||||||
|
|
@ -691,6 +696,23 @@ export const CreateTeamDialog = ({
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const generation = ++prepareUnmountGenerationRef.current;
|
||||||
|
return () => {
|
||||||
|
// React StrictMode replays effect cleanup/setup in development; defer
|
||||||
|
// invalidation so the replay does not cancel the live prepare request.
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (!isCurrentPrepareGeneration(prepareUnmountGenerationRef, generation)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelScheduledIdle(prepareIdleHandleRef.current);
|
||||||
|
prepareIdleHandleRef.current = null;
|
||||||
|
prepareRequestSeqRef.current += 1;
|
||||||
|
lastPrepareRequestSignatureRef.current = null;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const prepareRuntimeStatusSignature = useMemo(
|
const prepareRuntimeStatusSignature = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildProviderPrepareRuntimeStatusSignature(
|
buildProviderPrepareRuntimeStatusSignature(
|
||||||
|
|
@ -800,12 +822,16 @@ export const CreateTeamDialog = ({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !canCreate || !launchTeam) {
|
if (!open || !canCreate || !launchTeam) {
|
||||||
|
cancelScheduledIdle(prepareIdleHandleRef.current);
|
||||||
|
prepareIdleHandleRef.current = null;
|
||||||
prepareRequestSeqRef.current += 1;
|
prepareRequestSeqRef.current += 1;
|
||||||
lastPrepareRequestSignatureRef.current = null;
|
lastPrepareRequestSignatureRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof api.teams.prepareProvisioning !== 'function') {
|
if (typeof api.teams.prepareProvisioning !== 'function') {
|
||||||
|
cancelScheduledIdle(prepareIdleHandleRef.current);
|
||||||
|
prepareIdleHandleRef.current = null;
|
||||||
prepareRequestSeqRef.current += 1;
|
prepareRequestSeqRef.current += 1;
|
||||||
lastPrepareRequestSignatureRef.current = null;
|
lastPrepareRequestSignatureRef.current = null;
|
||||||
setPrepareState('failed');
|
setPrepareState('failed');
|
||||||
|
|
@ -818,6 +844,8 @@ export const CreateTeamDialog = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!effectiveCwd) {
|
if (!effectiveCwd) {
|
||||||
|
cancelScheduledIdle(prepareIdleHandleRef.current);
|
||||||
|
prepareIdleHandleRef.current = null;
|
||||||
prepareRequestSeqRef.current += 1;
|
prepareRequestSeqRef.current += 1;
|
||||||
lastPrepareRequestSignatureRef.current = null;
|
lastPrepareRequestSignatureRef.current = null;
|
||||||
setPrepareState('idle');
|
setPrepareState('idle');
|
||||||
|
|
@ -1023,16 +1051,6 @@ export const CreateTeamDialog = ({
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelScheduledIdle(prepareIdleHandleRef.current);
|
|
||||||
prepareIdleHandleRef.current = null;
|
|
||||||
// Bump the request sequence so any callback that already woke up but
|
|
||||||
// hasn't checked yet treats itself as superseded.
|
|
||||||
if (prepareRequestSeqRef.current === requestSeq) {
|
|
||||||
prepareRequestSeqRef.current += 1;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [
|
}, [
|
||||||
open,
|
open,
|
||||||
canCreate,
|
canCreate,
|
||||||
|
|
@ -1041,6 +1059,7 @@ export const CreateTeamDialog = ({
|
||||||
effectiveMemberDrafts,
|
effectiveMemberDrafts,
|
||||||
effectiveAnthropicRuntimeLimitContext,
|
effectiveAnthropicRuntimeLimitContext,
|
||||||
prepareRequestSignature,
|
prepareRequestSignature,
|
||||||
|
prepareRuntimeStatusSignature,
|
||||||
runtimeProviderStatusById,
|
runtimeProviderStatusById,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
selectedProviderId,
|
selectedProviderId,
|
||||||
|
|
|
||||||
237
test/main/services/team/AgentTeamsMcpHttpServer.test.ts
Normal file
237
test/main/services/team/AgentTeamsMcpHttpServer.test.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import http from 'http';
|
||||||
|
import net from 'net';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const hoisted = vi.hoisted(() => ({
|
||||||
|
killProcessTreeMock: vi.fn(),
|
||||||
|
spawnCliMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@main/utils/childProcess', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@main/utils/childProcess')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
killProcessTree: (...args: unknown[]) => hoisted.killProcessTreeMock(...args),
|
||||||
|
spawnCli: (...args: unknown[]) => hoisted.spawnCliMock(...args),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { AgentTeamsMcpHttpServer } from '@main/services/team/AgentTeamsMcpHttpServer';
|
||||||
|
|
||||||
|
class FakeChildProcess extends EventEmitter {
|
||||||
|
pid = 43123;
|
||||||
|
stderr = new EventEmitter();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function allocateLoopbackPort(): Promise<number> {
|
||||||
|
return new Promise((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 port')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
server.close(() => resolve(address.port));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AgentTeamsMcpHttpServer', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
hoisted.killProcessTreeMock.mockReset();
|
||||||
|
hoisted.spawnCliMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts the MCP server over HTTP with hidden app-owned process env', async () => {
|
||||||
|
const child = new FakeChildProcess();
|
||||||
|
const spawnProcess = vi.fn(() => child as any);
|
||||||
|
const server = new AgentTeamsMcpHttpServer({
|
||||||
|
resolveLaunchSpec: async () => ({
|
||||||
|
command: 'node',
|
||||||
|
args: ['mcp-server/dist/index.js'],
|
||||||
|
}),
|
||||||
|
allocatePort: async () => 41001,
|
||||||
|
spawnProcess,
|
||||||
|
waitForPort: vi.fn(async () => undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handle = await server.ensureStarted();
|
||||||
|
|
||||||
|
expect(handle).toEqual({
|
||||||
|
url: 'http://127.0.0.1:41001/mcp',
|
||||||
|
port: 41001,
|
||||||
|
pid: 43123,
|
||||||
|
});
|
||||||
|
expect(spawnProcess).toHaveBeenCalledWith(
|
||||||
|
'node',
|
||||||
|
[
|
||||||
|
'mcp-server/dist/index.js',
|
||||||
|
'--transport',
|
||||||
|
'httpStream',
|
||||||
|
'--host',
|
||||||
|
'127.0.0.1',
|
||||||
|
'--port',
|
||||||
|
'41001',
|
||||||
|
'--endpoint',
|
||||||
|
'/mcp',
|
||||||
|
],
|
||||||
|
expect.objectContaining({
|
||||||
|
AGENT_TEAMS_MCP_TRANSPORT: 'httpStream',
|
||||||
|
AGENT_TEAMS_MCP_HTTP_PORT: '41001',
|
||||||
|
AGENT_TEAMS_MCP_HTTP_ENDPOINT: '/mcp',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a hidden default spawn without holding stdout open', async () => {
|
||||||
|
const child = new FakeChildProcess();
|
||||||
|
hoisted.spawnCliMock.mockReturnValue(child as any);
|
||||||
|
const server = new AgentTeamsMcpHttpServer({
|
||||||
|
resolveLaunchSpec: async () => ({
|
||||||
|
command: 'node',
|
||||||
|
args: ['mcp-server/dist/index.js'],
|
||||||
|
}),
|
||||||
|
allocatePort: async () => 41005,
|
||||||
|
waitForPort: vi.fn(async () => undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handle = await server.ensureStarted();
|
||||||
|
|
||||||
|
expect(handle.pid).toBe(43123);
|
||||||
|
expect(hoisted.spawnCliMock).toHaveBeenCalledWith(
|
||||||
|
'node',
|
||||||
|
[
|
||||||
|
'mcp-server/dist/index.js',
|
||||||
|
'--transport',
|
||||||
|
'httpStream',
|
||||||
|
'--host',
|
||||||
|
'127.0.0.1',
|
||||||
|
'--port',
|
||||||
|
'41005',
|
||||||
|
'--endpoint',
|
||||||
|
'/mcp',
|
||||||
|
],
|
||||||
|
expect.objectContaining({
|
||||||
|
env: expect.objectContaining({
|
||||||
|
AGENT_TEAMS_MCP_TRANSPORT: 'httpStream',
|
||||||
|
AGENT_TEAMS_MCP_HTTP_PORT: '41005',
|
||||||
|
AGENT_TEAMS_MCP_HTTP_ENDPOINT: '/mcp',
|
||||||
|
}),
|
||||||
|
stdio: ['ignore', 'ignore', 'pipe'],
|
||||||
|
windowsHide: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coalesces concurrent starts', async () => {
|
||||||
|
const child = new FakeChildProcess();
|
||||||
|
const spawnProcess = vi.fn(() => child as any);
|
||||||
|
const server = new AgentTeamsMcpHttpServer({
|
||||||
|
resolveLaunchSpec: async () => ({
|
||||||
|
command: 'node',
|
||||||
|
args: ['mcp-server/dist/index.js'],
|
||||||
|
}),
|
||||||
|
allocatePort: async () => 41002,
|
||||||
|
spawnProcess,
|
||||||
|
waitForPort: async () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [first, second] = await Promise.all([server.ensureStarted(), server.ensureStarted()]);
|
||||||
|
|
||||||
|
expect(first).toBe(second);
|
||||||
|
expect(spawnProcess).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails startup promptly when the child exits before readiness', async () => {
|
||||||
|
const child = new FakeChildProcess();
|
||||||
|
const server = new AgentTeamsMcpHttpServer({
|
||||||
|
resolveLaunchSpec: async () => ({
|
||||||
|
command: 'node',
|
||||||
|
args: ['mcp-server/dist/index.js'],
|
||||||
|
}),
|
||||||
|
allocatePort: async () => 41003,
|
||||||
|
spawnProcess: vi.fn(() => child as any),
|
||||||
|
waitForPort: vi.fn(() => {
|
||||||
|
child.emit('exit', 1, null);
|
||||||
|
return new Promise<void>(() => {
|
||||||
|
// Keep readiness pending so startup resolves only through the child exit.
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(server.ensureStarted()).rejects.toThrow(
|
||||||
|
'Agent Teams MCP HTTP server exited before startup completed with code 1'
|
||||||
|
);
|
||||||
|
expect(hoisted.killProcessTreeMock).toHaveBeenCalledWith(child, 'SIGKILL');
|
||||||
|
expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain(
|
||||||
|
'Agent Teams MCP HTTP server exited before startup completed with code 1'
|
||||||
|
);
|
||||||
|
vi.mocked(console.warn).mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not return a handle if the child exits during readiness polling', async () => {
|
||||||
|
const child = new FakeChildProcess();
|
||||||
|
const server = new AgentTeamsMcpHttpServer({
|
||||||
|
resolveLaunchSpec: async () => ({
|
||||||
|
command: 'node',
|
||||||
|
args: ['mcp-server/dist/index.js'],
|
||||||
|
}),
|
||||||
|
allocatePort: async () => 41004,
|
||||||
|
spawnProcess: vi.fn(() => child as any),
|
||||||
|
waitForPort: vi.fn(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
child.emit('exit', 0, null);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(server.ensureStarted()).rejects.toThrow(
|
||||||
|
'Agent Teams MCP HTTP server exited before startup completed'
|
||||||
|
);
|
||||||
|
expect(hoisted.killProcessTreeMock).toHaveBeenCalledWith(child, 'SIGKILL');
|
||||||
|
expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain(
|
||||||
|
'Agent Teams MCP HTTP server exited before startup completed with code 0'
|
||||||
|
);
|
||||||
|
vi.mocked(console.warn).mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits for the HTTP health endpoint before marking the server ready', async () => {
|
||||||
|
const child = new FakeChildProcess();
|
||||||
|
const port = await allocateLoopbackPort();
|
||||||
|
let healthRequests = 0;
|
||||||
|
const healthServer = http.createServer((request, response) => {
|
||||||
|
if (request.url === '/health') {
|
||||||
|
healthRequests += 1;
|
||||||
|
response.writeHead(200, { 'content-type': 'text/plain' });
|
||||||
|
response.end('ok');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.writeHead(404);
|
||||||
|
response.end();
|
||||||
|
});
|
||||||
|
const spawnProcess = vi.fn((_command: string, args: string[]) => {
|
||||||
|
expect(args).toContain(String(port));
|
||||||
|
healthServer.listen(port, '127.0.0.1');
|
||||||
|
return child as any;
|
||||||
|
});
|
||||||
|
const server = new AgentTeamsMcpHttpServer({
|
||||||
|
resolveLaunchSpec: async () => ({
|
||||||
|
command: 'node',
|
||||||
|
args: ['mcp-server/dist/index.js'],
|
||||||
|
}),
|
||||||
|
allocatePort: async () => port,
|
||||||
|
spawnProcess,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handle = await server.ensureStarted();
|
||||||
|
|
||||||
|
expect(handle.url).toBe(`http://127.0.0.1:${port}/mcp`);
|
||||||
|
expect(healthRequests).toBeGreaterThan(0);
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve) => healthServer.close(() => resolve()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -189,6 +189,43 @@ describe('OpenCodeBridgeCommandClient', () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resolves command env lazily for each bridge command', async () => {
|
||||||
|
runner.nextResult = {
|
||||||
|
stdout: `${JSON.stringify(bridgeSuccess({ data: { runId: 'run-1' } }))}\n`,
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 0,
|
||||||
|
timedOut: false,
|
||||||
|
};
|
||||||
|
let envVersion = 0;
|
||||||
|
const client = createClient({
|
||||||
|
envProvider: () => {
|
||||||
|
envVersion += 1;
|
||||||
|
return {
|
||||||
|
PATH: '/usr/bin',
|
||||||
|
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL: `http://127.0.0.1:${5000 + envVersion}/mcp`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.execute('opencode.launchTeam', { runId: 'run-1' }, {
|
||||||
|
cwd: '/tmp/project',
|
||||||
|
timeoutMs: 10_000,
|
||||||
|
});
|
||||||
|
await client.execute('opencode.launchTeam', { runId: 'run-2' }, {
|
||||||
|
cwd: '/tmp/project',
|
||||||
|
timeoutMs: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runner.calls[0].env).toMatchObject({
|
||||||
|
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL: 'http://127.0.0.1:5001/mcp',
|
||||||
|
OPENCODE_DISABLE_AUTOUPDATE: '1',
|
||||||
|
});
|
||||||
|
expect(runner.calls[1].env).toMatchObject({
|
||||||
|
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL: 'http://127.0.0.1:5002/mcp',
|
||||||
|
OPENCODE_DISABLE_AUTOUPDATE: '1',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('redactBridgeDiagnosticText', () => {
|
describe('redactBridgeDiagnosticText', () => {
|
||||||
|
|
@ -205,7 +242,9 @@ describe('redactBridgeDiagnosticText', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function createClient(): OpenCodeBridgeCommandClient {
|
function createClient(
|
||||||
|
overrides: Partial<ConstructorParameters<typeof OpenCodeBridgeCommandClient>[0]> = {}
|
||||||
|
): OpenCodeBridgeCommandClient {
|
||||||
return new OpenCodeBridgeCommandClient({
|
return new OpenCodeBridgeCommandClient({
|
||||||
binaryPath: '/usr/local/bin/agent-teams-controller',
|
binaryPath: '/usr/local/bin/agent-teams-controller',
|
||||||
tempDirectory: tempDir,
|
tempDirectory: tempDir,
|
||||||
|
|
@ -215,6 +254,7 @@ function createClient(): OpenCodeBridgeCommandClient {
|
||||||
diagnosticIdFactory: () => 'diag-1',
|
diagnosticIdFactory: () => 'diag-1',
|
||||||
clock: () => new Date('2026-04-21T12:00:00.000Z'),
|
clock: () => new Date('2026-04-21T12:00:00.000Z'),
|
||||||
env: { PATH: '/usr/bin' },
|
env: { PATH: '/usr/bin' },
|
||||||
|
...overrides,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
cleanupManagedOpenCodeServeProcesses,
|
cleanupManagedOpenCodeServeProcesses,
|
||||||
getOpenCodeServeLoopbackBaseUrl,
|
getOpenCodeServeLoopbackBaseUrl,
|
||||||
|
isAppManagedWindowsOpenCodeServeCommand,
|
||||||
isManagedOpenCodeServeProcessDetails,
|
isManagedOpenCodeServeProcessDetails,
|
||||||
isOpenCodeServeCommand,
|
isOpenCodeServeCommand,
|
||||||
} from '@main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup';
|
} from '@main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup';
|
||||||
|
|
@ -14,6 +15,12 @@ const MANAGED_DETAILS = [
|
||||||
'AGENT_TEAMS_MCP_CLAUDE_DIR=/tmp/claude',
|
'AGENT_TEAMS_MCP_CLAUDE_DIR=/tmp/claude',
|
||||||
'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY=/tmp/mcp-entry.js',
|
'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY=/tmp/mcp-entry.js',
|
||||||
].join(' ');
|
].join(' ');
|
||||||
|
const MANAGED_DETAILS_WITH_REMOTE_MCP = [
|
||||||
|
'/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 54171',
|
||||||
|
'CLAUDE_MULTIMODEL_DATA_HOME=/tmp/agent-teams-runtime',
|
||||||
|
'OPENCODE_CONFIG_CONTENT={}',
|
||||||
|
'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL=http://127.0.0.1:58461/mcp',
|
||||||
|
].join(' ');
|
||||||
const MANAGED_DETAILS_WITH_WORKSPACE_MCP = [
|
const MANAGED_DETAILS_WITH_WORKSPACE_MCP = [
|
||||||
'/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 54171',
|
'/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 54171',
|
||||||
'CLAUDE_MULTIMODEL_DATA_HOME=/tmp/agent-teams-runtime',
|
'CLAUDE_MULTIMODEL_DATA_HOME=/tmp/agent-teams-runtime',
|
||||||
|
|
@ -34,8 +41,27 @@ describe('OpenCodeManagedHostProcessCleanup', () => {
|
||||||
expect(isOpenCodeServeCommand('node mcp-server/src/index.ts')).toBe(false);
|
expect(isOpenCodeServeCommand('node mcp-server/src/index.ts')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('identifies app-managed Windows OpenCode serve commands', () => {
|
||||||
|
expect(
|
||||||
|
isAppManagedWindowsOpenCodeServeCommand(
|
||||||
|
'"C:\\Users\\User\\AppData\\Roaming\\claude-agent-teams-ui\\data\\runtimes\\opencode\\versions\\1.14.48\\opencode-windows-x64\\opencode.exe" serve --hostname 127.0.0.1 --port 49913'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isAppManagedWindowsOpenCodeServeCommand(
|
||||||
|
'C:\\tools\\opencode.exe serve --hostname 127.0.0.1 --port 49913'
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
isAppManagedWindowsOpenCodeServeCommand(
|
||||||
|
'C:\\Users\\User\\AppData\\Roaming\\claude-agent-teams-ui\\data\\runtimes\\opencode\\versions\\1.14.48\\opencode-windows-x64\\opencode.exe auth login'
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('requires Agent Teams managed environment markers', () => {
|
it('requires Agent Teams managed environment markers', () => {
|
||||||
expect(isManagedOpenCodeServeProcessDetails(MANAGED_DETAILS)).toBe(true);
|
expect(isManagedOpenCodeServeProcessDetails(MANAGED_DETAILS)).toBe(true);
|
||||||
|
expect(isManagedOpenCodeServeProcessDetails(MANAGED_DETAILS_WITH_REMOTE_MCP)).toBe(true);
|
||||||
expect(isManagedOpenCodeServeProcessDetails(MANAGED_DETAILS_WITH_WORKSPACE_MCP)).toBe(true);
|
expect(isManagedOpenCodeServeProcessDetails(MANAGED_DETAILS_WITH_WORKSPACE_MCP)).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
isManagedOpenCodeServeProcessDetails(
|
isManagedOpenCodeServeProcessDetails(
|
||||||
|
|
@ -347,7 +373,89 @@ describe('OpenCodeManagedHostProcessCleanup', () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips fallback cleanup on Windows because environment markers are unavailable', async () => {
|
it('kills old orphaned app-managed Windows OpenCode serve processes', async () => {
|
||||||
|
const killProcess = vi.fn();
|
||||||
|
const disposeServeHost = vi.fn(() => resolved(undefined));
|
||||||
|
|
||||||
|
const result = await cleanupManagedOpenCodeServeProcesses({
|
||||||
|
mode: 'orphaned',
|
||||||
|
platform: 'win32',
|
||||||
|
startedBeforeMs: Date.parse('2026-05-16T00:47:55.000Z'),
|
||||||
|
listProcessRows: () =>
|
||||||
|
resolved([
|
||||||
|
{
|
||||||
|
pid: 71628,
|
||||||
|
ppid: 86256,
|
||||||
|
command:
|
||||||
|
'"C:\\Users\\User\\AppData\\Roaming\\claude-agent-teams-ui\\data\\runtimes\\opencode\\versions\\1.14.48\\opencode-windows-x64\\opencode.exe" serve --hostname 127.0.0.1 --port 49913',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
readProcessStartTimeMs: () => resolved(Date.parse('2026-05-16T00:35:31.000Z')),
|
||||||
|
disposeServeHost,
|
||||||
|
isProcessAlive: () => false,
|
||||||
|
killProcess,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(disposeServeHost).toHaveBeenCalledWith('http://127.0.0.1:49913');
|
||||||
|
expect(killProcess).toHaveBeenCalledWith(71628);
|
||||||
|
expect(result.killed).toBe(1);
|
||||||
|
expect(result.scanned).toBe(1);
|
||||||
|
expect(result.diagnostics).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors required markers when Windows details are unavailable', async () => {
|
||||||
|
const killProcess = vi.fn();
|
||||||
|
|
||||||
|
const result = await cleanupManagedOpenCodeServeProcesses({
|
||||||
|
mode: 'force',
|
||||||
|
platform: 'win32',
|
||||||
|
requiredDetailsMarkers: ['CLAUDE_TEAM_APP_INSTANCE_ID=app-1'],
|
||||||
|
listProcessRows: () =>
|
||||||
|
resolved([
|
||||||
|
{
|
||||||
|
pid: 71629,
|
||||||
|
ppid: 86256,
|
||||||
|
command:
|
||||||
|
'"C:\\Users\\User\\AppData\\Roaming\\claude-agent-teams-ui\\data\\runtimes\\opencode\\versions\\1.14.48\\opencode-windows-x64\\opencode.exe" serve --hostname 127.0.0.1 --port 49914',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
readProcessDetails: () => resolved(null),
|
||||||
|
disposeServeHost: () => resolved(undefined),
|
||||||
|
isProcessAlive: () => false,
|
||||||
|
killProcess,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(killProcess).not.toHaveBeenCalled();
|
||||||
|
expect(result.candidates[0]).toMatchObject({ pid: 71629, action: 'kept_unmanaged' });
|
||||||
|
expect(result.diagnostics).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps app-managed Windows OpenCode serve processes while their parent is still alive', async () => {
|
||||||
|
const killProcess = vi.fn();
|
||||||
|
|
||||||
|
const result = await cleanupManagedOpenCodeServeProcesses({
|
||||||
|
mode: 'orphaned',
|
||||||
|
platform: 'win32',
|
||||||
|
startedBeforeMs: Date.parse('2026-05-16T00:47:55.000Z'),
|
||||||
|
listProcessRows: () =>
|
||||||
|
resolved([
|
||||||
|
{
|
||||||
|
pid: 71628,
|
||||||
|
ppid: 86256,
|
||||||
|
command:
|
||||||
|
'"C:\\Users\\User\\AppData\\Roaming\\claude-agent-teams-ui\\data\\runtimes\\opencode\\versions\\1.14.48\\opencode-windows-x64\\opencode.exe" serve --hostname 127.0.0.1 --port 49913',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
readProcessStartTimeMs: () => resolved(Date.parse('2026-05-16T00:35:31.000Z')),
|
||||||
|
isProcessAlive: (pid) => pid === 86256,
|
||||||
|
killProcess,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(killProcess).not.toHaveBeenCalled();
|
||||||
|
expect(result.candidates[0]).toMatchObject({ pid: 71628, action: 'kept_recent' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not kill unmanaged Windows OpenCode serve commands', async () => {
|
||||||
const killProcess = vi.fn();
|
const killProcess = vi.fn();
|
||||||
|
|
||||||
const result = await cleanupManagedOpenCodeServeProcesses({
|
const result = await cleanupManagedOpenCodeServeProcesses({
|
||||||
|
|
@ -358,15 +466,15 @@ describe('OpenCodeManagedHostProcessCleanup', () => {
|
||||||
{
|
{
|
||||||
pid: 500,
|
pid: 500,
|
||||||
ppid: 1,
|
ppid: 1,
|
||||||
command: 'opencode.exe serve --hostname 127.0.0.1',
|
command: 'C:\\tools\\opencode.exe serve --hostname 127.0.0.1',
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
readProcessDetails: () => resolved(MANAGED_DETAILS),
|
|
||||||
killProcess,
|
killProcess,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(killProcess).not.toHaveBeenCalled();
|
expect(killProcess).not.toHaveBeenCalled();
|
||||||
expect(result.scanned).toBe(0);
|
expect(result.scanned).toBe(1);
|
||||||
expect(result.diagnostics[0]).toContain('skipped on Windows');
|
expect(result.diagnostics).toEqual([]);
|
||||||
|
expect(result.candidates[0]).toMatchObject({ pid: 500, action: 'kept_unmanaged' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,19 +9,7 @@ const hoisted = vi.hoisted(() => ({
|
||||||
isPackaged: false,
|
isPackaged: false,
|
||||||
version: '9.9.9-test',
|
version: '9.9.9-test',
|
||||||
},
|
},
|
||||||
execFileMock: vi.fn(
|
execCliMock: vi.fn(async () => ({ stdout: '/mock/node', stderr: '' })),
|
||||||
(
|
|
||||||
_file: string,
|
|
||||||
_args: readonly string[],
|
|
||||||
_options:
|
|
||||||
| { encoding?: string; timeout?: number }
|
|
||||||
| ((error: Error | null, stdout: string, stderr: string) => void),
|
|
||||||
callback?: (error: Error | null, stdout: string, stderr: string) => void
|
|
||||||
) => {
|
|
||||||
const cb = typeof _options === 'function' ? _options : callback;
|
|
||||||
cb?.(null, '/mock/node', '');
|
|
||||||
}
|
|
||||||
),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let mockHomeDir = '';
|
let mockHomeDir = '';
|
||||||
|
|
@ -29,11 +17,11 @@ type ModuleLoad = (request: string, parent: NodeModule | undefined, isMain: bool
|
||||||
const moduleInternal = Module as unknown as { _load: ModuleLoad };
|
const moduleInternal = Module as unknown as { _load: ModuleLoad };
|
||||||
const originalModuleLoad = moduleInternal._load;
|
const originalModuleLoad = moduleInternal._load;
|
||||||
|
|
||||||
vi.mock('child_process', async (importOriginal) => {
|
vi.mock('@main/utils/childProcess', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('child_process')>();
|
const actual = await importOriginal<typeof import('@main/utils/childProcess')>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
execFile: hoisted.execFileMock,
|
execCli: hoisted.execCliMock,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -189,7 +177,7 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
setAppDataBasePath(tempAppData);
|
setAppDataBasePath(tempAppData);
|
||||||
setPackagedMode(false);
|
setPackagedMode(false);
|
||||||
setResourcesPath(undefined);
|
setResourcesPath(undefined);
|
||||||
hoisted.execFileMock.mockClear();
|
hoisted.execCliMock.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -283,6 +271,21 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
expectNodeEntry(server, builtEntry);
|
expectNodeEntry(server, builtEntry);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the shared CLI helper for the Node.js runtime resolver', async () => {
|
||||||
|
mockBuiltWorkspaceEntryAvailable();
|
||||||
|
const builder = new TeamMcpConfigBuilder();
|
||||||
|
|
||||||
|
const configPath = await builder.writeConfigFile();
|
||||||
|
createdPaths.push(configPath);
|
||||||
|
|
||||||
|
expect(readGeneratedServer(configPath)?.command).toBe('/mock/node');
|
||||||
|
expect(hoisted.execCliMock).toHaveBeenCalledWith(
|
||||||
|
'node',
|
||||||
|
['-e', 'process.stdout.write(process.execPath)'],
|
||||||
|
expect.objectContaining({ encoding: 'utf-8', timeout: 5000 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps generated team MCP config minimal and does not inline top-level user MCP', async () => {
|
it('keeps generated team MCP config minimal and does not inline top-level user MCP', async () => {
|
||||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-'));
|
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-'));
|
||||||
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-project-'));
|
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-project-'));
|
||||||
|
|
|
||||||
|
|
@ -661,6 +661,82 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('coalesces duplicate OpenCode compatibility preflight requests while prepare is in flight', async () => {
|
||||||
|
const prepareGate: { release?: () => void } = {};
|
||||||
|
const prepare = vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Promise<{
|
||||||
|
ok: true;
|
||||||
|
providerId: 'opencode';
|
||||||
|
modelId: null;
|
||||||
|
diagnostics: string[];
|
||||||
|
warnings: string[];
|
||||||
|
}>((resolve) => {
|
||||||
|
prepareGate.release = () =>
|
||||||
|
resolve({
|
||||||
|
ok: true,
|
||||||
|
providerId: 'opencode',
|
||||||
|
modelId: null,
|
||||||
|
diagnostics: [],
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const registry = new TeamRuntimeAdapterRegistry([
|
||||||
|
{
|
||||||
|
providerId: 'opencode',
|
||||||
|
prepare,
|
||||||
|
getLastOpenCodeTeamLaunchReadiness: vi.fn(() => ({
|
||||||
|
state: 'ready',
|
||||||
|
launchAllowed: true,
|
||||||
|
modelId: 'opencode/big-pickle',
|
||||||
|
availableModels: ['opencode/big-pickle'],
|
||||||
|
opencodeVersion: '1.0.0',
|
||||||
|
installMethod: 'unknown',
|
||||||
|
binaryPath: 'opencode',
|
||||||
|
hostHealthy: true,
|
||||||
|
appMcpConnected: true,
|
||||||
|
requiredToolsPresent: true,
|
||||||
|
permissionBridgeReady: true,
|
||||||
|
issues: [],
|
||||||
|
warnings: [],
|
||||||
|
diagnostics: [],
|
||||||
|
})),
|
||||||
|
launch: vi.fn(),
|
||||||
|
reconcile: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
} as any,
|
||||||
|
]);
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
svc.setRuntimeAdapterRegistry(registry);
|
||||||
|
const opts = {
|
||||||
|
providerId: 'opencode' as const,
|
||||||
|
forceFresh: true,
|
||||||
|
modelIds: ['opencode/big-pickle'],
|
||||||
|
modelVerificationMode: 'compatibility' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = svc.prepareForProvisioning(tempRoot, opts);
|
||||||
|
const second = svc.prepareForProvisioning(tempRoot, opts);
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 20 && prepare.mock.calls.length === 0; attempt += 1) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
expect(prepare).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prepareGate.release).toBeTypeOf('function');
|
||||||
|
prepareGate.release?.();
|
||||||
|
|
||||||
|
const [firstResult, secondResult] = await Promise.all([first, second]);
|
||||||
|
|
||||||
|
expect(prepare).toHaveBeenCalledTimes(1);
|
||||||
|
expect(firstResult).not.toBe(secondResult);
|
||||||
|
expect(firstResult.ready).toBe(true);
|
||||||
|
expect(secondResult.ready).toBe(true);
|
||||||
|
expect(firstResult.details).toContain(
|
||||||
|
'Selected model opencode/big-pickle is compatible. Deep verification pending.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('checks every selected OpenCode model instead of only the first one', async () => {
|
it('checks every selected OpenCode model instead of only the first one', async () => {
|
||||||
const prepare = vi.fn(async (input: { model?: string }) => {
|
const prepare = vi.fn(async (input: { model?: string }) => {
|
||||||
if (input.model === 'opencode/nemotron-3-super-free') {
|
if (input.model === 'opencode/nemotron-3-super-free') {
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,18 @@ describe('cli child process helpers', () => {
|
||||||
expect(result).toEqual({} as any);
|
expect(result).toEqual({} as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hides spawned CLI windows by default but preserves explicit opt-out', () => {
|
||||||
|
setPlatform('win32');
|
||||||
|
const spawnMock = child.spawn as unknown as Mock;
|
||||||
|
spawnMock.mockReturnValue({} as any);
|
||||||
|
|
||||||
|
spawnCli('C:\\bin\\claude.exe', ['--version']);
|
||||||
|
expect(spawnMock.mock.calls[0][2]).toMatchObject({ windowsHide: true });
|
||||||
|
|
||||||
|
spawnCli('C:\\bin\\claude.exe', ['--version'], { windowsHide: false });
|
||||||
|
expect(spawnMock.mock.calls[1][2]).toMatchObject({ windowsHide: false });
|
||||||
|
});
|
||||||
|
|
||||||
it('falls back to shell when spawn throws EINVAL', () => {
|
it('falls back to shell when spawn throws EINVAL', () => {
|
||||||
setPlatform('win32');
|
setPlatform('win32');
|
||||||
const error: any = new Error('spawn EINVAL');
|
const error: any = new Error('spawn EINVAL');
|
||||||
|
|
@ -296,6 +308,23 @@ describe('cli child process helpers', () => {
|
||||||
expect(result.stdout).toBe('ok');
|
expect(result.stdout).toBe('ok');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hides exec CLI windows by default but preserves explicit opt-out', async () => {
|
||||||
|
setPlatform('win32');
|
||||||
|
const execFileMock = child.execFile as unknown as Mock;
|
||||||
|
execFileMock.mockImplementation(
|
||||||
|
(_cmd: string, _args: string[], _opts: unknown, cb: ExecCallback) => {
|
||||||
|
cb(null, 'ok', '');
|
||||||
|
return {} as any;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await execCli('C:\\bin\\claude.exe', ['--version']);
|
||||||
|
expect(execFileMock.mock.calls[0][2]).toMatchObject({ windowsHide: true });
|
||||||
|
|
||||||
|
await execCli('C:\\bin\\claude.exe', ['--version'], { windowsHide: false });
|
||||||
|
expect(execFileMock.mock.calls[1][2]).toMatchObject({ windowsHide: false });
|
||||||
|
});
|
||||||
|
|
||||||
it('skips straight to shell for Windows cmd launchers', async () => {
|
it('skips straight to shell for Windows cmd launchers', async () => {
|
||||||
setPlatform('win32');
|
setPlatform('win32');
|
||||||
const execFileMock = child.execFile as unknown as Mock;
|
const execFileMock = child.execFile as unknown as Mock;
|
||||||
|
|
|
||||||
|
|
@ -117,13 +117,13 @@ describe('shellEnv', () => {
|
||||||
1,
|
1,
|
||||||
'/bin/zsh',
|
'/bin/zsh',
|
||||||
['-lic', 'env -0'],
|
['-lic', 'env -0'],
|
||||||
expect.any(Object)
|
expect.objectContaining({ windowsHide: true })
|
||||||
);
|
);
|
||||||
expect(hoisted.spawn).toHaveBeenNthCalledWith(
|
expect(hoisted.spawn).toHaveBeenNthCalledWith(
|
||||||
2,
|
2,
|
||||||
'/bin/zsh',
|
'/bin/zsh',
|
||||||
['-ic', 'env -0'],
|
['-ic', 'env -0'],
|
||||||
expect.any(Object)
|
expect.objectContaining({ windowsHide: true })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,51 @@ const fetchCliStatus = vi.fn();
|
||||||
const createSchedule = vi.fn();
|
const createSchedule = vi.fn();
|
||||||
const updateSchedule = vi.fn();
|
const updateSchedule = vi.fn();
|
||||||
const teamRosterEditorSectionMock = vi.hoisted(() => ({ lastProps: null as any }));
|
const teamRosterEditorSectionMock = vi.hoisted(() => ({ lastProps: null as any }));
|
||||||
|
const createTeamDraftMock = vi.hoisted(() => ({
|
||||||
|
state: {
|
||||||
|
teamName: 'team-alpha',
|
||||||
|
setTeamName: vi.fn(),
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
id: 'member-opencode',
|
||||||
|
name: 'tom',
|
||||||
|
roleSelection: '',
|
||||||
|
customRole: 'Developer',
|
||||||
|
workflow: '',
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'opencode/big-pickle',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'member-codex',
|
||||||
|
name: 'bob',
|
||||||
|
roleSelection: '',
|
||||||
|
customRole: 'Developer',
|
||||||
|
workflow: '',
|
||||||
|
providerId: 'codex',
|
||||||
|
model: 'gpt-5.5',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
setMembers: vi.fn(),
|
||||||
|
syncModelsWithLead: false,
|
||||||
|
setSyncModelsWithLead: vi.fn(),
|
||||||
|
teammateWorktreeDefault: false,
|
||||||
|
setTeammateWorktreeDefault: vi.fn(),
|
||||||
|
cwdMode: 'project' as const,
|
||||||
|
setCwdMode: vi.fn(),
|
||||||
|
selectedProjectPath: '/tmp/project',
|
||||||
|
setSelectedProjectPath: vi.fn(),
|
||||||
|
customCwd: '',
|
||||||
|
setCustomCwd: vi.fn(),
|
||||||
|
soloTeam: false,
|
||||||
|
setSoloTeam: vi.fn(),
|
||||||
|
launchTeam: true,
|
||||||
|
setLaunchTeam: vi.fn(),
|
||||||
|
teamColor: 'slate',
|
||||||
|
setTeamColor: vi.fn(),
|
||||||
|
isLoaded: true,
|
||||||
|
clearDraft: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
const storeState = {
|
const storeState = {
|
||||||
appConfig: { general: { multimodelEnabled: true } },
|
appConfig: { general: { multimodelEnabled: true } },
|
||||||
|
|
@ -141,6 +186,20 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
|
||||||
effort: draft.effort as 'low' | 'medium' | 'high' | undefined,
|
effort: draft.effort as 'low' | 'medium' | 'high' | undefined,
|
||||||
fastMode: draft.fastMode as 'inherit' | 'on' | 'off' | undefined,
|
fastMode: draft.fastMode as 'inherit' | 'on' | 'off' | undefined,
|
||||||
})),
|
})),
|
||||||
|
createMemberDraft: (member: any = {}) => ({
|
||||||
|
id: member.id ?? 'draft-member',
|
||||||
|
name: member.name ?? '',
|
||||||
|
originalName: member.originalName ?? member.name ?? '',
|
||||||
|
roleSelection: member.roleSelection ?? '',
|
||||||
|
customRole: member.customRole ?? '',
|
||||||
|
workflow: member.workflow ?? '',
|
||||||
|
isolation: member.isolation,
|
||||||
|
providerId: member.providerId,
|
||||||
|
providerBackendId: member.providerBackendId,
|
||||||
|
model: member.model ?? '',
|
||||||
|
effort: member.effort,
|
||||||
|
fastMode: member.fastMode,
|
||||||
|
}),
|
||||||
clearMemberModelOverrides: (member: unknown) => member,
|
clearMemberModelOverrides: (member: unknown) => member,
|
||||||
createMemberDraftsFromInputs: (
|
createMemberDraftsFromInputs: (
|
||||||
members: Array<{
|
members: Array<{
|
||||||
|
|
@ -228,6 +287,10 @@ vi.mock('@renderer/components/ui/button', () => ({
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/ui/auto-resize-textarea', () => ({
|
||||||
|
AutoResizeTextarea: (props: Record<string, unknown>) => React.createElement('textarea', props),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@renderer/components/ui/checkbox', () => ({
|
vi.mock('@renderer/components/ui/checkbox', () => ({
|
||||||
Checkbox: ({
|
Checkbox: ({
|
||||||
checked,
|
checked,
|
||||||
|
|
@ -307,6 +370,10 @@ vi.mock('@renderer/hooks/useChipDraftPersistence', () => ({
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@renderer/hooks/useCreateTeamDraft', () => ({
|
||||||
|
useCreateTeamDraft: () => createTeamDraftMock.state,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@renderer/hooks/useDraftPersistence', () => ({
|
vi.mock('@renderer/hooks/useDraftPersistence', () => ({
|
||||||
useDraftPersistence: () => {
|
useDraftPersistence: () => {
|
||||||
const [value, setValue] = React.useState('');
|
const [value, setValue] = React.useState('');
|
||||||
|
|
@ -447,6 +514,7 @@ vi.mock('@renderer/components/team/dialogs/CodexFastModeSelector', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
|
import { CreateTeamDialog } from '@renderer/components/team/dialogs/CreateTeamDialog';
|
||||||
import { LaunchTeamDialog } from '@renderer/components/team/dialogs/LaunchTeamDialog';
|
import { LaunchTeamDialog } from '@renderer/components/team/dialogs/LaunchTeamDialog';
|
||||||
import { runProviderPrepareDiagnostics } from '@renderer/components/team/dialogs/providerPrepareDiagnostics';
|
import { runProviderPrepareDiagnostics } from '@renderer/components/team/dialogs/providerPrepareDiagnostics';
|
||||||
import { isTeamModelAvailableForUi } from '@renderer/utils/teamModelAvailability';
|
import { isTeamModelAvailableForUi } from '@renderer/utils/teamModelAvailability';
|
||||||
|
|
@ -461,6 +529,7 @@ describe('LaunchTeamDialog', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
|
vi.useRealTimers();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
storeState.cliStatus = { providers: [] };
|
storeState.cliStatus = { providers: [] };
|
||||||
storeState.launchParamsByTeam = {};
|
storeState.launchParamsByTeam = {};
|
||||||
|
|
@ -1801,4 +1870,154 @@ describe('LaunchTeamDialog', () => {
|
||||||
await flush();
|
await flush();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps create-team preflight alive across same-signature rerenders', async () => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
vi.useFakeTimers();
|
||||||
|
storeState.cliStatus = {
|
||||||
|
flavor: 'agent_teams_orchestrator',
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
providerId: 'anthropic',
|
||||||
|
supported: true,
|
||||||
|
authenticated: true,
|
||||||
|
authMethod: 'api_key',
|
||||||
|
verificationState: 'verified',
|
||||||
|
modelVerificationState: 'verified',
|
||||||
|
statusMessage: null,
|
||||||
|
detailMessage: null,
|
||||||
|
models: ['haiku'],
|
||||||
|
modelCatalog: {
|
||||||
|
source: 'live',
|
||||||
|
status: 'ready',
|
||||||
|
models: [{ id: 'haiku' }],
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
teamLaunch: true,
|
||||||
|
oneShot: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
providerId: 'codex',
|
||||||
|
supported: true,
|
||||||
|
authenticated: true,
|
||||||
|
authMethod: 'chatgpt',
|
||||||
|
verificationState: 'verified',
|
||||||
|
modelVerificationState: 'verified',
|
||||||
|
statusMessage: null,
|
||||||
|
detailMessage: null,
|
||||||
|
selectedBackendId: 'codex-native',
|
||||||
|
resolvedBackendId: 'codex-native',
|
||||||
|
models: ['gpt-5.5'],
|
||||||
|
modelCatalog: {
|
||||||
|
source: 'app-server',
|
||||||
|
status: 'ready',
|
||||||
|
models: [{ id: 'gpt-5.5' }],
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
teamLaunch: true,
|
||||||
|
oneShot: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
providerId: 'opencode',
|
||||||
|
supported: true,
|
||||||
|
authenticated: true,
|
||||||
|
authMethod: 'opencode_managed',
|
||||||
|
verificationState: 'verified',
|
||||||
|
modelVerificationState: 'verified',
|
||||||
|
statusMessage: 'warming up',
|
||||||
|
detailMessage: 'first render',
|
||||||
|
models: ['opencode/big-pickle'],
|
||||||
|
modelCatalog: {
|
||||||
|
source: 'app-server',
|
||||||
|
status: 'ready',
|
||||||
|
models: [{ id: 'opencode/big-pickle' }],
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
teamLaunch: true,
|
||||||
|
oneShot: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
let resolvePrepare!: (value: {
|
||||||
|
status: 'ready';
|
||||||
|
warnings: [];
|
||||||
|
details: [];
|
||||||
|
modelResultsById: {};
|
||||||
|
}) => void;
|
||||||
|
const preparePromise = new Promise<{
|
||||||
|
status: 'ready';
|
||||||
|
warnings: [];
|
||||||
|
details: [];
|
||||||
|
modelResultsById: {};
|
||||||
|
}>((resolve) => {
|
||||||
|
resolvePrepare = resolve;
|
||||||
|
});
|
||||||
|
vi.mocked(runProviderPrepareDiagnostics).mockReturnValue(preparePromise as any);
|
||||||
|
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
const renderDialog = async (): Promise<void> => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(CreateTeamDialog, {
|
||||||
|
open: true,
|
||||||
|
canCreate: true,
|
||||||
|
provisioningErrorsByTeam: {},
|
||||||
|
clearProvisioningError: vi.fn(),
|
||||||
|
existingTeamNames: [],
|
||||||
|
provisioningTeamNames: [],
|
||||||
|
activeTeams: [],
|
||||||
|
defaultProjectPath: '/tmp/project',
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onCreate: vi.fn(async () => {}),
|
||||||
|
onOpenTeam: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
};
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await renderDialog();
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
vi.runOnlyPendingTimers();
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(vi.mocked(runProviderPrepareDiagnostics)).toHaveBeenCalled();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await renderDialog();
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
const callsAfterSameSignatureRerender = vi.mocked(runProviderPrepareDiagnostics).mock.calls.length;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolvePrepare({
|
||||||
|
status: 'ready',
|
||||||
|
warnings: [],
|
||||||
|
details: [],
|
||||||
|
modelResultsById: {},
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(vi.mocked(runProviderPrepareDiagnostics)).toHaveBeenCalledTimes(
|
||||||
|
callsAfterSameSignatureRerender
|
||||||
|
);
|
||||||
|
expect(host.textContent).toContain('Selected providers are ready.');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue