agent-ecosystem/test/main/services/team/OpenCodeBridgeCommandClient.test.ts
2026-05-22 16:49:23 +03:00

673 lines
18 KiB
TypeScript

import { promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
OpenCodeBridgeCommandClient,
type OpenCodeBridgeDiagnosticsSink,
type OpenCodeBridgeProcessRunInput,
type OpenCodeBridgeProcessRunner,
type OpenCodeBridgeProcessRunResult,
redactBridgeDiagnosticText,
resolveOpenCodeBridgeProcessCwd,
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient';
import type {
OpenCodeBridgeDiagnosticEvent,
OpenCodeBridgeSuccess,
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract';
let tempDir: string;
let runner: FakeBridgeProcessRunner;
let diagnostics: FakeDiagnosticsSink;
describe('OpenCodeBridgeCommandClient', () => {
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-bridge-client-'));
runner = new FakeBridgeProcessRunner();
diagnostics = new FakeDiagnosticsSink();
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
it('writes a private input envelope, executes the bridge command, and removes the input file', async () => {
runner.nextResult = {
stdout: `${JSON.stringify(bridgeSuccess({ data: { runId: 'run-1' } }))}\n`,
stderr: '',
exitCode: 0,
timedOut: false,
};
const client = createClient();
const result = await client.execute(
'opencode.launchTeam',
{ runId: 'run-1' },
{
cwd: '/tmp/project',
timeoutMs: 10_000,
}
);
expect(result).toMatchObject({
ok: true,
requestId: 'req-1',
command: 'opencode.launchTeam',
});
expect(runner.calls).toHaveLength(1);
expect(runner.calls[0]).toMatchObject({
binaryPath: '/usr/local/bin/agent-teams-controller',
args: [
'runtime',
'opencode-command',
'--json',
'--input',
expect.any(String),
'--output',
expect.any(String),
],
cwd: '/tmp/project',
timeoutMs: 10_000,
env: expect.objectContaining({
OPENCODE_DISABLE_AUTOUPDATE: '1',
}),
});
const inputPath = runner.calls[0].args[4];
const outputPath = runner.calls[0].args[6];
expect(JSON.parse(await runner.readInputEnvelope(0))).toMatchObject({
schemaVersion: 1,
requestId: 'req-1',
command: 'opencode.launchTeam',
cwd: '/tmp/project',
timeoutMs: 10_000,
body: { runId: 'run-1' },
});
await expect(fs.access(inputPath)).rejects.toThrow();
await expect(fs.access(outputPath)).rejects.toThrow();
});
it('reads bridge JSON from the output file when stdout is empty', async () => {
runner.nextResult = {
stdout: '',
stderr: '',
exitCode: 0,
timedOut: false,
};
runner.nextOutputFileContents = `${JSON.stringify(bridgeSuccess({ data: { runId: 'run-1' } }))}\n`;
const client = createClient();
const result = await client.execute(
'opencode.launchTeam',
{ runId: 'run-1' },
{
cwd: '/tmp/project',
timeoutMs: 10_000,
}
);
expect(result).toMatchObject({
ok: true,
requestId: 'req-1',
command: 'opencode.launchTeam',
});
});
it('keeps bridge temp file names safe when requestId contains Windows path characters', async () => {
const requestId = 'req:windows/path\\unsafe*id?';
runner.nextResult = {
stdout: `${JSON.stringify(bridgeSuccess({ requestId, data: { runId: 'run-1' } }))}\n`,
stderr: '',
exitCode: 0,
timedOut: false,
};
const client = createClient();
const result = await client.execute(
'opencode.launchTeam',
{ runId: 'run-1' },
{
cwd: '/tmp/project',
timeoutMs: 10_000,
requestId,
}
);
const inputPath = runner.calls[0].args[4];
expect(result).toMatchObject({
ok: true,
requestId,
});
expect(path.dirname(inputPath)).toBe(tempDir);
expect(path.basename(inputPath)).not.toContain('/');
expect(path.basename(inputPath)).not.toContain('\\');
expect(path.basename(inputPath)).not.toContain(':');
expect(JSON.parse(await runner.readInputEnvelope(0))).toMatchObject({
requestId,
});
});
it('prefers a non-empty output file over process stdout wrapper text', async () => {
runner.nextResult = {
stdout: '{"ok":true,"command":"opencode.launchTeam","requestId":"req-1","bytes":512}\n',
stderr: '',
exitCode: 0,
timedOut: false,
};
runner.nextOutputFileContents = `${JSON.stringify(
bridgeSuccess({ data: { runId: 'run-1' } })
)}\n`;
const client = createClient();
const result = await client.execute(
'opencode.launchTeam',
{ runId: 'run-1' },
{
cwd: '/tmp/project',
timeoutMs: 10_000,
}
);
expect(result).toMatchObject({
ok: true,
requestId: 'req-1',
command: 'opencode.launchTeam',
});
});
it('fails closed when stdout contains logs plus json', async () => {
runner.nextResult = {
stdout: 'debug token=secret\n{"ok":true}\n',
stderr: '',
exitCode: 0,
timedOut: false,
};
const client = createClient();
const result = await client.execute(
'opencode.launchTeam',
{ runId: 'run-1' },
{
cwd: '/tmp/project',
timeoutMs: 10_000,
}
);
expect(result).toMatchObject({
ok: false,
error: {
kind: 'contract_violation',
retryable: false,
},
});
expect(diagnostics.append).toHaveBeenCalledWith(
expect.objectContaining({
type: 'opencode_bridge_contract_violation',
severity: 'error',
runId: 'run-1',
data: expect.objectContaining({
stdoutPreview: 'debug token=[redacted]\n{"ok":true}\n',
}),
})
);
});
it('records bridge timeout as unknown outcome with redacted diagnostics', async () => {
runner.nextResult = {
stdout: '',
stderr: 'Authorization: Bearer live-token',
exitCode: null,
timedOut: true,
};
const client = createClient();
const result = await client.execute(
'opencode.launchTeam',
{ runId: 'run-1' },
{
cwd: '/tmp/project',
timeoutMs: 10_000,
}
);
expect(result).toMatchObject({
ok: false,
error: {
kind: 'timeout',
retryable: true,
details: {
stderr: 'Authorization: Bearer [redacted]',
},
},
diagnostics: [
expect.objectContaining({
type: 'opencode_bridge_unknown_outcome',
severity: 'warning',
}),
],
});
});
it('keeps bridge failures best-effort when the diagnostics sink fails', async () => {
runner.nextResult = {
stdout: '',
stderr: '',
exitCode: 0,
timedOut: false,
};
diagnostics.append.mockRejectedValueOnce(new Error('disk full'));
const client = createClient();
await expect(
client.execute(
'opencode.launchTeam',
{ runId: 'run-1' },
{
cwd: '/tmp/project',
timeoutMs: 10_000,
}
)
).resolves.toMatchObject({
ok: false,
error: {
kind: 'contract_violation',
message: 'Bridge stdout was empty',
},
});
});
it('turns non-zero process exit into provider_error without parsing stdout', async () => {
runner.nextResult = {
stdout: `${JSON.stringify(bridgeSuccess())}\n`,
stderr: 'api_key=secret failed',
exitCode: 2,
timedOut: false,
};
const client = createClient();
const result = await client.execute(
'opencode.launchTeam',
{ runId: 'run-1' },
{
cwd: '/tmp/project',
timeoutMs: 10_000,
}
);
expect(result).toMatchObject({
ok: false,
error: {
kind: 'provider_error',
retryable: true,
details: {
exitCode: 2,
stderr: 'api_key=[redacted] failed',
},
},
});
});
it('retries empty stdout once for readiness because it is read-only', async () => {
runner.nextResults = [
{
stdout: '',
stderr: '',
exitCode: 0,
timedOut: false,
},
{
stdout: `${JSON.stringify(
bridgeSuccess({
command: 'opencode.readiness',
data: { state: 'ready', launchAllowed: true },
})
)}\n`,
stderr: '',
exitCode: 0,
timedOut: false,
},
];
const client = createClient();
const result = await client.execute(
'opencode.readiness',
{ projectPath: '/tmp/project' },
{
cwd: '/tmp/project',
timeoutMs: 10_000,
}
);
expect(result).toMatchObject({
ok: true,
requestId: 'req-1',
command: 'opencode.readiness',
});
expect(runner.calls).toHaveLength(2);
});
it('keeps empty readiness stdout diagnostics after the retry is exhausted', async () => {
runner.nextResults = [
{
stdout: '',
stderr: '',
exitCode: 0,
timedOut: false,
},
{
stdout: '',
stderr: '',
exitCode: 0,
timedOut: false,
},
];
const client = createClient();
const result = await client.execute(
'opencode.readiness',
{ projectPath: '/tmp/project' },
{
cwd: '/tmp/project',
timeoutMs: 10_000,
}
);
expect(result).toMatchObject({
ok: false,
error: {
kind: 'contract_violation',
message: 'Bridge stdout was empty',
details: {
attempts: 2,
stdoutBytes: 0,
stderrBytes: 0,
outputSource: 'none',
outputFileBytes: 0,
outputReadError: 'ENOENT',
},
},
});
expect(runner.calls).toHaveLength(2);
});
it('does not retry empty stdout for state-changing bridge commands', async () => {
runner.nextResults = [
{
stdout: '',
stderr: '',
exitCode: 0,
timedOut: false,
},
{
stdout: `${JSON.stringify(bridgeSuccess({ data: { runId: 'run-1' } }))}\n`,
stderr: '',
exitCode: 0,
timedOut: false,
},
];
const client = createClient();
const result = await client.execute(
'opencode.launchTeam',
{ runId: 'run-1' },
{
cwd: '/tmp/project',
timeoutMs: 10_000,
}
);
expect(result).toMatchObject({
ok: false,
error: {
kind: 'contract_violation',
message: 'Bridge stdout was empty',
details: {
command: 'opencode.launchTeam',
requestId: 'req-1',
attempts: 1,
exitCode: 0,
timedOut: false,
stdoutBytes: 0,
stderrBytes: 0,
outputSource: 'none',
outputFileBytes: 0,
outputReadError: 'ENOENT',
},
},
});
expect(runner.calls).toHaveLength(1);
expect(diagnostics.append).toHaveBeenCalledWith(
expect.objectContaining({
type: 'opencode_bridge_contract_violation',
data: expect.objectContaining({
attempts: 1,
outputReadError: 'ENOENT',
}),
})
);
});
it('rejects bridge result envelope mismatches before caller can mutate state', async () => {
runner.nextResult = {
stdout: `${JSON.stringify(bridgeSuccess({ requestId: 'other-req' }))}\n`,
stderr: '',
exitCode: 0,
timedOut: false,
};
const client = createClient();
const result = await client.execute(
'opencode.launchTeam',
{ runId: 'run-1' },
{
cwd: '/tmp/project',
timeoutMs: 10_000,
}
);
expect(result).toMatchObject({
ok: false,
error: {
kind: 'contract_violation',
message: 'OpenCode bridge requestId mismatch',
retryable: false,
},
});
});
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',
});
});
it('runs Windows batch launchers from their launcher directory while preserving envelope cwd', async () => {
runner.nextResult = {
stdout: `${JSON.stringify(bridgeSuccess({ data: { runId: 'run-1' } }))}\n`,
stderr: '',
exitCode: 0,
timedOut: false,
};
const client = createClient({
binaryPath: 'C:\\runtime\\agent_teams_orchestrator\\cli-dev.cmd',
});
await client.execute(
'opencode.launchTeam',
{ runId: 'run-1' },
{
cwd: 'C:\\projects\\team workspace',
timeoutMs: 10_000,
}
);
expect(runner.calls[0].cwd).toBe(
process.platform === 'win32'
? 'C:\\runtime\\agent_teams_orchestrator'
: 'C:\\projects\\team workspace'
);
expect(JSON.parse(await runner.readInputEnvelope(0))).toMatchObject({
cwd: 'C:\\projects\\team workspace',
});
});
});
describe('redactBridgeDiagnosticText', () => {
it('redacts common secret forms and caps large payloads', () => {
const value = `token=abc password:secret Authorization: Bearer live ${'x'.repeat(5000)}`;
const redacted = redactBridgeDiagnosticText(value);
expect(redacted).toContain('token=[redacted]');
expect(redacted).toContain('password:[redacted]');
expect(redacted).toContain('Authorization: Bearer [redacted]');
expect(redacted).toContain('[truncated]');
expect(redacted.length).toBeLessThan(4_100);
});
});
describe('resolveOpenCodeBridgeProcessCwd', () => {
it('keeps non-Windows launchers on the requested project cwd', () => {
expect(
resolveOpenCodeBridgeProcessCwd('/usr/local/bin/claude-multimodel', '/repo', 'linux')
).toBe('/repo');
});
it('uses the launcher directory for Windows batch launchers', () => {
expect(
resolveOpenCodeBridgeProcessCwd(
'C:\\runtime\\agent_teams_orchestrator\\cli-dev.cmd',
'C:\\projects\\team workspace',
'win32'
)
).toBe('C:\\runtime\\agent_teams_orchestrator');
});
it('keeps Windows exe launchers on the requested project cwd', () => {
expect(
resolveOpenCodeBridgeProcessCwd(
'C:\\runtime-cache\\claude-multimodel.exe',
'C:\\projects\\team workspace',
'win32'
)
).toBe('C:\\projects\\team workspace');
});
});
function createClient(
overrides: Partial<ConstructorParameters<typeof OpenCodeBridgeCommandClient>[0]> = {}
): OpenCodeBridgeCommandClient {
return new OpenCodeBridgeCommandClient({
binaryPath: '/usr/local/bin/agent-teams-controller',
tempDirectory: tempDir,
processRunner: runner,
diagnostics,
requestIdFactory: () => 'req-1',
diagnosticIdFactory: () => 'diag-1',
clock: () => new Date('2026-04-21T12:00:00.000Z'),
env: { PATH: '/usr/bin' },
...overrides,
});
}
function bridgeSuccess(
overrides: Partial<OpenCodeBridgeSuccess<unknown>> = {}
): OpenCodeBridgeSuccess<unknown> {
return {
ok: true,
schemaVersion: 1,
requestId: 'req-1',
command: 'opencode.launchTeam',
completedAt: '2026-04-21T12:00:01.000Z',
durationMs: 1000,
runtime: {
providerId: 'opencode',
binaryPath: '/usr/local/bin/opencode',
binaryFingerprint: 'bin-1',
version: '1.0.0',
capabilitySnapshotId: 'cap-1',
},
diagnostics: [],
data: {
runId: 'run-1',
},
...overrides,
};
}
class FakeBridgeProcessRunner implements OpenCodeBridgeProcessRunner {
calls: OpenCodeBridgeProcessRunInput[] = [];
inputEnvelopes: string[] = [];
nextResults: OpenCodeBridgeProcessRunResult[] = [];
nextResult: OpenCodeBridgeProcessRunResult = {
stdout: '',
stderr: '',
exitCode: 0,
timedOut: false,
};
nextOutputFileContents: string | null = null;
async run(input: OpenCodeBridgeProcessRunInput): Promise<OpenCodeBridgeProcessRunResult> {
this.calls.push(input);
this.inputEnvelopes.push(await fs.readFile(input.args[4], 'utf8'));
const outputFlagIndex = input.args.indexOf('--output');
const outputPath = outputFlagIndex >= 0 ? input.args[outputFlagIndex + 1] : undefined;
if (this.nextOutputFileContents !== null && outputPath) {
await fs.writeFile(outputPath, this.nextOutputFileContents, 'utf8');
this.nextOutputFileContents = null;
}
return this.nextResults.shift() ?? this.nextResult;
}
async readInputEnvelope(index: number): Promise<string> {
return this.inputEnvelopes[index];
}
}
class FakeDiagnosticsSink implements OpenCodeBridgeDiagnosticsSink {
readonly events: OpenCodeBridgeDiagnosticEvent[] = [];
readonly append = vi.fn(async (event: OpenCodeBridgeDiagnosticEvent) => {
this.events.push(event);
});
}