fix(opencode): harden local provider launches

This commit is contained in:
777genius 2026-05-20 22:49:42 +03:00
parent 187a2697f7
commit fd50f736b8
6 changed files with 868 additions and 11 deletions

View file

@ -102,11 +102,12 @@ const SECRET_FLAG_PATTERN =
const BEARER_TOKEN_PATTERN = /\bBearer\s+\S+/gi;
const SECRET_KEY_PATTERN = /\bsk-[A-Za-z0-9_-]{16,}\b/g;
const OPEN_CODE_CAPABILITY_SNAPSHOT_REFRESH_RETRY_WARNING =
'OpenCode capability snapshot changed between readiness and launch; refreshed readiness and retried once.';
'OpenCode capability snapshot changed between readiness and launch; refreshed readiness and retried launch.';
const OPEN_CODE_CAPABILITY_SNAPSHOT_PRELAUNCH_MISMATCH_MARKERS = [
'Bridge server capability snapshot mismatch',
'OpenCode bridge capability snapshot precondition mismatch',
];
const OPEN_CODE_CAPABILITY_SNAPSHOT_REFRESH_RETRY_LIMIT = 3;
const OPEN_CODE_READINESS_RETRY_DELAYS_MS = [750, 2_000] as const;
type OpenCodeTeamLaunchReadinessInput = Parameters<
@ -303,7 +304,13 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
let data = await this.bridge.launchOpenCodeTeam(
buildLaunchCommand(runtimeSnapshot, selectedModel)
);
if (!skipReadinessPreflight && isOpenCodePreLaunchCapabilitySnapshotMismatchData(data)) {
let capabilitySnapshotRefreshAttempts = 0;
while (
!skipReadinessPreflight &&
isOpenCodePreLaunchCapabilitySnapshotMismatchData(data) &&
capabilitySnapshotRefreshAttempts < OPEN_CODE_CAPABILITY_SNAPSHOT_REFRESH_RETRY_LIMIT
) {
capabilitySnapshotRefreshAttempts += 1;
const refreshed = await this.prepare(input);
if (!refreshed.ok) {
return blockedLaunchResult(
@ -335,6 +342,8 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
`opencode-capability-recovery-${randomUUID()}`
)
);
} else {
break;
}
}
@ -689,6 +698,7 @@ function mapOpenCodeLaunchDataToRuntimeResult(
member.name,
fallbackLaunchState,
bridgeMember?.sessionId,
bridgeMember?.model,
bridgeMember?.runtimePid,
bridgeMember?.pendingPermissionRequestIds,
bridgeMember != null,
@ -792,6 +802,7 @@ function mapBridgeMemberToRuntimeEvidence(
memberName: string,
launchState: OpenCodeTeamMemberLaunchBridgeState,
sessionId: string | undefined,
model: string | undefined,
runtimePid: number | undefined,
pendingPermissionRequestIds: string[] | undefined,
runtimeMaterialized: boolean,
@ -855,6 +866,7 @@ function mapBridgeMemberToRuntimeEvidence(
return {
memberName,
providerId: 'opencode',
...(isNonEmptyString(model) ? { model: model.trim() } : {}),
launchState: failed
? 'failed_to_start'
: confirmed
@ -1074,6 +1086,7 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
'Do not mark the review complete from this prompt alone.',
'A visible agent-teams_message_send reply is optional. Concrete review progress, review tool usage, or agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) is sufficient response proof.',
`If you cannot pick up the review now, call agent-teams_member_work_sync_status (or mcp__agent-teams__member_work_sync_status) with ${workSyncToolArgs}, then report state "blocked" or "still_working" only for the real current state.`,
'Do not stop after member_work_sync_status. A status-only tool call is incomplete; member_work_sync_report is the required proof.',
taskIds.length ? `Relevant taskIds: ${taskIds.map((id) => `"${id}"`).join(', ')}.` : null,
`Do not use provider names, runtime names, or team names as memberName; use exactly "${input.memberName}".`,
'Do not reply only with acknowledgement.',
@ -1084,6 +1097,7 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
'A visible agent-teams_message_send reply is optional. Concrete task progress or agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) is sufficient response proof.',
`Call agent-teams_member_work_sync_status (or mcp__agent-teams__member_work_sync_status) with ${workSyncToolArgs}.`,
`Then call agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) with ${workSyncToolArgs}, the returned agendaFingerprint/reportToken, and state "still_working" or "blocked".`,
'Do not stop after member_work_sync_status. A status-only tool call is incomplete; member_work_sync_report is the required proof.',
taskIds.length
? `When reporting, include taskIds: ${taskIds.map((id) => `"${id}"`).join(', ')}.`
: null,

View file

@ -1,7 +1,6 @@
import { promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
@ -17,6 +16,7 @@ import {
getTeamsBasePath,
setClaudeBasePathOverride,
} from '../../../../src/main/utils/pathDecoder';
import {
formatMemberWorkSyncDiagnostics,
formatProgressDump,
@ -25,9 +25,9 @@ import {
} from './memberWorkSyncLiveHarness';
import {
createOpenCodeLiveHarness,
type OpenCodeLiveHarness,
readInboxMessages,
waitForOpenCodeLanesStopped,
type OpenCodeLiveHarness,
} from './openCodeLiveTestHarness';
import type { TeamChangeEvent, TeamProvisioningProgress } from '../../../../src/shared/types';
@ -69,7 +69,7 @@ liveDescribe('Member work sync OpenCode live e2e', () => {
} else {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
}, 90_000);
it(
'delivers a work-sync nudge to a real OpenCode member and accepts its still-working report',

View file

@ -0,0 +1,182 @@
import { execFile } from 'child_process';
import { constants as fsConstants, promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
import { promisify } from 'util';
import { describe, expect, it } from 'vitest';
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
const liveDescribe =
process.env.OPENCODE_DEFAULT_MODEL_RESOLUTION_LIVE_E2E === '1' ? describe : describe.skip;
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli';
const execFileAsync = promisify(execFile);
interface ProviderModelListResponse {
providers?: {
opencode?: {
defaultModel?: string;
models?: Array<{ id?: string }>;
};
};
}
interface RuntimeStatusResponse {
providers?: {
opencode?: {
models?: string[];
modelCatalog?: {
defaultLaunchModel?: string | null;
models?: Array<{ id?: string; launchModel?: string; displayName?: string }>;
};
};
};
}
type DefaultModelResolver = {
resolveProviderDefaultModel: (
claudePath: string,
cwd: string,
providerId: string,
env: NodeJS.ProcessEnv,
providerArgs: string[],
limitContext: boolean
) => Promise<string | null>;
materializeEffectiveTeamMemberSpecs: (params: {
claudePath: string;
cwd: string;
members: Array<{ name: string; providerId: 'opencode'; model?: string }>;
defaults: { providerId: 'anthropic' };
primaryProviderId: 'opencode';
primaryEnv: {
env: NodeJS.ProcessEnv;
authSource: string;
providerArgs: string[];
geminiRuntimeAuth: null;
};
providerArgsResolver: () => string[];
limitContext: boolean;
}) => Promise<Array<{ name: string; providerId: 'opencode'; model?: string }>>;
};
liveDescribe('OpenCode default model resolution live e2e', () => {
it('materializes an OpenCode Default teammate through the real model-list pipe', async () => {
const orchestratorCli =
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
await assertExecutable(orchestratorCli);
const env: NodeJS.ProcessEnv = {
...process.env,
PATH: withBunOnPath(process.env.PATH ?? ''),
OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1',
};
const svc = new TeamProvisioningService() as unknown as DefaultModelResolver;
const defaultModel = await svc.resolveProviderDefaultModel(
orchestratorCli,
process.cwd(),
'opencode',
env,
[],
false
);
expect(defaultModel).toMatch(/^opencode\/.+/);
await expect(
svc.materializeEffectiveTeamMemberSpecs({
claudePath: orchestratorCli,
cwd: process.cwd(),
members: [{ name: 'atlas', providerId: 'opencode' }],
defaults: { providerId: 'anthropic' },
primaryProviderId: 'opencode',
primaryEnv: {
env,
authSource: 'opencode_managed',
providerArgs: [],
geminiRuntimeAuth: null,
},
providerArgsResolver: () => [],
limitContext: false,
})
).resolves.toEqual([{ name: 'atlas', providerId: 'opencode', model: defaultModel }]);
}, 60_000);
it('keeps the real OpenCode catalog hydrated instead of summary-only big-pickle', async () => {
const orchestratorCli =
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
await assertExecutable(orchestratorCli);
const env = buildOpenCodeLiveEnv();
const modelList = await runJsonCommand<ProviderModelListResponse>(
orchestratorCli,
['model', 'list', '--json', '--provider', 'opencode'],
env
);
const modelListIds =
modelList.providers?.opencode?.models
?.map((model) => model.id?.trim())
.filter((id): id is string => Boolean(id)) ?? [];
expect(modelList.providers?.opencode?.defaultModel).toBe('opencode/big-pickle');
expect(modelListIds.length).toBeGreaterThan(50);
expect(modelListIds).toContain('opencode/big-pickle');
expect(modelListIds.some((id) => id !== 'opencode/big-pickle')).toBe(true);
const runtimeStatus = await runJsonCommand<RuntimeStatusResponse>(
orchestratorCli,
['runtime', 'status', '--json', '--provider', 'opencode'],
env
);
const provider = runtimeStatus.providers?.opencode;
const catalogIds =
provider?.modelCatalog?.models
?.map((model) => model.launchModel?.trim() || model.id?.trim())
.filter((id): id is string => Boolean(id)) ?? [];
expect(provider?.modelCatalog?.defaultLaunchModel).toBe('opencode/big-pickle');
expect(provider?.models?.length ?? 0).toBeGreaterThan(50);
expect(catalogIds.length).toBeGreaterThan(50);
expect(catalogIds).toContain('opencode/big-pickle');
}, 90_000);
});
async function assertExecutable(filePath: string): Promise<void> {
await fs.access(filePath, fsConstants.X_OK);
}
function withBunOnPath(value: string): string {
const candidates = [
process.env.BUN_INSTALL ? path.join(process.env.BUN_INSTALL, 'bin') : null,
process.env.HOME ? path.join(process.env.HOME, '.bun', 'bin') : null,
'/opt/homebrew/bin',
'/usr/local/bin',
].filter((candidate): candidate is string => Boolean(candidate));
return [...candidates, value].join(path.delimiter);
}
function buildOpenCodeLiveEnv(): NodeJS.ProcessEnv {
const realHome = os.userInfo().homedir;
return {
...process.env,
HOME: realHome,
USERPROFILE: realHome,
PATH: withBunOnPath(process.env.PATH ?? ''),
OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1',
};
}
async function runJsonCommand<T>(
binaryPath: string,
args: string[],
env: NodeJS.ProcessEnv
): Promise<T> {
const { stdout } = await execFileAsync(binaryPath, args, {
cwd: process.cwd(),
env,
timeout: 60_000,
maxBuffer: 1024 * 1024,
});
return JSON.parse(stdout) as T;
}

View file

@ -0,0 +1,391 @@
import { promises as fs } from 'node:fs';
import * as http from 'node:http';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
getTeamsBasePath,
setClaudeBasePathOverride,
} from '../../../../src/main/utils/pathDecoder';
import { formatProgressDump } from './memberWorkSyncLiveHarness';
import {
createOpenCodeLiveHarness,
type OpenCodeLiveHarness,
waitForOpenCodeLanesStopped,
waitUntil,
} from './openCodeLiveTestHarness';
import type { TeamProvisioningProgress } from '../../../../src/shared/types';
const liveDescribe =
process.env.OPENCODE_E2E === '1' && process.env.OPENCODE_E2E_LOCAL_PROVIDER_APP_LAUNCH === '1'
? describe
: describe.skip;
const LOCAL_MODEL = 'llama.cpp/qwen-test:0.5b';
liveDescribe('OpenCode local provider app launch live e2e', () => {
let tempDir: string;
let tempClaudeRoot: string;
let fakeServer: FakeOpenAiCompatibleServer | null;
let harness: OpenCodeLiveHarness | null;
let teamName: string | null;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-local-provider-app-launch-'));
tempClaudeRoot = path.join(tempDir, '.claude');
await fs.mkdir(tempClaudeRoot, { recursive: true });
setClaudeBasePathOverride(tempClaudeRoot);
fakeServer = null;
harness = null;
teamName = null;
});
afterEach(async () => {
if (harness && teamName) {
await harness.svc.stopTeam(teamName).catch(() => undefined);
await waitForOpenCodeLanesStopped(teamName);
}
await harness?.dispose().catch(() => undefined);
await fakeServer?.close().catch(() => undefined);
setClaudeBasePathOverride(null);
if (process.env.OPENCODE_E2E_KEEP_TEMP === '1') {
console.info(`[OpenCodeLocalProviderAppLaunch.live] preserved temp dir: ${tempDir}`);
} else {
await fs.rm(tempDir, { recursive: true, force: true });
}
clearBenignSlowConfigReadWarnings();
}, 90_000);
it(
'creates and stops an OpenCode team through the app service using a configured authless local provider',
async () => {
const projectPath = path.join(tempDir, 'project');
await fs.mkdir(projectPath, { recursive: true });
await fs.writeFile(
path.join(projectPath, 'README.md'),
'# OpenCode local provider app launch live e2e\n',
'utf8'
);
fakeServer = await startFakeOpenAiCompatibleServer();
await writeFakeLocalOpenCodeConfig({
projectPath,
baseUrl: fakeServer.baseUrl,
});
harness = await createOpenCodeLiveHarness({
tempDir,
selectedModel: LOCAL_MODEL,
projectPath,
});
teamName = `opencode-local-provider-app-${Date.now()}`;
const progressEvents: TeamProvisioningProgress[] = [];
const { runId } = await harness.svc.createTeam(
{
teamName,
cwd: projectPath,
providerId: 'opencode',
model: LOCAL_MODEL,
skipPermissions: true,
members: [
{
name: 'bob',
role: 'Developer',
providerId: 'opencode',
model: LOCAL_MODEL,
mcpPolicy: { mode: 'appOnly' },
},
],
},
(progress) => progressEvents.push(progress)
);
const progressDump = formatProgressDump(progressEvents);
expect(runId, progressDump).toBeTruthy();
expect(
progressEvents.some((progress) =>
progress.message.includes('OpenCode team launch is ready')
),
progressDump
).toBe(true);
expect(progressDump).not.toContain('provider not connected');
expect(progressDump).not.toContain('not authenticated');
expect(progressDump).not.toContain('OpenCode team launch is not enabled');
expect(fakeServer.requests, progressDump).toContain('POST /v1/chat/completions');
const runtimeSnapshot = await harness.svc.getTeamAgentRuntimeSnapshot(teamName);
expect(runtimeSnapshot.runId).toBe(runId);
expect(runtimeSnapshot.members.bob).toMatchObject({
alive: true,
providerId: 'opencode',
laneId: 'primary',
laneKind: 'primary',
runtimeModel: LOCAL_MODEL,
historicalBootstrapConfirmed: true,
});
const deliveryMarker = `local-provider-delivery-${Date.now()}`;
const chatBodyCountBeforeDelivery = fakeServer.chatBodies.length;
const delivery = await harness.svc.deliverOpenCodeMemberMessage(teamName, {
memberName: 'bob',
messageId: `local-provider-delivery-${Date.now()}`,
replyRecipient: 'user',
source: 'manual',
text: [
`Local provider delivery marker: ${deliveryMarker}`,
'Answer with PONG. Do not edit files.',
].join('\n'),
});
expect(delivery.delivered, JSON.stringify(delivery, null, 2)).toBe(true);
await waitUntil(
async () =>
fakeServer!.chatBodies.length > chatBodyCountBeforeDelivery &&
fakeServer!.chatBodies.some((body) => JSON.stringify(body).includes(deliveryMarker)),
60_000,
500
);
await harness.svc.stopTeam(teamName);
await waitForOpenCodeLanesStopped(teamName);
clearBenignSlowConfigReadWarnings();
},
300_000
);
it(
'fails app service launch for an unknown local model before creating OpenCode lanes',
async () => {
const projectPath = path.join(tempDir, 'unknown-model-project');
await fs.mkdir(projectPath, { recursive: true });
fakeServer = await startFakeOpenAiCompatibleServer();
await writeFakeLocalOpenCodeConfig({
projectPath,
baseUrl: fakeServer.baseUrl,
});
harness = await createOpenCodeLiveHarness({
tempDir,
selectedModel: 'llama.cpp/missing-test:0.5b',
projectPath,
});
teamName = `opencode-local-provider-unknown-${Date.now()}`;
const progressEvents: TeamProvisioningProgress[] = [];
const { runId } = await harness.svc.createTeam(
{
teamName,
cwd: projectPath,
providerId: 'opencode',
model: 'llama.cpp/missing-test:0.5b',
skipPermissions: true,
members: [
{
name: 'bob',
role: 'Developer',
providerId: 'opencode',
model: 'llama.cpp/missing-test:0.5b',
},
],
},
(progress) => progressEvents.push(progress)
);
expect(runId).toBeTruthy();
await waitUntil(
async () => progressEvents.some((progress) => progress.state === 'failed'),
30_000,
500
);
const progressDump = formatProgressDump(progressEvents);
expect(progressEvents.some((progress) => progress.state === 'failed'), progressDump).toBe(
true
);
expect(progressDump).toMatch(/missing-test:0\.5b|not available|unavailable/i);
expect(fakeServer.requests, progressDump).not.toContain('POST /v1/chat/completions');
await waitUntil(
async () => {
const laneIndexPath = path.join(
getTeamsBasePath(),
teamName!,
'runtime',
'opencode',
'lanes.json'
);
try {
const parsed = JSON.parse(await fs.readFile(laneIndexPath, 'utf8')) as {
lanes?: Record<string, unknown>;
};
return Object.keys(parsed.lanes ?? {}).length === 0;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return true;
}
throw error;
}
},
15_000,
500
);
clearBenignSlowConfigReadWarnings();
},
180_000
);
});
interface FakeOpenAiCompatibleServer {
baseUrl: string;
requests: string[];
chatBodies: unknown[];
close: () => Promise<void>;
}
async function startFakeOpenAiCompatibleServer(): Promise<FakeOpenAiCompatibleServer> {
const requests: string[] = [];
const chatBodies: unknown[] = [];
const server = http.createServer(async (request, response) => {
requests.push(`${request.method ?? 'GET'} ${request.url ?? '/'}`);
if (request.url === '/v1/models') {
sendJson(response, 200, {
object: 'list',
data: [{ id: 'qwen-test:0.5b', object: 'model' }],
});
return;
}
if (request.method === 'POST' && request.url === '/v1/chat/completions') {
const body = JSON.parse((await readRequestBody(request)) || '{}') as { stream?: boolean };
chatBodies.push(body);
if (body.stream) {
const created = Math.floor(Date.now() / 1000);
response.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
});
response.write(
`data: ${JSON.stringify({
id: 'chatcmpl-test',
object: 'chat.completion.chunk',
created,
model: 'qwen-test:0.5b',
choices: [
{
index: 0,
delta: { role: 'assistant', content: 'PONG' },
finish_reason: null,
},
],
})}\n\n`
);
response.write(
`data: ${JSON.stringify({
id: 'chatcmpl-test',
object: 'chat.completion.chunk',
created,
model: 'qwen-test:0.5b',
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
})}\n\n`
);
response.end('data: [DONE]\n\n');
return;
}
sendJson(response, 200, {
id: 'chatcmpl-test',
object: 'chat.completion',
model: 'qwen-test:0.5b',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'PONG' },
finish_reason: 'stop',
},
],
});
return;
}
sendJson(response, 404, { error: { message: 'not found' } });
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
const address = server.address();
if (!address || typeof address === 'string') {
await closeServer(server);
throw new Error('Fake OpenAI-compatible server did not bind to a TCP port');
}
return {
baseUrl: `http://127.0.0.1:${address.port}`,
requests,
chatBodies,
close: () => closeServer(server),
};
}
async function writeFakeLocalOpenCodeConfig(input: {
projectPath: string;
baseUrl: string;
}): Promise<void> {
const configPath = path.join(input.projectPath, 'opencode.json');
await fs.writeFile(
configPath,
`${JSON.stringify(
{
provider: {
'llama.cpp': {
npm: '@ai-sdk/openai-compatible',
options: {
baseURL: `${input.baseUrl}/v1`,
},
models: {
'qwen-test:0.5b': {},
},
},
},
model: LOCAL_MODEL,
small_model: LOCAL_MODEL,
},
null,
2
)}\n`,
'utf8'
);
}
async function readRequestBody(request: http.IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of request) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks).toString('utf8');
}
function sendJson(response: http.ServerResponse, status: number, body: unknown): void {
response.writeHead(status, { 'content-type': 'application/json' });
response.end(JSON.stringify(body));
}
function closeServer(server: http.Server): Promise<void> {
return new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
function clearBenignSlowConfigReadWarnings(): void {
const warn = vi.mocked(console.warn);
if (
warn.mock.calls.length > 0 &&
warn.mock.calls.every((call) =>
call.map((part) => String(part)).join(' ').includes('[getConfig] slow read diag=')
)
) {
warn.mockClear();
}
}

View file

@ -40,6 +40,8 @@ const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_
const DEFAULT_MODEL = 'opencode/big-pickle';
liveDescribe('OpenCode team provisioning live e2e', () => {
const liveDefaultModelIt =
process.env.OPENCODE_E2E_DEFAULT_MODEL_LAUNCH === '1' ? it : it.skip;
let tempDir: string;
let tempClaudeRoot: string;
@ -205,6 +207,187 @@ liveDescribe('OpenCode team provisioning live e2e', () => {
.catch(() => undefined);
}
}, 300_000);
liveDefaultModelIt(
'creates and stops a pure OpenCode team when all OpenCode model selections are Default',
async () => {
const orchestratorCli =
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
await assertExecutable(orchestratorCli);
const projectPath = path.join(tempDir, 'default-model-project');
await fs.mkdir(projectPath, { recursive: true });
await fs.writeFile(
path.join(projectPath, 'opencode.json'),
`${JSON.stringify({ model: DEFAULT_MODEL, small_model: DEFAULT_MODEL }, null, 2)}\n`
);
const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec();
const bridgeEnv = {
...createStableBridgeEnv(),
PATH: withBunOnPath(process.env.PATH ?? ''),
XDG_DATA_HOME: path.join(tempDir, 'xdg-data-default-model'),
AGENT_TEAMS_MCP_CLAUDE_DIR: tempClaudeRoot,
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command,
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '',
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args),
};
const bridgeClient = new OpenCodeBridgeCommandClient({
binaryPath: orchestratorCli,
tempDirectory: path.join(tempDir, 'bridge-input-default-model'),
env: bridgeEnv,
});
const stateChangingCommands = createStateChangingCommands({
bridge: bridgeClient,
controlDir: path.join(tempDir, 'control-default-model'),
});
const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, {
stateChangingCommands,
timeoutMs: 180_000,
launchTimeoutMs: 180_000,
reconcileTimeoutMs: 90_000,
stopTimeoutMs: 90_000,
});
const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge);
const svc = new TeamProvisioningService();
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
const teamName = `opencode-team-default-model-${Date.now()}`;
const progressEvents: TeamProvisioningProgress[] = [];
try {
const { runId } = await svc.createTeam(
{
teamName,
cwd: projectPath,
providerId: 'opencode',
skipPermissions: true,
members: [
{
name: 'atlas',
role: 'Developer',
providerId: 'opencode',
},
],
},
(progress) => {
progressEvents.push(progress);
}
);
expect(runId).toBeTruthy();
const progressDump = progressEvents
.map((progress) =>
[
progress.state,
progress.message,
progress.messageSeverity,
progress.error,
progress.cliLogsTail,
]
.filter(Boolean)
.join(' | ')
)
.join('\n');
expect(
progressEvents.some((progress) =>
progress.message.includes('OpenCode team launch is ready')
),
progressDump
).toBe(true);
expect(progressDump).not.toContain('OpenCode launch requires a selected raw model id');
expect(progressDump).not.toContain('Failed to parse runtime default model list');
expect(progressDump).not.toContain('Failed to load runtime default model list');
const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
expect(runtimeSnapshot.runId).toBe(runId);
expect(runtimeSnapshot.members.atlas).toMatchObject({
alive: true,
providerId: 'opencode',
laneId: 'primary',
laneKind: 'primary',
runtimeModel: DEFAULT_MODEL,
historicalBootstrapConfirmed: true,
});
expect(hasOpenCodeRuntimeHandle(runtimeSnapshot.members.atlas)).toBe(true);
await svc.stopTeam(teamName);
await waitUntil(async () => {
const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName);
return Object.keys(laneIndex.lanes).length === 0;
}, 90_000);
const relaunchProgressEvents: TeamProvisioningProgress[] = [];
const { runId: relaunchRunId } = await svc.launchTeam(
{
teamName,
cwd: projectPath,
providerId: 'opencode',
skipPermissions: true,
},
(progress) => {
relaunchProgressEvents.push(progress);
}
);
expect(relaunchRunId).toBeTruthy();
expect(relaunchRunId).not.toBe(runId);
const relaunchProgressDump = relaunchProgressEvents
.map((progress) =>
[
progress.state,
progress.message,
progress.messageSeverity,
progress.error,
progress.cliLogsTail,
]
.filter(Boolean)
.join(' | ')
)
.join('\n');
expect(
relaunchProgressEvents.some((progress) =>
progress.message.includes('OpenCode team launch is ready')
),
relaunchProgressDump
).toBe(true);
expect(relaunchProgressDump).not.toContain(
'OpenCode launch requires a selected raw model id'
);
expect(relaunchProgressDump).not.toContain('No OpenCode model is available');
expect(relaunchProgressDump).not.toContain('Failed to parse runtime default model list');
expect(relaunchProgressDump).not.toContain('Failed to load runtime default model list');
const relaunchedSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
expect(relaunchedSnapshot.runId).toBe(relaunchRunId);
expect(relaunchedSnapshot.members.atlas).toMatchObject({
alive: true,
providerId: 'opencode',
laneId: 'primary',
laneKind: 'primary',
runtimeModel: DEFAULT_MODEL,
historicalBootstrapConfirmed: true,
});
expect(hasOpenCodeRuntimeHandle(relaunchedSnapshot.members.atlas)).toBe(true);
await svc.stopTeam(teamName);
await waitUntil(async () => {
const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName);
return Object.keys(laneIndex.lanes).length === 0;
}, 90_000);
} finally {
await svc.stopTeam(teamName).catch(() => undefined);
await readinessBridge
.cleanupOpenCodeHosts({
reason: 'opencode-team-default-model-live-e2e-cleanup',
mode: 'force',
projectPath,
staleAgeMs: null,
leaseStaleAgeMs: null,
})
.catch(() => undefined);
}
},
300_000
);
});
function createStateChangingCommands(input: {

View file

@ -1,15 +1,15 @@
import { describe, expect, it, vi } from 'vitest';
import { REQUIRED_AGENT_TEAMS_APP_TOOL_IDS } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability';
import {
OpenCodeTeamRuntimeAdapter,
type OpenCodeTeamRuntimeBridgePort,
type TeamRuntimeLaunchInput,
} from '../../../../src/main/services/team/runtime';
import type { OpenCodeTeamLaunchReadiness } from '../../../../src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness';
import type { OpenCodeLaunchTeamCommandData } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract';
import type { OpenCodeTeamLaunchReadiness } from '../../../../src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness';
import type { PersistedTeamLaunchSnapshot } from '../../../../src/shared/types';
import { REQUIRED_AGENT_TEAMS_APP_TOOL_IDS } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability';
describe('OpenCodeTeamRuntimeAdapter', () => {
it('maps readiness failures to a structured prepare block', async () => {
@ -376,6 +376,44 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
);
});
it('launches model-less Default selections with the readiness-resolved model', async () => {
const launchOpenCodeTeam = vi.fn<
NonNullable<OpenCodeTeamRuntimeBridgePort['launchOpenCodeTeam']>
>(async () => successfulOpenCodeLaunchData({ model: 'opencode/big-pickle' }));
const adapter = new OpenCodeTeamRuntimeAdapter(
bridgePort(
readiness({
state: 'ready',
launchAllowed: true,
modelId: 'opencode/big-pickle',
availableModels: ['opencode/big-pickle'],
}),
{ launchOpenCodeTeam }
)
);
const result = await adapter.launch(
launchInput({
model: undefined,
expectedMembers: [
{
name: 'alice',
providerId: 'opencode',
cwd: '/repo',
},
],
})
);
expect(result.teamLaunchState).toBe('clean_success');
expect(launchOpenCodeTeam).toHaveBeenCalledWith(
expect.objectContaining({
selectedModel: 'opencode/big-pickle',
})
);
expect(result.members.alice?.model).toBe('opencode/big-pickle');
});
it('uses concrete member diagnostics as failed OpenCode hard failure reasons', async () => {
const concreteReason =
'Latest assistant message msg_123 failed with APIError - Insufficient credits. Add more using https://openrouter.ai/settings/credits';
@ -607,7 +645,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
expect(result.teamLaunchState).toBe('clean_success');
expect(result.warnings).toContain(
'OpenCode capability snapshot changed between readiness and launch; refreshed readiness and retried once.'
'OpenCode capability snapshot changed between readiness and launch; refreshed readiness and retried launch.'
);
expect(checkReadiness).toHaveBeenCalledTimes(2);
expect(launchOpenCodeTeam).toHaveBeenCalledTimes(2);
@ -637,6 +675,50 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
);
});
it('keeps refreshing bounded capability snapshot churn until launch observes the current snapshot', async () => {
let readinessCalls = 0;
const capabilitySnapshots = ['cap-1', 'cap-2', 'cap-3', 'cap-4'];
const checkReadiness = vi.fn<
OpenCodeTeamRuntimeBridgePort['checkOpenCodeTeamLaunchReadiness']
>(() => {
readinessCalls += 1;
return Promise.resolve(readiness({ state: 'ready', launchAllowed: true }));
});
const launchOpenCodeTeam = vi.fn<
NonNullable<OpenCodeTeamRuntimeBridgePort['launchOpenCodeTeam']>
>((input) =>
Promise.resolve(
input.expectedCapabilitySnapshotId === 'cap-3'
? successfulOpenCodeLaunchData()
: failedCapabilitySnapshotLaunchData('Bridge server capability snapshot mismatch')
)
);
const adapter = new OpenCodeTeamRuntimeAdapter({
checkOpenCodeTeamLaunchReadiness: checkReadiness,
getLastOpenCodeRuntimeSnapshot: vi.fn(
() => runtimeSnapshot(capabilitySnapshots[Math.max(0, Math.min(readinessCalls - 1, 3))] ?? 'cap-4')
),
launchOpenCodeTeam,
});
const result = await adapter.launch(launchInput());
expect(result.teamLaunchState).toBe('clean_success');
expect(result.warnings).toContain(
'OpenCode capability snapshot changed between readiness and launch; refreshed readiness and retried launch.'
);
expect(checkReadiness).toHaveBeenCalledTimes(3);
expect(launchOpenCodeTeam).toHaveBeenCalledTimes(3);
expect(launchOpenCodeTeam.mock.calls.map((call) => call[0].expectedCapabilitySnapshotId)).toEqual(
['cap-1', 'cap-2', 'cap-3']
);
expect(
launchOpenCodeTeam.mock.calls.slice(1).every((call) =>
/^opencode-capability-recovery-/.test(call[0].capabilitySnapshotRecoveryAttemptId ?? '')
)
).toBe(true);
});
it('uses a fresh recovery attempt id when capability refresh returns the same snapshot', async () => {
let readinessCalls = 0;
const checkReadiness = vi.fn<
@ -775,7 +857,8 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
const result = await adapter.launch(launchInput());
expect(result.teamLaunchState).toBe('partial_failure');
expect(launchOpenCodeTeam).toHaveBeenCalledTimes(2);
expect(checkReadiness).toHaveBeenCalledTimes(4);
expect(launchOpenCodeTeam).toHaveBeenCalledTimes(4);
expect(result.diagnostics).toContain(
'error:opencode_bridge: OpenCode bridge failed: OpenCode bridge capability snapshot precondition mismatch'
);
@ -1094,6 +1177,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
expect(sentText).toContain('agent-teams_member_work_sync_status');
expect(sentText).toContain('agent-teams_member_work_sync_report');
expect(sentText).toContain('mcp__agent-teams__member_work_sync_report');
expect(sentText).toContain('A status-only tool call is incomplete');
expect(sentText).toContain('teamName="team-a"');
expect(sentText).toContain('memberName="bob"');
expect(sentText).toContain('controlUrl="http://127.0.0.1:43123"');
@ -1143,6 +1227,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
expect(sentText).toContain('review workflow tools');
expect(sentText).toContain('Do not mark the review complete from this prompt alone.');
expect(sentText).toContain('agent-teams_member_work_sync_report');
expect(sentText).toContain('A status-only tool call is incomplete');
expect(sentText).not.toContain('This delivered app message is a member-work-sync nudge.');
});
@ -1544,7 +1629,9 @@ function runtimeSnapshot(capabilitySnapshotId: string) {
};
}
function successfulOpenCodeLaunchData(): OpenCodeLaunchTeamCommandData {
function successfulOpenCodeLaunchData(
overrides: { model?: string } = {}
): OpenCodeLaunchTeamCommandData {
return {
runId: 'run-1',
teamLaunchState: 'ready',
@ -1553,7 +1640,7 @@ function successfulOpenCodeLaunchData(): OpenCodeLaunchTeamCommandData {
sessionId: 'oc-session-1',
launchState: 'confirmed_alive',
runtimePid: 123,
model: 'openai/gpt-5.4-mini',
model: overrides.model ?? 'openai/gpt-5.4-mini',
evidence: [
{ kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' },
{ kind: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' },