feat(opencode): surface runtime diagnostics and approvals

This commit is contained in:
777genius 2026-05-22 15:43:15 +03:00
parent 5551eea482
commit 7b99a3713b
41 changed files with 3895 additions and 232 deletions

View file

@ -147,6 +147,7 @@ import { clearAutoResumeService } from './services/team/AutoResumeService';
import { agentTeamsMcpHttpServer } from './services/team/AgentTeamsMcpHttpServer';
import { LaunchIoGovernor } from './services/team/LaunchIoGovernor';
import { OpenCodeBridgeCommandClient } from './services/team/opencode/bridge/OpenCodeBridgeCommandClient';
import { OpenCodeBridgeDiagnosticsStore } from './services/team/opencode/bridge/OpenCodeBridgeDiagnosticsStore';
import {
createOpenCodeBridgeCommandLeaseStore,
createOpenCodeBridgeCommandLedgerStore,
@ -407,6 +408,9 @@ async function createOpenCodeRuntimeAdapterRegistry(
});
const mcpEntry = mcpLaunchSpec.args[0];
if (mcpEntry) {
for (const [key, value] of Object.entries(mcpLaunchSpec.env ?? {})) {
targetEnv[key] = value;
}
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);
@ -515,13 +519,16 @@ async function createOpenCodeRuntimeAdapterRegistry(
}
return nextEnv;
};
const bridgeControlDir = join(app.getPath('userData'), 'opencode-bridge');
const bridgeClient = new OpenCodeBridgeCommandClient({
binaryPath,
tempDirectory: join(app.getPath('temp'), 'claude-team-opencode-bridge'),
env: bridgeEnv,
envProvider: resolveBridgeCommandEnv,
diagnostics: new OpenCodeBridgeDiagnosticsStore({
directory: join(bridgeControlDir, 'diagnostics'),
}),
});
const bridgeControlDir = join(app.getPath('userData'), 'opencode-bridge');
const clientIdentity = createOpenCodeBridgeClientIdentity({
appVersion: typeof app.getVersion === 'function' ? app.getVersion() : '1.3.0',
gitSha: process.env.VITE_GIT_SHA ?? process.env.GIT_SHA ?? null,
@ -546,6 +553,7 @@ async function createOpenCodeRuntimeAdapterRegistry(
});
const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, {
stateChangingCommands,
appVersion: clientIdentity.appVersion,
});
openCodeLifecycleBridge = readinessBridge;
return new TeamRuntimeAdapterRegistry([new OpenCodeTeamRuntimeAdapter(readinessBridge)]);

View file

@ -396,11 +396,21 @@ function createRuntimeStatusErrorProviderStatus(
error: unknown
): CliProviderStatus {
const message = error instanceof Error ? error.message : String(error);
const lower = message.toLowerCase();
const detailMessage =
providerId === 'opencode' && (lower.includes('timed out') || lower.includes('timeout'))
? [
'OpenCode runtime status did not return before the desktop timeout.',
'This means the Agent Teams runtime process did not produce provider-status JSON in time, not necessarily that OpenCode auth is missing.',
'Likely causes include slow or hung OpenCode CLI startup, provider/model inventory, local OpenCode plugins, cache/profile corruption, stale bundled runtime, or Windows security software delaying child processes.',
`Raw timeout detail: ${message}`,
].join(' ')
: message;
return {
...createDefaultProviderStatus(providerId),
verificationState: 'error',
statusMessage: 'Provider status unavailable',
detailMessage: message,
detailMessage,
};
}

View file

@ -110,6 +110,10 @@ import {
} from '@shared/utils/inboxNoise';
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
import {
isOpenCodeWindowsAccessDeniedDiagnostic,
normalizeOpenCodeWindowsAccessDeniedDiagnostic,
} from '@shared/utils/openCodeWindowsAccessDenied';
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
@ -228,11 +232,19 @@ import {
normalizeMemberDiagnosticText,
shouldUseGeminiStagedLaunch,
} from './provisioning/TeamProvisioningPromptBuilders';
import type { RuntimeTurnSettledProvider } from '@features/member-work-sync/main';
export type { RuntimeBootstrapMemberMcpLaunchConfig } from './provisioning/TeamProvisioningBootstrapSpec';
export {
buildAddMemberSpawnMessage,
buildRestartMemberSpawnMessage,
} from './provisioning/TeamProvisioningPromptBuilders';
import { openCodeRuntimeApprovalProvider } from './approvals/OpenCodeRuntimeApprovalProvider';
import {
RuntimeToolApprovalCoordinator,
type RuntimeToolApprovalEntry,
} from './approvals/RuntimeToolApprovalCoordinator';
import { isOpenCodeBridgeNoOutputDiagnostic } from './opencode/bridge/OpenCodeBridgeSupportDiagnostics';
import {
buildOpenCodePromptDeliveryAttemptId,
createOpenCodePromptDeliveryLedgerStore,
@ -406,10 +418,10 @@ import type {
TeamRuntimeLaunchInput,
TeamRuntimeLaunchResult,
TeamRuntimeMemberLaunchEvidence,
TeamRuntimeMemberSpec,
TeamRuntimePrepareResult,
TeamRuntimeStopInput,
} from './runtime';
import type { RuntimeTurnSettledProvider } from '@features/member-work-sync/main';
type OpenCodeRuntimeMessageAdapter = TeamLaunchRuntimeAdapter & {
sendMessageToMember(
@ -608,6 +620,7 @@ import type {
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
TeamProvisioningState,
TeamProvisioningSupportDiagnostic,
TeamRuntimeState,
TeamTask,
ToolActivityEventPayload,
@ -962,9 +975,22 @@ function pushUniqueLine(lines: string[], line: string): void {
}
}
function pushUniqueSupportDiagnostics(
diagnostics: TeamProvisioningSupportDiagnostic[],
incoming: readonly TeamProvisioningSupportDiagnostic[] | undefined
): void {
for (const diagnostic of incoming ?? []) {
if (!diagnostics.some((existing) => existing.id === diagnostic.id)) {
diagnostics.push({ ...diagnostic });
}
}
}
function looksLikeOpenCodeProviderPrepareDiagnostic(value: string): boolean {
const lower = value.trim().toLowerCase();
return (
isOpenCodeBridgeNoOutputDiagnostic(value) ||
isOpenCodeWindowsAccessDeniedDiagnostic(value) ||
lower.includes('opencode /experimental/tool') ||
lower.includes('/experimental/tool') ||
lower.includes('mcp_unavailable') ||
@ -981,6 +1007,15 @@ function normalizeOpenCodePrepareDiagnostic(value: string, reason?: string): str
return trimmed;
}
if (isOpenCodeBridgeNoOutputDiagnostic(trimmed)) {
return 'OpenCode runtime check returned no output.';
}
const accessDeniedDiagnostic = normalizeOpenCodeWindowsAccessDeniedDiagnostic(trimmed);
if (accessDeniedDiagnostic) {
return accessDeniedDiagnostic;
}
if (/opencode cli (?:not detected on path|not found)/i.test(trimmed)) {
return OPENCODE_RUNTIME_BINARY_UNREACHABLE_DIAGNOSTIC;
}
@ -1028,6 +1063,11 @@ function isOpenCodeModelVerificationTimeoutDiagnostic(value: string | null | und
function selectOpenCodeModelPreparePrimaryReason(
prepare: Extract<TeamRuntimePrepareResult, { ok: false }>
): string {
const providerDiagnostic = selectOpenCodePrepareProviderDiagnostic(prepare);
if (providerDiagnostic) {
return providerDiagnostic;
}
const candidates = [...prepare.diagnostics, prepare.reason]
.map((entry) => entry?.trim() ?? '')
.filter(Boolean);
@ -1035,6 +1075,15 @@ function selectOpenCodeModelPreparePrimaryReason(
return timeoutReason ?? candidates[0] ?? prepare.reason;
}
function selectOpenCodePrepareProviderDiagnostic(
prepare: Pick<TeamRuntimePrepareResult, 'diagnostics' | 'warnings'>
): string | undefined {
return [...prepare.diagnostics, ...prepare.warnings].find(
(entry) =>
isOpenCodeBridgeNoOutputDiagnostic(entry) || isOpenCodeWindowsAccessDeniedDiagnostic(entry)
);
}
function isOpenCodeModelPrepareBusyDeferred(
prepare: Extract<TeamRuntimePrepareResult, { ok: false }>,
primaryReason: string
@ -4997,6 +5046,16 @@ export class TeamProvisioningService {
| undefined = new Map<string, ReturnType<typeof setTimeout>>();
private readonly runtimeAdapterTraceLinesByRunId = new Map<string, string[]>();
private readonly runtimeAdapterTraceKeyByRunId = new Map<string, string>();
private readonly runtimeToolApprovalCoordinator = new RuntimeToolApprovalCoordinator({
getSettings: (teamName) => this.getToolApprovalSettings(teamName),
answerApproval: ({ entry, allow, message }) =>
this.answerRuntimeToolApproval(entry, allow, message),
emitApprovalEvent: (event) => this.emitToolApprovalEvent(event),
showApprovalNotification: (approval) =>
this.maybeShowToolApprovalOsNotification(undefined, approval),
dismissApprovalNotification: (requestId) => this.dismissApprovalNotification(requestId),
logWarning: (message) => logger.warn(message),
});
private readonly runtimeAdapterRunByTeam = new Map<
string,
{
@ -10423,6 +10482,7 @@ export class TeamProvisioningService {
}
private deleteSecondaryRuntimeRun(teamName: string, laneId: string): void {
this.clearOpenCodeRuntimeToolApprovals(teamName, { laneId, emitDismiss: true });
const runs = this.secondaryRuntimeRunByTeam.get(teamName);
if (!runs) {
return;
@ -10434,6 +10494,7 @@ export class TeamProvisioningService {
}
private clearSecondaryRuntimeRuns(teamName: string): void {
this.clearOpenCodeRuntimeToolApprovals(teamName, { emitDismiss: true });
this.secondaryRuntimeRunByTeam.delete(teamName);
}
@ -11253,6 +11314,7 @@ export class TeamProvisioningService {
private resetTeamScopedTransientStateForNewRun(teamName: string): void {
peekAutoResumeService()?.cancelPendingAutoResume(teamName);
this.clearOpenCodeRuntimeToolApprovals(teamName, { emitDismiss: true });
this.invalidateRuntimeSnapshotCaches(teamName);
this.retainedClaudeLogsByTeam.delete(teamName);
this.persistedTranscriptClaudeLogsCache.delete(teamName);
@ -18260,6 +18322,7 @@ export class TeamProvisioningService {
details: result.details ? [...result.details] : undefined,
warnings: result.warnings ? [...result.warnings] : undefined,
issues: result.issues?.map((issue) => ({ ...issue })),
supportDiagnostics: result.supportDiagnostics?.map((diagnostic) => ({ ...diagnostic })),
};
}
@ -18304,6 +18367,7 @@ export class TeamProvisioningService {
const details: string[] = [];
const blockingMessages: string[] = [];
const issues: TeamProvisioningPrepareIssue[] = [];
const supportDiagnostics: TeamProvisioningSupportDiagnostic[] = [];
const selectedModelIds = Array.from(
new Set((opts?.modelIds ?? []).map((modelId) => modelId.trim()).filter(Boolean))
);
@ -18353,9 +18417,13 @@ export class TeamProvisioningService {
normalizeOpenCodePrepareDiagnostic(warning, prepareReason)
)
);
pushUniqueSupportDiagnostics(supportDiagnostics, prepare.supportDiagnostics);
if (!prepare.ok) {
const providerDiagnostic = selectOpenCodePrepareProviderDiagnostic(prepare);
blockingMessages.push(
normalizeOpenCodePrepareDiagnostic(`OpenCode: ${prepare.reason}`, prepare.reason)
providerDiagnostic
? normalizeOpenCodePrepareDiagnostic(providerDiagnostic, prepare.reason)
: normalizeOpenCodePrepareDiagnostic(`OpenCode: ${prepare.reason}`, prepare.reason)
);
}
continue;
@ -18371,6 +18439,7 @@ export class TeamProvisioningService {
warnings.push(...openCodeModelPrepare.warnings);
blockingMessages.push(...openCodeModelPrepare.blockingMessages);
issues.push(...openCodeModelPrepare.issues);
pushUniqueSupportDiagnostics(supportDiagnostics, openCodeModelPrepare.supportDiagnostics);
continue;
}
@ -18539,6 +18608,10 @@ export class TeamProvisioningService {
: 'Some provider runtimes are not ready',
warnings: failureWarnings.length > 0 ? failureWarnings : undefined,
issues: issues.length > 0 ? issues : undefined,
supportDiagnostics:
supportDiagnostics.length > 0
? supportDiagnostics.map((diagnostic) => ({ ...diagnostic }))
: undefined,
};
}
@ -18555,6 +18628,10 @@ export class TeamProvisioningService {
: 'CLI is warmed up and ready to launch',
warnings: warnings.length > 0 ? warnings : undefined,
issues: issues.length > 0 ? issues : undefined,
supportDiagnostics:
supportDiagnostics.length > 0
? supportDiagnostics.map((diagnostic) => ({ ...diagnostic }))
: undefined,
};
}
@ -18573,15 +18650,17 @@ export class TeamProvisioningService {
warnings: string[];
blockingMessages: string[];
issues: TeamProvisioningPrepareIssue[];
supportDiagnostics: TeamProvisioningSupportDiagnostic[];
}> {
const details: string[] = [];
const warnings: string[] = [];
const blockingMessages: string[] = [];
const issues: TeamProvisioningPrepareIssue[] = [];
const supportDiagnostics: TeamProvisioningSupportDiagnostic[] = [];
const startedAt = Date.now();
if (modelIds.length === 0) {
return { details, warnings, blockingMessages, issues };
return { details, warnings, blockingMessages, issues, supportDiagnostics };
}
if (verificationMode === 'compatibility') {
@ -18629,6 +18708,11 @@ export class TeamProvisioningService {
reason: prepare.ok ? null : prepare.reason,
diagnostics: prepare.diagnostics,
warnings: prepare.warnings,
supportDiagnostics: prepare.supportDiagnostics?.map((diagnostic) => ({
id: diagnostic.id,
kind: diagnostic.kind,
title: diagnostic.title,
})),
});
return prepare;
} catch (error) {
@ -18700,6 +18784,7 @@ export class TeamProvisioningService {
}
const { modelId, prepare } = result;
pushUniqueSupportDiagnostics(supportDiagnostics, prepare.supportDiagnostics);
const prepareReason = prepare.ok ? undefined : prepare.reason;
warnings.push(
...prepare.warnings.map((warning) =>
@ -18800,7 +18885,7 @@ export class TeamProvisioningService {
blockingMessages,
});
return { details, warnings, blockingMessages, issues };
return { details, warnings, blockingMessages, issues, supportDiagnostics };
}
private isProviderScopedOpenCodePrepareFailure(
@ -18829,11 +18914,13 @@ export class TeamProvisioningService {
warnings: string[];
blockingMessages: string[];
issues: TeamProvisioningPrepareIssue[];
supportDiagnostics: TeamProvisioningSupportDiagnostic[];
} | null> {
const details: string[] = [];
const warnings: string[] = [];
const blockingMessages: string[] = [];
const issues: TeamProvisioningPrepareIssue[] = [];
const supportDiagnostics: TeamProvisioningSupportDiagnostic[] = [];
const startedAt = Date.now();
appendPreflightDebugLog('opencode_compatibility_batch_start', {
@ -18879,11 +18966,20 @@ export class TeamProvisioningService {
ok: sharedPrepare.ok,
reason: sharedPrepare.ok ? null : sharedPrepare.reason,
diagnostics: sharedPrepare.diagnostics,
supportDiagnostics: sharedPrepare.supportDiagnostics?.map((diagnostic) => ({
id: diagnostic.id,
kind: diagnostic.kind,
title: diagnostic.title,
})),
});
if (!sharedPrepare.ok) {
pushUniqueSupportDiagnostics(supportDiagnostics, sharedPrepare.supportDiagnostics);
const providerDiagnostic = selectOpenCodePrepareProviderDiagnostic(sharedPrepare);
const primaryReason = normalizeOpenCodePrepareDiagnostic(
sharedPrepare.diagnostics.find((entry) => entry.trim().length > 0) ?? sharedPrepare.reason,
providerDiagnostic ??
sharedPrepare.diagnostics.find((entry) => entry.trim().length > 0) ??
sharedPrepare.reason,
sharedPrepare.reason
);
if (primaryReason.trim().length > 0) {
@ -18899,7 +18995,7 @@ export class TeamProvisioningService {
code: sharedPrepare.reason,
message: primaryReason.trim() || `OpenCode: ${sharedPrepare.reason}`,
});
return { details, warnings, blockingMessages, issues };
return { details, warnings, blockingMessages, issues, supportDiagnostics };
}
const latestReadiness =
@ -18955,7 +19051,7 @@ export class TeamProvisioningService {
details,
});
return { details, warnings, blockingMessages, issues };
return { details, warnings, blockingMessages, issues, supportDiagnostics };
}
private resolveOpenCodeCompatibilityModel(
@ -21517,6 +21613,19 @@ export class TeamProvisioningService {
launchResult,
launchInput
);
const requestTeamColor = 'color' in input.request ? input.request.color : undefined;
const requestTeamDisplayName =
'displayName' in input.request ? input.request.displayName : undefined;
this.syncOpenCodeRuntimeToolApprovals({
teamName: input.request.teamName,
runId,
laneId: 'primary',
cwd: launchCwd,
members: result.members,
expectedMembers: launchInput.expectedMembers,
teamColor: requestTeamColor,
teamDisplayName: requestTeamDisplayName,
});
const success = result.teamLaunchState === 'clean_success';
const pending = result.teamLaunchState === 'partial_pending';
const failed = result.teamLaunchState === 'partial_failure';
@ -22716,6 +22825,11 @@ export class TeamProvisioningService {
const teamName = runtimeProgress.teamName;
const runtimeRun = this.runtimeAdapterRunByTeam.get(teamName);
this.cancelledRuntimeAdapterRunIds.add(runId);
this.clearOpenCodeRuntimeToolApprovals(teamName, {
runId,
laneId: 'primary',
emitDismiss: true,
});
this.runtimeAdapterRunByTeam.delete(teamName);
this.aliveRunByTeam.delete(teamName);
if (this.provisioningRunByTeam.get(teamName) === runId) {
@ -28122,6 +28236,47 @@ export class TeamProvisioningService {
this.emitMemberSpawnChange(run, lane.member.name);
}
private async applyOpenCodeSecondaryPermissionAnswerResult(
entry: RuntimeToolApprovalEntry,
result: TeamRuntimeLaunchResult
): Promise<void> {
const trackedRunId = this.getTrackedRunId(entry.approval.teamName);
const run = trackedRunId ? this.runs.get(trackedRunId) : null;
if (!run) {
throw new Error(`Run not found for team "${entry.approval.teamName}"`);
}
const lane = (run.mixedSecondaryLanes ?? []).find(
(candidate) => candidate.laneId === entry.laneId
);
if (!lane) {
throw new Error(
`OpenCode secondary lane ${entry.laneId} was not found for team "${entry.approval.teamName}"`
);
}
const guarded = await this.guardCommittedOpenCodeSecondaryLaneEvidence({
teamName: entry.approval.teamName,
laneId: entry.laneId,
memberName: entry.memberName,
result,
});
lane.result = guarded;
lane.warnings = [...guarded.warnings];
lane.diagnostics = [...guarded.diagnostics];
lane.state = 'finished';
await this.publishMixedSecondaryLaneStatusChange(run, lane);
this.syncOpenCodeRuntimeToolApprovals({
teamName: entry.approval.teamName,
runId: entry.approval.runId,
laneId: entry.laneId,
cwd: entry.cwd ?? '',
members: guarded.members,
expectedMembers: entry.expectedMembers ?? [],
teamDisplayName: entry.approval.teamDisplayName,
teamColor: entry.approval.teamColor,
});
}
private async guardCommittedOpenCodeSecondaryLaneEvidence(params: {
teamName: string;
laneId: string;
@ -28637,6 +28792,18 @@ export class TeamProvisioningService {
await finishCancelledLane();
return;
}
const laneExpectedMembers: TeamRuntimeMemberSpec[] = [
{
name: lane.member.name,
role: lane.member.role,
workflow: lane.member.workflow,
isolation: lane.member.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: 'opencode',
model: lane.member.model,
effort: lane.member.effort,
cwd: laneCwd,
},
];
const launchOpenCodeLane = () =>
adapter.launch({
runId: laneRunId,
@ -28649,18 +28816,7 @@ export class TeamProvisioningService {
effort: lane.member.effort,
runtimeOnly: true,
skipPermissions: run.request.skipPermissions !== false,
expectedMembers: [
{
name: lane.member.name,
role: lane.member.role,
workflow: lane.member.workflow,
isolation: lane.member.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: 'opencode',
model: lane.member.model,
effort: lane.member.effort,
cwd: laneCwd,
},
],
expectedMembers: laneExpectedMembers,
previousLaunchState,
});
let rawResult: TeamRuntimeLaunchResult;
@ -28753,6 +28909,16 @@ export class TeamProvisioningService {
)
: resultWithTiming;
lane.result = normalizedResult;
this.syncOpenCodeRuntimeToolApprovals({
teamName: run.teamName,
runId: laneRunId,
laneId: lane.laneId,
cwd: laneCwd,
members: normalizedResult.members,
expectedMembers: laneExpectedMembers,
teamColor: run.request.color,
teamDisplayName: run.request.displayName,
});
lane.warnings = [...normalizedResult.warnings];
const launchDiagnostics = appendDiagnosticOnce(
[...requestedDiagnostics, ...migration.diagnostics, ...normalizedResult.diagnostics],
@ -31301,6 +31467,11 @@ export class TeamProvisioningService {
startedAt: previousProgress?.startedAt ?? startedAt,
updatedAt: startedAt,
});
this.clearOpenCodeRuntimeToolApprovals(teamName, {
runId,
laneId: 'primary',
emitDismiss: true,
});
this.runtimeAdapterRunByTeam.delete(teamName);
this.aliveRunByTeam.delete(teamName);
if (this.provisioningRunByTeam.get(teamName) === runId) {
@ -32668,11 +32839,13 @@ export class TeamProvisioningService {
const toolName = typeof request?.tool_name === 'string' ? request.tool_name : 'Unknown';
const toolInput = (request?.input ?? {}) as Record<string, unknown>;
const providerId = toolInput.provider === 'codex' ? 'codex' : undefined;
const approval: ToolApprovalRequest = {
requestId,
runId: run.runId,
teamName: run.teamName,
...(providerId ? { providerId } : {}),
source: 'lead',
toolName,
toolInput,
@ -32776,13 +32949,35 @@ export class TeamProvisioningService {
this.maybeShowToolApprovalOsNotification(run, approval);
}
private syncOpenCodeRuntimeToolApprovals(input: {
teamName: string;
runId: string;
laneId: string;
cwd: string;
members: Record<string, TeamRuntimeMemberLaunchEvidence>;
expectedMembers: TeamRuntimeMemberSpec[];
teamColor?: string;
teamDisplayName?: string;
}): void {
const entries = openCodeRuntimeApprovalProvider.collectPendingApprovals(input);
this.runtimeToolApprovalCoordinator.sync(
{
teamName: input.teamName,
runId: input.runId,
laneId: input.laneId,
providerId: 'opencode',
},
entries
);
}
/**
* Shows a native OS notification for a pending tool approval when the app
* is not in focus. On macOS, adds Allow/Deny action buttons that respond
* directly from the notification without switching to the app.
*/
private maybeShowToolApprovalOsNotification(
run: ProvisioningRun,
run: ProvisioningRun | undefined,
approval: ToolApprovalRequest
): void {
const win = this.mainWindowRef;
@ -32795,13 +32990,16 @@ export class TeamProvisioningService {
const snoozedUntil = config.notifications.snoozedUntil;
if (snoozedUntil && Date.now() < snoozedUntil) return;
const { Notification: ElectronNotification } = require('electron') as typeof import('electron');
if (!ElectronNotification.isSupported()) return;
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { Notification: ElectronNotification } = require('electron') as Partial<
typeof import('electron')
>;
if (!ElectronNotification?.isSupported?.()) return;
const isMac = process.platform === 'darwin';
const isLinux = process.platform === 'linux';
const iconPath = isMac ? undefined : getAppIconPath();
const teamLabel = run.request.displayName ?? run.teamName;
const teamLabel = approval.teamDisplayName ?? run?.request.displayName ?? approval.teamName;
const body = this.formatToolApprovalBody(approval.toolName, approval.toolInput);
// Actions (Allow/Deny buttons) supported on macOS and Windows.
@ -32848,17 +33046,17 @@ export class TeamProvisioningService {
cleanup();
const allow = index === 0;
logger.info(
`[${run.teamName}] Tool approval ${allow ? 'allowed' : 'denied'} via OS notification`
`[${approval.teamName}] Tool approval ${allow ? 'allowed' : 'denied'} via OS notification`
);
void this.respondToToolApproval(
run.teamName,
run.runId,
approval.teamName,
approval.runId,
approval.requestId,
allow,
allow ? undefined : 'Denied via notification'
).catch((err) => {
logger.error(
`[${run.teamName}] Failed to respond via notification: ${err instanceof Error ? err.message : String(err)}`
`[${approval.teamName}] Failed to respond via notification: ${err instanceof Error ? err.message : String(err)}`
);
});
});
@ -32876,6 +33074,16 @@ export class TeamProvisioningService {
}
}
private clearOpenCodeRuntimeToolApprovals(
teamName: string,
options: { runId?: string; laneId?: string; emitDismiss?: boolean } = {}
): void {
this.runtimeToolApprovalCoordinator.clear(teamName, {
...options,
providerId: 'opencode',
});
}
private formatToolApprovalBody(toolName: string, toolInput: Record<string, unknown>): string {
switch (toolName) {
case 'AskUserQuestion':
@ -33095,6 +33303,83 @@ export class TeamProvisioningService {
this.inFlightResponses.delete(requestId);
}
}
this.runtimeToolApprovalCoordinator.reEvaluate();
}
private async answerRuntimeToolApproval(
entry: RuntimeToolApprovalEntry,
allow: boolean,
_message?: string
): Promise<void> {
if (entry.providerId !== 'opencode') {
throw new Error(`Runtime approval provider is not supported: ${entry.providerId}`);
}
const adapter = this.getOpenCodeRuntimeAdapter();
if (!adapter?.answerRuntimePermission) {
throw new Error('OpenCode runtime permission answer bridge is not available');
}
const previousLaunchState = await this.launchStateStore.read(entry.approval.teamName);
const result = await adapter.answerRuntimePermission({
runId: entry.approval.runId,
laneId: entry.laneId,
teamName: entry.approval.teamName,
cwd: entry.cwd ?? '',
providerId: 'opencode',
memberName: entry.memberName,
requestId: entry.providerRequestId,
decision: allow ? 'allow' : 'reject',
expectedMembers: entry.expectedMembers ?? [],
previousLaunchState,
});
if (entry.laneId === 'primary') {
const launchInput: TeamRuntimeLaunchInput = {
runId: entry.approval.runId,
laneId: entry.laneId,
teamName: entry.approval.teamName,
cwd: entry.cwd ?? '',
providerId: 'opencode',
skipPermissions: false,
expectedMembers: entry.expectedMembers ?? [],
previousLaunchState,
};
const { result: committed } = await this.persistOpenCodeRuntimeAdapterLaunchResult(
result,
launchInput
);
if (committed.teamLaunchState === 'partial_failure') {
this.runtimeAdapterRunByTeam.delete(entry.approval.teamName);
} else {
this.runtimeAdapterRunByTeam.set(entry.approval.teamName, {
runId: entry.approval.runId,
providerId: 'opencode',
cwd: entry.cwd,
members: committed.members,
});
this.aliveRunByTeam.set(entry.approval.teamName, entry.approval.runId);
}
this.syncOpenCodeRuntimeToolApprovals({
teamName: entry.approval.teamName,
runId: entry.approval.runId,
laneId: entry.laneId,
cwd: entry.cwd ?? '',
members: committed.members,
expectedMembers: entry.expectedMembers ?? [],
teamDisplayName: entry.approval.teamDisplayName,
teamColor: entry.approval.teamColor,
});
} else {
await this.applyOpenCodeSecondaryPermissionAnswerResult(entry, result);
}
this.teamChangeEmitter?.({
type: 'process',
teamName: entry.approval.teamName,
runId: entry.approval.runId,
detail: allow ? 'permission-allowed' : 'permission-denied',
});
}
/**
@ -33108,6 +33393,17 @@ export class TeamProvisioningService {
allow: boolean,
message?: string
): Promise<void> {
const handledByRuntime = await this.runtimeToolApprovalCoordinator.respond(
teamName,
runId,
requestId,
allow,
message
);
if (handledByRuntime) {
return;
}
// Look in both provisioning and alive runs — control_requests arrive during provisioning too
const currentRunId = this.getTrackedRunId(teamName);
if (!currentRunId) throw new Error(`No active process for team "${teamName}"`);
@ -33122,8 +33418,8 @@ export class TeamProvisioningService {
// to handle the race where timeout already responded and deleted the approval
this.clearApprovalTimeout(requestId);
if (!this.tryClaimResponse(requestId)) {
// Timeout already responded — silently exit, UI cleanup via autoResolved event
run.pendingApprovals.delete(requestId);
// Another response is already being written; leave the pending approval tracked
// until that write succeeds or fails.
return;
}
@ -33148,15 +33444,22 @@ export class TeamProvisioningService {
approval.toolName,
approval.toolInput
);
} finally {
run.pendingApprovals.delete(requestId);
this.inFlightResponses.delete(requestId);
run.pendingApprovals.delete(requestId);
this.dismissApprovalNotification(requestId);
} catch (error) {
this.inFlightResponses.delete(requestId);
if (run.pendingApprovals.has(requestId)) {
this.startApprovalTimeout(run, requestId);
}
throw error;
}
return;
}
if (!run.child?.stdin?.writable) {
this.inFlightResponses.delete(requestId);
this.startApprovalTimeout(run, requestId);
throw new Error(`Team "${teamName}" process stdin is not writable`);
}
@ -33222,11 +33525,16 @@ export class TeamProvisioningService {
}
});
});
} finally {
run.pendingApprovals.delete(requestId);
} catch (error) {
this.inFlightResponses.delete(requestId);
this.dismissApprovalNotification(requestId);
if (run.pendingApprovals.has(requestId)) {
this.startApprovalTimeout(run, requestId);
}
throw error;
}
run.pendingApprovals.delete(requestId);
this.inFlightResponses.delete(requestId);
this.dismissApprovalNotification(requestId);
}
/**
@ -35740,7 +36048,6 @@ export class TeamProvisioningService {
const nextMembers: Record<string, unknown>[] = [];
for (const m of membersRaw) {
const name = typeof m.name === 'string' ? m.name.trim() : '';
const agentType = typeof m.agentType === 'string' ? m.agentType : '';
if (!name) continue;
if (isLeadMember(m) || name === 'user') {
nextMembers.push(m);

View file

@ -0,0 +1,185 @@
import type {
TeamRuntimeMemberLaunchEvidence,
TeamRuntimeMemberSpec,
TeamRuntimePendingApproval,
} from '../runtime/TeamRuntimeAdapter';
import type {
RuntimeApprovalLaunchPolicy,
RuntimeApprovalProviderPort,
RuntimeToolApprovalAnswerInput,
RuntimeToolApprovalEntry,
} from './RuntimeToolApprovalCoordinator';
import type { ToolApprovalRequest } from '@shared/types/team';
interface CollectOpenCodeRuntimeApprovalsInput {
teamName: string;
runId: string;
laneId: string;
cwd: string;
members: Record<string, TeamRuntimeMemberLaunchEvidence>;
expectedMembers: TeamRuntimeMemberSpec[];
teamColor?: string;
teamDisplayName?: string;
nowIso?: () => string;
}
export class OpenCodeRuntimeApprovalProvider implements RuntimeApprovalProviderPort<
{ toolApprovalMode?: 'auto' | 'manual' },
CollectOpenCodeRuntimeApprovalsInput
> {
readonly providerId = 'opencode' as const;
buildLaunchPolicy(
skipPermissions: boolean,
_context: { toolApprovalMode?: 'auto' | 'manual' } = {}
): RuntimeApprovalLaunchPolicy {
return {
providerId: this.providerId,
mode: skipPermissions ? 'auto' : 'manual',
config: {
permission: skipPermissions ? 'allow' : 'ask',
},
};
}
collectPendingApprovals(input: CollectOpenCodeRuntimeApprovalsInput): RuntimeToolApprovalEntry[] {
return collectOpenCodeRuntimeApprovalEntries(input);
}
async answerApproval(_input: RuntimeToolApprovalAnswerInput): Promise<void> {
throw new Error('OpenCode approval answers are handled by the runtime adapter bridge.');
}
assertManualSupported(): void {
return;
}
}
export const openCodeRuntimeApprovalProvider = new OpenCodeRuntimeApprovalProvider();
export function collectOpenCodeRuntimeApprovalEntries(
input: CollectOpenCodeRuntimeApprovalsInput
): RuntimeToolApprovalEntry[] {
const entries: RuntimeToolApprovalEntry[] = [];
const nowIso = input.nowIso ?? (() => new Date().toISOString());
for (const [memberName, member] of Object.entries(input.members)) {
for (const approval of collectOpenCodeRuntimePendingApprovals(member)) {
const providerRequestId = approval.requestId.trim();
if (!providerRequestId) {
continue;
}
const requestId = buildOpenCodeRuntimeApprovalRequestId(input.runId, providerRequestId);
const toolName = openCodeApprovalToolName(approval);
const toolInput = openCodeApprovalToolInput(approval);
const uiRequest: ToolApprovalRequest = {
requestId,
runId: input.runId,
teamName: input.teamName,
providerId: 'opencode',
source: memberName,
toolName,
toolInput,
receivedAt: nowIso(),
teamColor: input.teamColor,
teamDisplayName: input.teamDisplayName,
runtimePermission: {
providerId: 'opencode',
laneId: input.laneId,
memberName,
providerRequestId,
sessionId: approval.sessionId ?? member.sessionId ?? null,
},
};
entries.push({
providerId: 'opencode',
approval: uiRequest,
providerRequestId,
laneId: input.laneId,
memberName,
cwd: input.cwd,
expectedMembers: input.expectedMembers,
});
}
}
return entries;
}
function collectOpenCodeRuntimePendingApprovals(
member: TeamRuntimeMemberLaunchEvidence
): TeamRuntimePendingApproval[] {
const approvals = [...(member.pendingApprovals ?? []), ...(member.pendingPermissions ?? [])];
const byRequestId = new Map<string, TeamRuntimePendingApproval>();
for (const approval of approvals) {
const requestId = approval.requestId.trim();
if (!requestId || approval.providerId !== 'opencode' || byRequestId.has(requestId)) {
continue;
}
byRequestId.set(requestId, { ...approval, requestId });
}
for (const requestId of member.pendingPermissionRequestIds ?? []) {
const trimmed = requestId.trim();
if (!trimmed || byRequestId.has(trimmed)) {
continue;
}
byRequestId.set(trimmed, {
providerId: 'opencode',
requestId: trimmed,
sessionId: member.sessionId ?? null,
tool: null,
title: null,
kind: null,
});
}
return Array.from(byRequestId.values());
}
export function buildOpenCodeRuntimeApprovalRequestId(
runId: string,
providerRequestId: string
): string {
return `opencode:${runId}:${providerRequestId}`;
}
export function openCodeApprovalToolName(approval: TeamRuntimePendingApproval): string {
const rawTool = approval.tool?.trim() || approval.kind?.trim() || approval.title?.trim();
const normalized = rawTool?.toLowerCase();
switch (normalized) {
case 'bash':
case 'shell':
case 'terminal':
return 'Bash';
case 'edit':
return 'Edit';
case 'write':
return 'Write';
case 'read':
return 'Read';
default:
return rawTool || 'OpenCodeTool';
}
}
export function openCodeApprovalToolInput(
approval: TeamRuntimePendingApproval
): Record<string, unknown> {
const raw: Record<string, unknown> =
approval.raw && typeof approval.raw === 'object' ? approval.raw : {};
const patterns = Array.isArray(raw.patterns)
? raw.patterns.filter((value): value is string => typeof value === 'string')
: undefined;
const firstPattern = patterns?.[0];
const title = approval.title?.trim();
const input: Record<string, unknown> = {
providerRequestId: approval.requestId,
provider: 'opencode',
...(approval.sessionId ? { sessionId: approval.sessionId } : {}),
...(approval.tool ? { tool: approval.tool } : {}),
...(approval.kind ? { kind: approval.kind } : {}),
...(title ? { title } : {}),
...(patterns?.length ? { patterns } : {}),
};
if (openCodeApprovalToolName(approval) === 'Bash' && firstPattern) {
input.command = firstPattern;
}
return input;
}

View file

@ -0,0 +1,429 @@
import { shouldAutoAllow } from '@main/utils/toolApprovalRules';
import type {
TeamRuntimeApprovalProviderId,
TeamRuntimeMemberSpec,
} from '../runtime/TeamRuntimeAdapter';
import type {
ToolApprovalAutoResolved,
ToolApprovalDismiss,
ToolApprovalRequest,
ToolApprovalSettings,
} from '@shared/types/team';
export type RuntimeApprovalProviderId = TeamRuntimeApprovalProviderId;
export type RuntimeApprovalDecision = 'allow' | 'deny';
export interface RuntimeApprovalLaunchPolicy {
providerId: RuntimeApprovalProviderId;
mode: 'auto' | 'manual';
config: Record<string, unknown>;
}
export interface RuntimeApprovalProviderPort<TContext = unknown, TRuntimeState = unknown> {
readonly providerId: RuntimeApprovalProviderId;
buildLaunchPolicy(skipPermissions: boolean, context: TContext): RuntimeApprovalLaunchPolicy;
collectPendingApprovals(runtimeState: TRuntimeState): RuntimeToolApprovalEntry[];
answerApproval(input: RuntimeToolApprovalAnswerInput): Promise<void>;
assertManualSupported(context: TContext): void;
}
export interface RuntimeToolApprovalEntry {
providerId: RuntimeApprovalProviderId;
approval: ToolApprovalRequest;
providerRequestId: string;
laneId: string;
memberName: string;
cwd?: string;
expectedMembers?: TeamRuntimeMemberSpec[];
metadata?: Record<string, unknown>;
}
export interface RuntimeToolApprovalAnswerInput {
entry: RuntimeToolApprovalEntry;
allow: boolean;
message?: string;
}
export type RuntimeToolApprovalEvent =
| ToolApprovalRequest
| ToolApprovalDismiss
| ToolApprovalAutoResolved;
export interface RuntimeToolApprovalCoordinatorDeps {
getSettings(teamName: string): ToolApprovalSettings;
answerApproval(input: RuntimeToolApprovalAnswerInput): Promise<void>;
emitApprovalEvent(event: RuntimeToolApprovalEvent): void;
showApprovalNotification?(approval: ToolApprovalRequest): void;
dismissApprovalNotification?(requestId: string): void;
logWarning?(message: string): void;
}
export interface RuntimeToolApprovalSyncScope {
teamName: string;
runId: string;
laneId?: string;
providerId?: RuntimeApprovalProviderId;
}
export interface RuntimeToolApprovalClearOptions {
runId?: string;
laneId?: string;
providerId?: RuntimeApprovalProviderId;
emitDismiss?: boolean;
}
export function mapAppApprovalDecisionToProviderDecision(
decision: RuntimeApprovalDecision
): 'allow' | 'reject' {
return decision === 'allow' ? 'allow' : 'reject';
}
export class RuntimeToolApprovalCoordinator {
private readonly approvalsByTeam = new Map<string, Map<string, RuntimeToolApprovalEntry>>();
private readonly timers = new Map<string, ReturnType<typeof setTimeout>>();
private readonly inFlightResponses = new Set<string>();
constructor(private readonly deps: RuntimeToolApprovalCoordinatorDeps) {}
sync(scope: RuntimeToolApprovalSyncScope, entries: RuntimeToolApprovalEntry[]): void {
const observedRequestIds = new Set<string>();
for (const entry of entries) {
observedRequestIds.add(entry.approval.requestId);
this.register(entry);
}
const approvals = this.approvalsByTeam.get(scope.teamName);
if (!approvals) {
return;
}
for (const [requestId, entry] of approvals) {
if (!this.matchesScope(entry, scope)) {
continue;
}
if (observedRequestIds.has(requestId)) {
continue;
}
this.removeEntry(entry);
this.deps.emitApprovalEvent({
autoResolved: true,
requestId,
runId: entry.approval.runId,
teamName: entry.approval.teamName,
reason: 'runtime_resolved',
} as ToolApprovalAutoResolved);
}
}
register(entry: RuntimeToolApprovalEntry): void {
const requestId = entry.approval.requestId;
if (!requestId) {
return;
}
const approvals = this.getTeamApprovals(entry.approval.teamName);
if (approvals.has(requestId) || this.inFlightResponses.has(requestId)) {
return;
}
const autoResult = shouldAutoAllow(
this.deps.getSettings(entry.approval.teamName),
entry.approval.toolName,
entry.approval.toolInput
);
if (autoResult.autoAllow) {
void this.answerUntracked(entry, true, undefined, 'auto_allow_category');
return;
}
approvals.set(requestId, entry);
this.deps.emitApprovalEvent(entry.approval);
this.startTimeout(entry);
this.deps.showApprovalNotification?.(entry.approval);
}
async respond(
teamName: string,
runId: string,
requestId: string,
allow: boolean,
message?: string
): Promise<boolean> {
const entry = this.approvalsByTeam.get(teamName)?.get(requestId);
if (!entry) {
return false;
}
if (entry.approval.runId !== runId) {
throw new Error(
`Stale approval: runId mismatch (expected ${entry.approval.runId}, got ${runId})`
);
}
this.clearTimer(requestId);
if (!this.tryClaimResponse(requestId)) {
return true;
}
try {
await this.deps.answerApproval({ entry, allow, message });
} catch (error) {
this.inFlightResponses.delete(requestId);
if (this.get(entry.approval.teamName, requestId) === entry) {
this.startTimeout(entry);
}
throw error;
}
this.removeEntry(entry);
this.inFlightResponses.delete(requestId);
return true;
}
clear(teamName: string, options: RuntimeToolApprovalClearOptions = {}): number {
const approvals = this.approvalsByTeam.get(teamName);
if (!approvals) {
return 0;
}
let removed = 0;
const removedRunIds = new Set<string>();
for (const entry of Array.from(approvals.values())) {
if (!this.matchesClearOptions(entry, options)) {
continue;
}
this.removeEntry(entry);
removed += 1;
removedRunIds.add(entry.approval.runId);
}
if (removed > 0 && options.emitDismiss) {
for (const runId of removedRunIds) {
this.deps.emitApprovalEvent({ dismissed: true, teamName, runId });
}
}
return removed;
}
reEvaluate(): void {
for (const approvals of Array.from(this.approvalsByTeam.values())) {
for (const entry of Array.from(approvals.values())) {
const requestId = entry.approval.requestId;
const settings = this.deps.getSettings(entry.approval.teamName);
const autoResult = shouldAutoAllow(
settings,
entry.approval.toolName,
entry.approval.toolInput
);
if (autoResult.autoAllow) {
this.clearTimer(requestId);
void this.answerTracked(entry, true, undefined, 'auto_allow_category');
continue;
}
if (settings.timeoutAction === 'wait') {
this.clearTimer(requestId);
} else if (!this.timers.has(requestId)) {
this.startTimeout(entry);
}
}
}
}
get(teamName: string, requestId: string): RuntimeToolApprovalEntry | undefined {
return this.approvalsByTeam.get(teamName)?.get(requestId);
}
size(teamName?: string): number {
if (teamName) {
return this.approvalsByTeam.get(teamName)?.size ?? 0;
}
let total = 0;
for (const approvals of this.approvalsByTeam.values()) {
total += approvals.size;
}
return total;
}
dispose(): void {
for (const requestId of Array.from(this.timers.keys())) {
this.clearTimer(requestId);
}
this.approvalsByTeam.clear();
this.inFlightResponses.clear();
}
private startTimeout(entry: RuntimeToolApprovalEntry): void {
const { timeoutAction, timeoutSeconds } = this.deps.getSettings(entry.approval.teamName);
if (timeoutAction === 'wait') {
return;
}
const requestId = entry.approval.requestId;
if (this.timers.has(requestId)) {
return;
}
const timer = setTimeout(() => {
this.timers.delete(requestId);
const current = this.get(entry.approval.teamName, requestId);
if (!current) {
return;
}
const currentAction = this.deps.getSettings(entry.approval.teamName).timeoutAction;
if (currentAction === 'wait') {
return;
}
const allow = currentAction === 'allow';
void this.answerTracked(
current,
allow,
allow ? undefined : 'Timed out - auto-denied by settings',
allow ? 'timeout_allow' : 'timeout_deny'
);
}, timeoutSeconds * 1000);
timer.unref?.();
this.timers.set(requestId, timer);
}
private async answerTracked(
entry: RuntimeToolApprovalEntry,
allow: boolean,
message: string | undefined,
reason: ToolApprovalAutoResolved['reason']
): Promise<void> {
const requestId = entry.approval.requestId;
if (!this.tryClaimResponse(requestId)) {
return;
}
try {
await this.deps.answerApproval({ entry, allow, message });
this.removeEntry(entry);
this.deps.emitApprovalEvent({
autoResolved: true,
requestId,
runId: entry.approval.runId,
teamName: entry.approval.teamName,
reason,
} as ToolApprovalAutoResolved);
} catch (error) {
this.deps.logWarning?.(
`[${entry.approval.teamName}] Failed to auto-resolve runtime approval ${requestId}: ${
error instanceof Error ? error.message : String(error)
}`
);
if (this.get(entry.approval.teamName, requestId) === entry) {
this.startTimeout(entry);
}
} finally {
this.inFlightResponses.delete(requestId);
}
}
private async answerUntracked(
entry: RuntimeToolApprovalEntry,
allow: boolean,
message: string | undefined,
reason: ToolApprovalAutoResolved['reason']
): Promise<void> {
const requestId = entry.approval.requestId;
if (!this.tryClaimResponse(requestId)) {
return;
}
try {
await this.deps.answerApproval({ entry, allow, message });
this.deps.emitApprovalEvent({
autoResolved: true,
requestId,
runId: entry.approval.runId,
teamName: entry.approval.teamName,
reason,
} as ToolApprovalAutoResolved);
} catch (error) {
this.deps.logWarning?.(
`[${entry.approval.teamName}] Failed to auto-resolve runtime approval ${requestId}: ${
error instanceof Error ? error.message : String(error)
}`
);
} finally {
this.inFlightResponses.delete(requestId);
}
}
private removeEntry(entry: RuntimeToolApprovalEntry): void {
const requestId = entry.approval.requestId;
this.clearTimer(requestId);
this.inFlightResponses.delete(requestId);
this.deps.dismissApprovalNotification?.(requestId);
const approvals = this.approvalsByTeam.get(entry.approval.teamName);
if (!approvals) {
return;
}
approvals.delete(requestId);
if (approvals.size === 0) {
this.approvalsByTeam.delete(entry.approval.teamName);
}
}
private clearTimer(requestId: string): void {
const timer = this.timers.get(requestId);
if (!timer) {
return;
}
clearTimeout(timer);
this.timers.delete(requestId);
}
private tryClaimResponse(requestId: string): boolean {
if (this.inFlightResponses.has(requestId)) {
return false;
}
this.inFlightResponses.add(requestId);
return true;
}
private getTeamApprovals(teamName: string): Map<string, RuntimeToolApprovalEntry> {
const existing = this.approvalsByTeam.get(teamName);
if (existing) {
return existing;
}
const approvals = new Map<string, RuntimeToolApprovalEntry>();
this.approvalsByTeam.set(teamName, approvals);
return approvals;
}
private matchesScope(
entry: RuntimeToolApprovalEntry,
scope: RuntimeToolApprovalSyncScope
): boolean {
if (entry.approval.teamName !== scope.teamName) {
return false;
}
if (entry.approval.runId !== scope.runId) {
return false;
}
if (scope.laneId && entry.laneId !== scope.laneId) {
return false;
}
if (scope.providerId && entry.providerId !== scope.providerId) {
return false;
}
return true;
}
private matchesClearOptions(
entry: RuntimeToolApprovalEntry,
options: RuntimeToolApprovalClearOptions
): boolean {
if (options.runId && entry.approval.runId !== options.runId) {
return false;
}
if (options.laneId && entry.laneId !== options.laneId) {
return false;
}
if (options.providerId && entry.providerId !== options.providerId) {
return false;
}
return true;
}
}

View file

@ -38,6 +38,14 @@ export interface OpenCodeBridgeProcessRunner {
run(input: OpenCodeBridgeProcessRunInput): Promise<OpenCodeBridgeProcessRunResult>;
}
interface OpenCodeBridgeOutputReadResult {
content: string;
outputSource: 'stdout' | 'file' | 'none';
stdoutBytes: number;
outputFileBytes: number | null;
outputReadError: string | null;
}
export interface OpenCodeBridgeDiagnosticsSink {
append(event: OpenCodeBridgeDiagnosticEvent): Promise<void>;
}
@ -185,7 +193,16 @@ export class OpenCodeBridgeCommandClient {
stderrLimitBytes: options.stderrLimitBytes ?? DEFAULT_STDERR_LIMIT_BYTES,
env: await this.resolveEnv(),
});
const stdout = await this.readBridgeOutput(processResult.stdout, outputPath);
const bridgeOutput = await this.readBridgeOutput(processResult.stdout, outputPath);
const processDetails = {
exitCode: processResult.exitCode,
timedOut: processResult.timedOut,
stdoutBytes: bridgeOutput.stdoutBytes,
stderrBytes: byteLength(processResult.stderr),
outputSource: bridgeOutput.outputSource,
outputFileBytes: bridgeOutput.outputFileBytes,
outputReadError: bridgeOutput.outputReadError,
};
if (processResult.timedOut) {
return this.contractFailure(
@ -196,6 +213,7 @@ export class OpenCodeBridgeCommandClient {
{
stderr: redactBridgeDiagnosticText(processResult.stderr),
attempts: attempt,
...processDetails,
}
);
}
@ -207,14 +225,14 @@ export class OpenCodeBridgeCommandClient {
'OpenCode bridge command failed',
true,
{
exitCode: processResult.exitCode,
stderr: redactBridgeDiagnosticText(processResult.stderr),
attempts: attempt,
...processDetails,
}
);
}
const parsed = parseSingleBridgeJsonResult<TData>(stdout);
const parsed = parseSingleBridgeJsonResult<TData>(bridgeOutput.content);
if (!parsed.ok) {
if (shouldRetryEmptyReadinessStdout(command, parsed.error, attempt, maxAttempts)) {
await sleep(EMPTY_STDOUT_READINESS_RETRY_DELAY_MS);
@ -222,9 +240,10 @@ export class OpenCodeBridgeCommandClient {
}
return this.contractFailure(envelope, 'contract_violation', parsed.error, false, {
stdoutPreview: redactBridgeDiagnosticText(stdout.slice(0, 2_000)),
stdoutPreview: redactBridgeDiagnosticText(bridgeOutput.content.slice(0, 2_000)),
stderrPreview: redactBridgeDiagnosticText(processResult.stderr.slice(0, 2_000)),
attempts: attempt,
...processDetails,
});
}
@ -232,6 +251,7 @@ export class OpenCodeBridgeCommandClient {
if (!validation.ok) {
return this.contractFailure(envelope, 'contract_violation', validation.reason, false, {
attempts: attempt,
...processDetails,
});
}
@ -253,14 +273,38 @@ export class OpenCodeBridgeCommandClient {
}
}
private async readBridgeOutput(stdout: string, outputPath: string): Promise<string> {
private async readBridgeOutput(
stdout: string,
outputPath: string
): Promise<OpenCodeBridgeOutputReadResult> {
const stdoutBytes = byteLength(stdout);
if (stdout.trim().length > 0) {
return stdout;
return {
content: stdout,
outputSource: 'stdout',
stdoutBytes,
outputFileBytes: null,
outputReadError: null,
};
}
try {
return await fs.readFile(outputPath, 'utf8');
} catch {
return stdout;
const output = await fs.readFile(outputPath, 'utf8');
const outputFileBytes = byteLength(output);
return {
content: output,
outputSource: output.trim().length > 0 ? 'file' : 'none',
stdoutBytes,
outputFileBytes,
outputReadError: null,
};
} catch (error) {
return {
content: stdout,
outputSource: 'none',
stdoutBytes,
outputFileBytes: 0,
outputReadError: getBridgeOutputReadError(error),
};
}
}
@ -291,6 +335,13 @@ export class OpenCodeBridgeCommandClient {
details: Record<string, unknown>
): Promise<OpenCodeBridgeFailure> {
const completedAt = this.clock().toISOString();
const diagnosticDetails = {
command: envelope.command,
requestId: envelope.requestId,
cwd: redactBridgeDiagnosticText(envelope.cwd),
binaryPath: redactBridgeDiagnosticText(this.binaryPath),
...details,
};
const diagnostic: OpenCodeBridgeDiagnosticEvent = {
id: this.diagnosticIdFactory(),
type:
@ -301,11 +352,11 @@ export class OpenCodeBridgeCommandClient {
runId: extractRunId(envelope.body) ?? undefined,
severity: retryable ? 'warning' : 'error',
message,
data: details,
data: diagnosticDetails,
createdAt: completedAt,
};
await this.diagnostics?.append(diagnostic);
await this.diagnostics?.append(diagnostic).catch(() => undefined);
return {
ok: false,
@ -318,7 +369,7 @@ export class OpenCodeBridgeCommandClient {
kind,
message,
retryable,
details,
details: diagnosticDetails,
},
diagnostics: [diagnostic],
};
@ -356,3 +407,17 @@ function bufferToString(value: string | Buffer | undefined): string {
}
return '';
}
function byteLength(value: string): number {
return Buffer.byteLength(value, 'utf8');
}
function getBridgeOutputReadError(error: unknown): string {
if (error && typeof error === 'object' && 'code' in error) {
const code = (error as { code?: unknown }).code;
if (typeof code === 'string' && code.trim()) {
return code.trim();
}
}
return error instanceof Error ? error.message : String(error);
}

View file

@ -64,6 +64,7 @@ export interface OpenCodeLaunchTeamCommandBody {
teamName: string;
projectPath: string;
selectedModel: string;
skipPermissions?: boolean;
members: OpenCodeTeamLaunchMemberCommandSpec[];
leadPrompt: string;
expectedCapabilitySnapshotId: string | null;
@ -71,6 +72,15 @@ export interface OpenCodeLaunchTeamCommandBody {
capabilitySnapshotRecoveryAttemptId?: string;
}
export interface OpenCodeRuntimePermissionCommandData {
requestId: string;
sessionId: string | null;
tool: string | null;
title: string | null;
kind: string | null;
raw?: Record<string, unknown>;
}
export interface OpenCodeTeamMemberLaunchCommandData {
sessionId: string;
launchState: OpenCodeTeamMemberLaunchBridgeState;
@ -78,6 +88,7 @@ export interface OpenCodeTeamMemberLaunchCommandData {
bootstrapMode?: OpenCodeBootstrapMode;
appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate;
pendingPermissionRequestIds?: string[];
pendingPermissions?: OpenCodeRuntimePermissionCommandData[];
diagnostics?: string[];
model: string;
runtimePid?: number;
@ -132,6 +143,30 @@ export interface OpenCodeStopTeamCommandData {
runtimeStoreManifestHighWatermark?: number | null;
}
export interface OpenCodeAnswerPermissionCommandBody {
runId: string;
laneId: string;
teamId: string;
teamName: string;
projectPath: string;
memberName?: string;
requestId: string;
decision: 'allow' | 'always' | 'reject';
expectedCapabilitySnapshotId?: string | null;
manifestHighWatermark?: number | null;
}
export interface OpenCodeListRuntimePermissionsCommandBody {
teamId: string;
teamName: string;
laneId?: string;
projectPath?: string;
}
export interface OpenCodeListRuntimePermissionsCommandData {
permissions: OpenCodeRuntimePermissionCommandData[];
}
export interface OpenCodeCleanupHostsCommandBody {
reason: 'startup' | 'shutdown' | 'manual' | string;
mode?: 'stale' | 'force';
@ -590,6 +625,7 @@ export function assertBridgeResultCanMutateState<TData>(
command: OpenCodeBridgeCommandName;
runId: string | null;
capabilitySnapshotId: string | null;
allowCapabilitySnapshotRecovery?: boolean;
}
): asserts result is OpenCodeBridgeSuccess<TData> {
if (!result.ok) {
@ -612,12 +648,28 @@ export function assertBridgeResultCanMutateState<TData>(
if (
expected.capabilitySnapshotId !== null &&
result.runtime.capabilitySnapshotId !== expected.capabilitySnapshotId
result.runtime.capabilitySnapshotId !== expected.capabilitySnapshotId &&
!(
expected.allowCapabilitySnapshotRecovery === true &&
hasOpenCodeBridgeDataDiagnosticCode(result.data, 'opencode_capability_snapshot_recovery')
)
) {
throw new Error('OpenCode bridge capability snapshot mismatch');
}
}
function hasOpenCodeBridgeDataDiagnosticCode(value: unknown, code: string): boolean {
if (!isRecord(value) || !Array.isArray(value.diagnostics)) {
return false;
}
return value.diagnostics.some((diagnostic) => {
if (!isRecord(diagnostic)) {
return false;
}
return diagnostic.code === code;
});
}
export function validateOpenCodeBridgeHandshake(input: {
handshake: OpenCodeBridgeHandshake;
expectedClient: OpenCodeBridgePeerIdentity;
@ -744,6 +796,7 @@ export function assertBridgeEvidenceCanCommitToRuntimeStores(input: {
manifest: RuntimeStoreManifestEvidence;
idempotencyKey: string;
enforceManifestHighWatermark?: boolean;
allowCapabilitySnapshotRecovery?: boolean;
}): asserts input is {
result: OpenCodeBridgeSuccess<unknown>;
requestId: string;
@ -758,6 +811,7 @@ export function assertBridgeEvidenceCanCommitToRuntimeStores(input: {
command: input.command,
runId: input.runId,
capabilitySnapshotId: input.capabilitySnapshotId,
allowCapabilitySnapshotRecovery: input.allowCapabilitySnapshotRecovery,
});
const resultManifestHighWatermark = extractManifestHighWatermark(input.result.data);

View file

@ -0,0 +1,143 @@
import { promises as fs } from 'fs';
import * as path from 'path';
import { redactBridgeDiagnosticText } from './OpenCodeBridgeCommandClient';
import type { OpenCodeBridgeDiagnosticsSink } from './OpenCodeBridgeCommandClient';
import type { OpenCodeBridgeDiagnosticEvent } from './OpenCodeBridgeCommandContract';
const DEFAULT_MAX_EVENTS_BYTES = 3 * 1024 * 1024;
const MAX_STRING_CHARS = 4_000;
export interface OpenCodeBridgeDiagnosticsStoreOptions {
directory: string;
maxEventsBytes?: number;
}
export class OpenCodeBridgeDiagnosticsStore implements OpenCodeBridgeDiagnosticsSink {
private readonly directory: string;
private readonly maxEventsBytes: number;
constructor(options: OpenCodeBridgeDiagnosticsStoreOptions) {
this.directory = options.directory;
this.maxEventsBytes = options.maxEventsBytes ?? DEFAULT_MAX_EVENTS_BYTES;
}
async append(event: OpenCodeBridgeDiagnosticEvent): Promise<void> {
try {
await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
const sanitized = sanitizeDiagnosticEvent(event);
await fs.writeFile(
path.join(this.directory, 'latest.json'),
`${JSON.stringify(sanitized, null, 2)}\n`,
{ encoding: 'utf8', mode: 0o600 }
);
await this.rotateEventsIfNeeded();
await fs.appendFile(
path.join(this.directory, 'events.ndjson'),
`${JSON.stringify(sanitized)}\n`,
{ encoding: 'utf8', mode: 0o600 }
);
} catch {
// Best-effort diagnostics must never block provider preflight or launch.
}
}
private async rotateEventsIfNeeded(): Promise<void> {
const eventsPath = path.join(this.directory, 'events.ndjson');
const stat = await fs.stat(eventsPath).catch(() => null);
if (!stat || stat.size <= this.maxEventsBytes) {
return;
}
const content = await fs.readFile(eventsPath, 'utf8').catch(() => '');
const keepBytes = Math.max(0, Math.floor(this.maxEventsBytes / 2));
const tailLines = selectNdjsonTailLines(content, keepBytes);
await fs.writeFile(
eventsPath,
`${JSON.stringify({
type: 'opencode_bridge_diagnostics_truncated',
providerId: 'opencode',
severity: 'info',
message: 'truncated previous bridge diagnostics',
createdAt: new Date().toISOString(),
})}\n${tailLines.length > 0 ? `${tailLines.join('\n')}\n` : ''}`,
{
encoding: 'utf8',
mode: 0o600,
}
);
}
}
function selectNdjsonTailLines(content: string, maxBytes: number): string[] {
if (maxBytes <= 0) {
return [];
}
const selected: string[] = [];
let totalBytes = 0;
const lines = content.split('\n').filter((line) => line.trim().length > 0);
for (let index = lines.length - 1; index >= 0; index -= 1) {
const line = lines[index];
const nextBytes = Buffer.byteLength(`${line}\n`, 'utf8');
if (selected.length > 0 && totalBytes + nextBytes > maxBytes) {
break;
}
if (selected.length === 0 || totalBytes + nextBytes <= maxBytes) {
selected.unshift(line);
totalBytes += nextBytes;
}
}
return selected;
}
function sanitizeDiagnosticEvent(
event: OpenCodeBridgeDiagnosticEvent
): OpenCodeBridgeDiagnosticEvent {
return {
...event,
message: sanitizeString(event.message),
...(event.teamName ? { teamName: sanitizeString(event.teamName) } : {}),
...(event.runId ? { runId: sanitizeString(event.runId) } : {}),
...(event.data ? { data: sanitizeRecord(event.data) } : {}),
};
}
function sanitizeRecord(value: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, sanitizeRecordEntry(key, entry)])
);
}
function sanitizeRecordEntry(key: string, entry: unknown): unknown {
const normalized = key.toLowerCase();
if (
normalized === 'stdin' ||
normalized === 'stdout' ||
normalized === 'stderr' ||
normalized === 'input'
) {
return '[omitted]';
}
return sanitizeValue(entry);
}
function sanitizeValue(value: unknown): unknown {
if (typeof value === 'string') {
return sanitizeString(value);
}
if (Array.isArray(value)) {
return value.map((entry) => sanitizeValue(entry));
}
if (value && typeof value === 'object') {
return sanitizeRecord(value as Record<string, unknown>);
}
return value;
}
function sanitizeString(value: string): string {
const redacted = redactBridgeDiagnosticText(value);
return redacted.length > MAX_STRING_CHARS
? `${redacted.slice(0, MAX_STRING_CHARS)}...[truncated]`
: redacted;
}

View file

@ -0,0 +1,146 @@
import * as os from 'os';
import { redactBridgeDiagnosticText } from './OpenCodeBridgeCommandClient';
import type { OpenCodeBridgeFailure } from './OpenCodeBridgeCommandContract';
import type { TeamProvisioningSupportDiagnostic } from '@shared/types/team';
const NO_OUTPUT_TITLE = 'OpenCode runtime check returned no output';
const NO_OUTPUT_SUMMARY = 'OpenCode readiness bridge exited without returning diagnostic JSON.';
export function isOpenCodeBridgeNoOutputDiagnostic(value: string | null | undefined): boolean {
const lower = value?.trim().toLowerCase() ?? '';
return (
lower.includes('opencode runtime check returned no output') ||
lower.includes('bridge stdout was empty') ||
lower.includes('opencode_bridge_contract_violation') ||
(lower.includes('opencode readiness bridge failed') && lower.includes('contract_violation'))
);
}
export function buildOpenCodeBridgeSupportDiagnostic(input: {
result: OpenCodeBridgeFailure;
projectPath: string;
selectedModel: string | null;
appVersion?: string | null;
}): TeamProvisioningSupportDiagnostic | null {
const event =
input.result.diagnostics.find((diagnostic) =>
isOpenCodeBridgeNoOutputDiagnostic(`${diagnostic.type}: ${diagnostic.message}`)
) ?? input.result.diagnostics[0];
const visibleError = `OpenCode readiness bridge failed: ${input.result.error.kind}: ${input.result.error.message}`;
const eventText = event ? `${event.type}: ${event.message}` : '';
if (
!isOpenCodeBridgeNoOutputDiagnostic(visibleError) &&
!isOpenCodeBridgeNoOutputDiagnostic(eventText)
) {
return null;
}
const details = {
...(event?.data ?? {}),
...(input.result.error.details ?? {}),
};
const createdAt = event?.createdAt ?? input.result.completedAt;
const copyText = buildOpenCodeBridgeSupportCopyText({
createdAt,
severity: event?.severity ?? (input.result.error.retryable ? 'warning' : 'error'),
visibleError,
details,
result: input.result,
projectPath: input.projectPath,
selectedModel: input.selectedModel,
appVersion: input.appVersion ?? null,
});
return {
id: event?.id ?? `opencode-bridge-support-${input.result.requestId}`,
providerId: 'opencode',
kind: 'opencode_bridge_no_output',
severity: event?.severity ?? (input.result.error.retryable ? 'warning' : 'error'),
title: NO_OUTPUT_TITLE,
summary: NO_OUTPUT_SUMMARY,
copyText,
createdAt,
};
}
function buildOpenCodeBridgeSupportCopyText(input: {
createdAt: string;
severity: 'info' | 'warning' | 'error';
visibleError: string;
details: Record<string, unknown>;
result: OpenCodeBridgeFailure;
projectPath: string;
selectedModel: string | null;
appVersion: string | null;
}): string {
const command = formatDiagnosticValue(input.details.command, input.result.command);
const requestId = formatDiagnosticValue(input.details.requestId, input.result.requestId);
const stderrPreview = formatPreview(input.details.stderrPreview);
return [
'Agent Teams OpenCode diagnostics',
`Time: ${input.createdAt}`,
'Provider: opencode',
`Severity: ${input.severity}`,
`Title: ${NO_OUTPUT_TITLE}`,
`Summary: ${NO_OUTPUT_SUMMARY}`,
'',
'Visible error:',
redactBridgeDiagnosticText(input.visibleError),
'',
'Bridge command:',
`command: ${command}`,
`requestId: ${requestId}`,
`attempts: ${formatDiagnosticValue(input.details.attempts)}`,
`exitCode: ${formatDiagnosticValue(input.details.exitCode)}`,
`timedOut: ${formatDiagnosticValue(input.details.timedOut)}`,
`stdoutBytes: ${formatDiagnosticValue(input.details.stdoutBytes)}`,
`stderrBytes: ${formatDiagnosticValue(input.details.stderrBytes)}`,
`outputSource: ${formatDiagnosticValue(input.details.outputSource)}`,
`outputFileBytes: ${formatDiagnosticValue(input.details.outputFileBytes)}`,
`outputReadError: ${formatDiagnosticValue(input.details.outputReadError)}`,
'',
'Environment:',
`platform: ${process.platform}`,
`arch: ${process.arch}`,
`appVersion: ${formatDiagnosticValue(input.appVersion)}`,
`projectPath: ${redactDiagnosticPath(input.projectPath)}`,
`selectedModel: ${formatDiagnosticValue(input.selectedModel)}`,
'',
'stderrPreview:',
stderrPreview,
].join('\n');
}
function formatDiagnosticValue(value: unknown, fallback: unknown = undefined): string {
const resolved = value ?? fallback;
if (resolved === null || resolved === undefined || resolved === '') {
return '(none)';
}
if (typeof resolved === 'string') {
return redactBridgeDiagnosticText(resolved);
}
if (typeof resolved === 'number' || typeof resolved === 'boolean') {
return String(resolved);
}
return redactBridgeDiagnosticText(JSON.stringify(resolved));
}
function formatPreview(value: unknown): string {
const formatted = formatDiagnosticValue(value);
return formatted === '(none)' ? '(empty)' : formatted;
}
function redactDiagnosticPath(value: string): string {
const home = os.homedir();
const trimmed = value.trim();
if (!trimmed) {
return '(none)';
}
if (home && trimmed.startsWith(home)) {
return `~${trimmed.slice(home.length)}`;
}
return redactBridgeDiagnosticText(trimmed);
}

View file

@ -4,6 +4,7 @@ import {
OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION,
stableHash,
} from './OpenCodeBridgeCommandContract';
import { buildOpenCodeBridgeSupportDiagnostic } from './OpenCodeBridgeSupportDiagnostics';
import type { OpenCodeTeamRuntimeBridgePort } from '../../runtime/OpenCodeTeamRuntimeAdapter';
import type {
@ -11,6 +12,7 @@ import type {
OpenCodeTeamLaunchReadinessState,
} from '../readiness/OpenCodeTeamLaunchReadiness';
import type {
OpenCodeAnswerPermissionCommandBody,
OpenCodeBackfillTaskLedgerCommandBody,
OpenCodeBackfillTaskLedgerCommandData,
OpenCodeBridgeCommandName,
@ -24,6 +26,8 @@ import type {
OpenCodeCommandStatusCommandData,
OpenCodeLaunchTeamCommandBody,
OpenCodeLaunchTeamCommandData,
OpenCodeListRuntimePermissionsCommandBody,
OpenCodeListRuntimePermissionsCommandData,
OpenCodeObserveMessageDeliveryCommandBody,
OpenCodeObserveMessageDeliveryCommandData,
OpenCodeReconcileTeamCommandBody,
@ -62,6 +66,7 @@ export interface OpenCodeReadinessBridgeOptions {
observeTimeoutMs?: number;
stopTimeoutMs?: number;
cleanupTimeoutMs?: number;
appVersion?: string;
stateChangingCommands?: Pick<OpenCodeStateChangingBridgeCommandService, 'execute'>;
}
@ -80,6 +85,7 @@ const DEFAULT_SEND_TIMEOUT_MS = 45_000;
const DEFAULT_OBSERVE_TIMEOUT_MS = 20_000;
const DEFAULT_STOP_TIMEOUT_MS = 30_000;
const DEFAULT_CLEANUP_TIMEOUT_MS = 10_000;
const DEFAULT_PERMISSION_TIMEOUT_MS = 30_000;
const DEFAULT_BACKFILL_TIMEOUT_MS = 45_000;
const DEFAULT_COMMAND_STATUS_TIMEOUT_MS = 5_000;
const OPEN_CODE_COMPLETED_COMMAND_RECOVERY_MESSAGE =
@ -118,6 +124,12 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
}
this.lastRuntimeSnapshotsByProjectPath.delete(input.projectPath);
const supportDiagnostic = buildOpenCodeBridgeSupportDiagnostic({
result,
projectPath: input.projectPath,
selectedModel: input.selectedModel,
appVersion: this.options.appVersion ?? null,
});
return blockedReadiness({
state: mapBridgeFailureToReadinessState(result.error.kind),
modelId: input.selectedModel,
@ -126,6 +138,7 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
...result.diagnostics.map(formatDiagnosticEvent),
],
missing: [result.error.message],
supportDiagnostics: supportDiagnostic ? [supportDiagnostic] : undefined,
});
}
@ -204,6 +217,40 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
};
}
async answerOpenCodeRuntimePermission(
input: OpenCodeAnswerPermissionCommandBody
): Promise<OpenCodeLaunchTeamCommandData> {
const result = await this.executeStateChangingCommand<
OpenCodeAnswerPermissionCommandBody,
OpenCodeLaunchTeamCommandData
>('opencode.answerPermission', input, {
teamName: input.teamName,
laneId: input.laneId,
runId: input.runId,
capabilitySnapshotId: input.expectedCapabilitySnapshotId ?? null,
cwd: input.projectPath,
timeoutMs: DEFAULT_PERMISSION_TIMEOUT_MS,
});
return result.ok ? result.data : blockedLaunchData(input.runId, result);
}
async listOpenCodeRuntimePermissions(
input: OpenCodeListRuntimePermissionsCommandBody
): Promise<OpenCodeListRuntimePermissionsCommandData> {
const cwd = input.projectPath ?? process.cwd();
const result = await this.bridge.execute<
OpenCodeListRuntimePermissionsCommandBody,
OpenCodeListRuntimePermissionsCommandData
>('opencode.listRuntimePermissions', input, {
cwd,
timeoutMs: DEFAULT_PERMISSION_TIMEOUT_MS,
});
if (result.ok) {
return result.data;
}
return { permissions: [] };
}
async cleanupOpenCodeHosts(
input: OpenCodeCleanupHostsCommandBody
): Promise<OpenCodeCleanupHostsCommandData> {
@ -565,7 +612,11 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
type OpenCodeStateChangingTeamCommandName = Extract<
OpenCodeBridgeCommandName,
'opencode.launchTeam' | 'opencode.reconcileTeam' | 'opencode.stopTeam' | 'opencode.sendMessage'
| 'opencode.launchTeam'
| 'opencode.reconcileTeam'
| 'opencode.stopTeam'
| 'opencode.sendMessage'
| 'opencode.answerPermission'
>;
function blockedLaunchData(
@ -600,6 +651,7 @@ function blockedReadiness(input: {
modelId: string | null;
diagnostics: string[];
missing: string[];
supportDiagnostics?: OpenCodeTeamLaunchReadiness['supportDiagnostics'];
}): OpenCodeTeamLaunchReadiness {
return {
state: input.state,
@ -617,6 +669,9 @@ function blockedReadiness(input: {
supportLevel: null,
missing: dedupe(input.missing),
diagnostics: dedupe(input.diagnostics),
...(input.supportDiagnostics?.length
? { supportDiagnostics: [...input.supportDiagnostics] }
: {}),
evidence: {
capabilitiesReady: false,
mcpToolProofRoute: null,

View file

@ -245,6 +245,10 @@ export class OpenCodeStateChangingBridgeCommandService {
manifest,
idempotencyKey,
enforceManifestHighWatermark,
allowCapabilitySnapshotRecovery: isOpenCodeLaunchCapabilitySnapshotRecoveryAttempt(
input.command,
input.body
),
});
} catch (error) {
await this.ledger.markFailed({
@ -354,6 +358,17 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isOpenCodeLaunchCapabilitySnapshotRecoveryAttempt(
command: OpenCodeBridgeCommandName,
body: unknown
): boolean {
if (command !== 'opencode.launchTeam' || !isRecord(body)) {
return false;
}
const recoveryAttemptId = body.capabilitySnapshotRecoveryAttemptId;
return typeof recoveryAttemptId === 'string' && recoveryAttemptId.trim().length > 0;
}
function requiresOpenCodeDeliveryAcceptanceContract(
command: OpenCodeBridgeCommandName,
body: unknown

View file

@ -11,6 +11,7 @@ import {
import type { OpenCodeApiCapabilities } from '../capabilities/OpenCodeApiCapabilities';
import type { OpenCodeMcpToolProof } from '../mcp/OpenCodeMcpToolAvailability';
import type { RuntimeStoreReadinessCheck } from '../store/RuntimeStoreManifest';
import type { TeamProvisioningSupportDiagnostic } from '@shared/types/team';
export type OpenCodeTeamLaunchReadinessState =
| 'ready'
@ -57,6 +58,7 @@ export interface OpenCodeTeamLaunchReadiness {
supportLevel: OpenCodeSupportLevel | null;
missing: string[];
diagnostics: string[];
supportDiagnostics?: TeamProvisioningSupportDiagnostic[];
evidence: {
capabilitiesReady: boolean;
mcpToolProofRoute: OpenCodeMcpToolProof['route'];

View file

@ -1,12 +1,14 @@
import { randomUUID } from 'crypto';
import type {
OpenCodeAnswerPermissionCommandBody,
OpenCodeBridgeRuntimeSnapshot,
OpenCodeLaunchTeamCommandBody,
OpenCodeLaunchTeamCommandData,
OpenCodeObserveMessageDeliveryCommandBody,
OpenCodeObserveMessageDeliveryCommandData,
OpenCodeReconcileTeamCommandBody,
OpenCodeRuntimePermissionCommandData,
OpenCodeSendMessageCommandBody,
OpenCodeSendMessageCommandData,
OpenCodeStopTeamCommandBody,
@ -20,6 +22,8 @@ import type {
TeamRuntimeLaunchResult,
TeamRuntimeMemberLaunchEvidence,
TeamRuntimeMemberStopEvidence,
TeamRuntimePendingPermission,
TeamRuntimePermissionAnswerInput,
TeamRuntimePrepareResult,
TeamRuntimeReconcileInput,
TeamRuntimeReconcileResult,
@ -52,6 +56,9 @@ export interface OpenCodeTeamRuntimeBridgePort {
observeOpenCodeTeamMessageDelivery?(
input: OpenCodeObserveMessageDeliveryCommandBody
): Promise<OpenCodeObserveMessageDeliveryCommandData>;
answerOpenCodeRuntimePermission?(
input: OpenCodeAnswerPermissionCommandBody
): Promise<OpenCodeLaunchTeamCommandData>;
}
export interface OpenCodeTeamRuntimeMessageInput {
@ -199,6 +206,9 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
retryable: isRetryableReadinessState(readiness.state),
diagnostics: mergeDiagnostics(readiness.diagnostics, readiness.missing),
warnings: [],
...(readiness.supportDiagnostics?.length
? { supportDiagnostics: [...readiness.supportDiagnostics] }
: {}),
};
}
@ -208,6 +218,9 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
modelId: readiness.modelId,
diagnostics: readiness.diagnostics,
warnings: [],
...(readiness.supportDiagnostics?.length
? { supportDiagnostics: [...readiness.supportDiagnostics] }
: {}),
};
}
@ -290,6 +303,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
teamName: input.teamName,
projectPath: input.cwd,
selectedModel: model,
skipPermissions: input.skipPermissions,
members: input.expectedMembers.map((member) => ({
name: member.name,
role: member.role?.trim() || member.workflow?.trim() || 'teammate',
@ -547,6 +561,42 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
};
}
async answerRuntimePermission(
input: TeamRuntimePermissionAnswerInput
): Promise<TeamRuntimeLaunchResult> {
if (!this.bridge.answerOpenCodeRuntimePermission) {
throw new Error('OpenCode permission answer bridge is not registered.');
}
const data = await this.bridge.answerOpenCodeRuntimePermission({
runId: input.runId,
laneId: input.laneId?.trim() || 'primary',
teamId: input.teamName,
teamName: input.teamName,
projectPath: input.cwd,
memberName: input.memberName,
requestId: input.requestId,
decision: input.decision,
expectedCapabilitySnapshotId: null,
manifestHighWatermark: null,
});
return mapOpenCodeLaunchDataToRuntimeResult(
{
runId: input.runId,
teamName: input.teamName,
laneId: input.laneId,
cwd: input.cwd,
providerId: this.providerId,
skipPermissions: false,
expectedMembers: input.expectedMembers,
previousLaunchState: input.previousLaunchState,
},
data,
[]
);
}
async stop(input: TeamRuntimeStopInput): Promise<TeamRuntimeStopResult> {
if (this.bridge.stopOpenCodeTeam) {
const projectPath = input.cwd ?? this.lastProjectPathByTeamName.get(input.teamName);
@ -701,6 +751,7 @@ function mapOpenCodeLaunchDataToRuntimeResult(
bridgeMember?.model,
bridgeMember?.runtimePid,
bridgeMember?.pendingPermissionRequestIds,
bridgeMember?.pendingPermissions,
bridgeMember != null,
memberDiagnostics,
input.runId,
@ -798,6 +849,33 @@ function normalizeAppManagedBootstrapCandidate(
};
}
function normalizeOpenCodeRuntimePendingPermissions(
permissions: OpenCodeRuntimePermissionCommandData[] | undefined
): TeamRuntimePendingPermission[] | undefined {
if (!permissions?.length) {
return undefined;
}
const normalized: TeamRuntimePendingPermission[] = [];
const seen = new Set<string>();
for (const permission of permissions) {
const requestId = permission.requestId?.trim();
if (!requestId || seen.has(requestId)) {
continue;
}
seen.add(requestId);
normalized.push({
providerId: 'opencode',
requestId,
sessionId: permission.sessionId ?? null,
tool: permission.tool ?? null,
title: permission.title ?? null,
kind: permission.kind ?? null,
...(permission.raw ? { raw: permission.raw } : {}),
});
}
return normalized.length > 0 ? normalized : undefined;
}
function mapBridgeMemberToRuntimeEvidence(
memberName: string,
launchState: OpenCodeTeamMemberLaunchBridgeState,
@ -805,6 +883,7 @@ function mapBridgeMemberToRuntimeEvidence(
model: string | undefined,
runtimePid: number | undefined,
pendingPermissionRequestIds: string[] | undefined,
pendingPermissions: OpenCodeRuntimePermissionCommandData[] | undefined,
runtimeMaterialized: boolean,
diagnostics: string[],
runId: string,
@ -863,6 +942,7 @@ function mapBridgeMemberToRuntimeEvidence(
: pendingRuntimeObserved || launchState === 'permission_blocked' || runtimeMaterialized
? 'warning'
: undefined;
const normalizedPendingApprovals = normalizeOpenCodeRuntimePendingPermissions(pendingPermissions);
return {
memberName,
providerId: 'opencode',
@ -887,6 +967,8 @@ function mapBridgeMemberToRuntimeEvidence(
pendingPermissionRequestIds && pendingPermissionRequestIds.length > 0
? [...new Set(pendingPermissionRequestIds)]
: undefined,
pendingApprovals: normalizedPendingApprovals,
pendingPermissions: normalizedPendingApprovals,
sessionId,
...(appManagedCandidatePresent
? { bootstrapEvidenceSource: 'app_managed_bootstrap' as const }

View file

@ -11,6 +11,7 @@ import type {
TeamAgentRuntimeLivenessKind,
TeamAgentRuntimePidSource,
TeamLaunchAggregateState,
TeamProvisioningSupportDiagnostic,
} from '@shared/types';
export const TEAM_RUNTIME_PROVIDER_IDS = ['anthropic', 'codex', 'gemini', 'opencode'] as const;
@ -28,6 +29,33 @@ export interface TeamRuntimeMemberSpec {
cwd: string;
}
export type TeamRuntimeApprovalProviderId = 'anthropic' | 'opencode' | 'codex';
export interface TeamRuntimePendingApproval {
providerId: Extract<TeamRuntimeApprovalProviderId, 'opencode' | 'codex'>;
requestId: string;
sessionId?: string | null;
tool?: string | null;
title?: string | null;
kind?: string | null;
raw?: Record<string, unknown>;
}
export type TeamRuntimePendingPermission = TeamRuntimePendingApproval;
export interface TeamRuntimePermissionAnswerInput {
runId: string;
teamName: string;
laneId?: string;
cwd: string;
providerId: Extract<TeamRuntimeApprovalProviderId, 'opencode' | 'codex'>;
memberName: string;
requestId: string;
decision: 'allow' | 'reject';
expectedMembers: TeamRuntimeMemberSpec[];
previousLaunchState: PersistedTeamLaunchSnapshot | null;
}
export interface TeamRuntimeLaunchInput {
runId: string;
teamName: string;
@ -58,6 +86,7 @@ export interface TeamRuntimePrepareSuccess {
modelId: string | null;
diagnostics: string[];
warnings: string[];
supportDiagnostics?: TeamProvisioningSupportDiagnostic[];
}
export interface TeamRuntimePrepareFailure {
@ -67,6 +96,7 @@ export interface TeamRuntimePrepareFailure {
diagnostics: string[];
warnings: string[];
retryable: boolean;
supportDiagnostics?: TeamProvisioningSupportDiagnostic[];
}
export type TeamRuntimePrepareResult = TeamRuntimePrepareSuccess | TeamRuntimePrepareFailure;
@ -82,6 +112,8 @@ export interface TeamRuntimeMemberLaunchEvidence {
hardFailure: boolean;
hardFailureReason?: string;
pendingPermissionRequestIds?: string[];
pendingApprovals?: TeamRuntimePendingApproval[];
pendingPermissions?: TeamRuntimePendingApproval[];
sessionId?: string;
bootstrapEvidenceSource?: OpenCodeBootstrapEvidenceSource;
bootstrapMode?: OpenCodeBootstrapMode;
@ -171,6 +203,9 @@ export interface TeamLaunchRuntimeAdapter {
launch(input: TeamRuntimeLaunchInput): Promise<TeamRuntimeLaunchResult>;
reconcile(input: TeamRuntimeReconcileInput): Promise<TeamRuntimeReconcileResult>;
stop(input: TeamRuntimeStopInput): Promise<TeamRuntimeStopResult>;
answerRuntimePermission?(
input: TeamRuntimePermissionAnswerInput
): Promise<TeamRuntimeLaunchResult>;
}
export function isTeamRuntimeProviderId(value: unknown): value is TeamRuntimeProviderId {

View file

@ -6,11 +6,14 @@ export type {
export { OpenCodeTeamRuntimeAdapter } from './OpenCodeTeamRuntimeAdapter';
export type {
TeamLaunchRuntimeAdapter,
TeamRuntimeApprovalProviderId,
TeamRuntimeLaunchInput,
TeamRuntimeLaunchResult,
TeamRuntimeMemberLaunchEvidence,
TeamRuntimeMemberSpec,
TeamRuntimeMemberStopEvidence,
TeamRuntimePendingApproval,
TeamRuntimePendingPermission,
TeamRuntimePrepareFailure,
TeamRuntimePrepareResult,
TeamRuntimePrepareSuccess,

View file

@ -1082,6 +1082,7 @@ export const CreateTeamDialog = ({
status: plan.selectedModelIds.length > 0 ? plan.cachedSnapshot.status : 'checking',
backendSummary: plan.backendSummary,
details: plan.cachedSnapshot.details,
supportDiagnostics: undefined,
});
prepareWarningsByProviderIdRef.current.delete(plan.providerId);
}
@ -1150,6 +1151,7 @@ export const CreateTeamDialog = ({
status,
backendSummary: plan.backendSummary,
details,
supportDiagnostics: undefined,
}
);
commitChecks(nextChecks);
@ -1181,6 +1183,7 @@ export const CreateTeamDialog = ({
status: prepResult.status,
backendSummary: plan.backendSummary,
details: prepResult.details,
supportDiagnostics: prepResult.supportDiagnostics,
});
commitChecks(nextChecks);
applyPrepareOutcome(nextChecks, loadingMessage);
@ -1194,6 +1197,7 @@ export const CreateTeamDialog = ({
status: 'failed',
backendSummary: plan.backendSummary,
details: [failureMessage],
supportDiagnostics: undefined,
});
prepareWarningsByProviderIdRef.current.delete(plan.providerId);
commitChecks(nextChecks);

View file

@ -1674,6 +1674,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
status: plan.selectedModelIds.length > 0 ? plan.cachedSnapshot.status : 'checking',
backendSummary: plan.backendSummary,
details: plan.cachedSnapshot.details,
supportDiagnostics: undefined,
});
prepareWarningsByProviderIdRef.current.delete(plan.providerId);
}
@ -1722,6 +1723,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
status,
backendSummary: plan.backendSummary,
details,
supportDiagnostics: undefined,
});
commitChecks(nextChecks);
applyPrepareOutcome(nextChecks, loadingMessage);
@ -1752,6 +1754,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
status: prepResult.status,
backendSummary: plan.backendSummary,
details: prepResult.details,
supportDiagnostics: prepResult.supportDiagnostics,
});
commitChecks(nextChecks);
applyPrepareOutcome(nextChecks, loadingMessage);
@ -1765,6 +1768,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
status: 'failed',
backendSummary: plan.backendSummary,
details: [failureMessage],
supportDiagnostics: undefined,
});
prepareWarningsByProviderIdRef.current.delete(plan.providerId);
commitChecks(nextChecks);

View file

@ -0,0 +1,152 @@
import { OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE } from '@shared/utils/openCodeWindowsAccessDenied';
import { describe, expect, it } from 'vitest';
import { getProvisioningFailureHint } from './ProvisioningProviderStatusList';
describe('getProvisioningFailureHint', () => {
it('returns the OpenCode Windows permissions hint for OpenCode access-denied details', () => {
expect(
getProvisioningFailureHint(null, [
{
providerId: 'opencode',
status: 'failed',
details: [OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE],
},
])
).toBe(
'Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.'
);
});
it('keeps non-OpenCode access-denied details on the generic CLI hint', () => {
expect(
getProvisioningFailureHint(null, [
{
providerId: 'anthropic',
status: 'failed',
details: ['EACCES: permission denied'],
},
])
).toBe(
'Make sure the local Claude CLI binary exists and can be started, then reopen this dialog.'
);
});
it('does not treat a mixed-provider generic access-denied message as OpenCode-specific', () => {
expect(
getProvisioningFailureHint('EACCES: permission denied', [
{
providerId: 'opencode',
status: 'ready',
details: [],
},
{
providerId: 'anthropic',
status: 'failed',
details: ['EACCES: permission denied'],
},
])
).toBe(
'Make sure the local Claude CLI binary exists and can be started, then reopen this dialog.'
);
});
it('does not prefer OpenCode access-denied notes over another provider failure', () => {
expect(
getProvisioningFailureHint(null, [
{
providerId: 'opencode',
status: 'notes',
details: [OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE],
},
{
providerId: 'anthropic',
status: 'failed',
details: ['EACCES: permission denied'],
},
])
).toBe(
'Make sure the local Claude CLI binary exists and can be started, then reopen this dialog.'
);
});
it('uses a normalized OpenCode access-denied message for a failed mixed-provider check', () => {
expect(
getProvisioningFailureHint(OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE, [
{
providerId: 'opencode',
status: 'failed',
details: [],
},
{
providerId: 'anthropic',
status: 'ready',
details: [],
},
])
).toBe(
'Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.'
);
});
it('uses a raw OpenCode access-denied message when no other provider failed', () => {
expect(
getProvisioningFailureHint('EPERM: operation not permitted', [
{
providerId: 'opencode',
status: 'failed',
details: [],
},
{
providerId: 'anthropic',
status: 'ready',
details: [],
},
])
).toBe(
'Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.'
);
});
it('uses the OpenCode Windows permissions hint for a single OpenCode access-denied message', () => {
expect(
getProvisioningFailureHint('EPERM: operation not permitted', [
{
providerId: 'opencode',
status: 'failed',
details: [],
},
])
).toBe(
'Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.'
);
});
it('keeps existing OpenCode runtime missing and MCP hints unchanged', () => {
expect(
getProvisioningFailureHint(null, [
{
providerId: 'opencode',
status: 'failed',
details: [
'OpenCode runtime binary is not installed or not reachable by launch preflight.',
],
},
])
).toBe(
'Install or retry OpenCode runtime from the provider status card, then reopen this dialog.'
);
expect(
getProvisioningFailureHint(null, [
{
providerId: 'opencode',
status: 'failed',
details: ['OpenCode app MCP is unreachable. Retry launch to refresh the app MCP bridge.'],
},
])
).toBe(
'Retry launch to refresh the OpenCode app MCP bridge. If it repeats, restart the app and OpenCode runtime.'
);
});
});

View file

@ -2,9 +2,17 @@ import React from 'react';
import { formatProviderBackendLabel } from '@renderer/utils/providerBackendIdentity';
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
import { AlertTriangle, CheckCircle2, Loader2, SlidersHorizontal } from 'lucide-react';
import {
isOpenCodeWindowsAccessDeniedDiagnostic,
OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE,
} from '@shared/utils/openCodeWindowsAccessDenied';
import { AlertTriangle, Check, CheckCircle2, Copy, Loader2, SlidersHorizontal } from 'lucide-react';
import type { CliProviderStatus, TeamProviderId } from '@shared/types';
import type {
CliProviderStatus,
TeamProviderId,
TeamProvisioningSupportDiagnostic,
} from '@shared/types';
export type ProvisioningProviderCheckStatus = 'pending' | 'checking' | 'ready' | 'notes' | 'failed';
export type ProvisioningPrepareState = 'idle' | 'loading' | 'ready' | 'failed';
@ -14,6 +22,7 @@ export interface ProvisioningProviderCheck {
status: ProvisioningProviderCheckStatus;
backendSummary?: string | null;
details: string[];
supportDiagnostics?: TeamProvisioningSupportDiagnostic[];
}
export function getProvisioningProviderLabel(providerId: TeamProviderId): string {
@ -151,6 +160,8 @@ export function getProvisioningProviderProgressMessage(
type ProvisioningDetailSummary =
| 'CLI binary missing'
| 'OpenCode runtime missing'
| 'OpenCode Windows access blocked'
| 'OpenCode runtime check returned no output'
| 'OpenCode app MCP unreachable'
| 'Working directory missing'
| 'CLI binary could not be started'
@ -174,6 +185,16 @@ function isSelectedModelDetail(lower: string): boolean {
return lower.includes('selected model');
}
function isOpenCodeBridgeNoOutputDiagnostic(value: string | null | undefined): boolean {
const lower = value?.trim().toLowerCase() ?? '';
return (
lower.includes('opencode runtime check returned no output') ||
lower.includes('bridge stdout was empty') ||
lower.includes('opencode_bridge_contract_violation') ||
(lower.includes('opencode readiness bridge failed') && lower.includes('contract_violation'))
);
}
function isFormattedModelDetail(lower: string): boolean {
return (
lower.includes(' - checking...') ||
@ -219,10 +240,17 @@ function getStatusLabel(status: ProvisioningProviderCheckStatus): string {
function summarizeDetail(
detail: string,
status: ProvisioningProviderCheckStatus
status: ProvisioningProviderCheckStatus,
providerId?: TeamProviderId
): ProvisioningDetailSummary | null {
const lower = detail.toLowerCase();
if (providerId === 'opencode' && isOpenCodeWindowsAccessDeniedDiagnostic(detail)) {
return 'OpenCode Windows access blocked';
}
if (providerId === 'opencode' && isOpenCodeBridgeNoOutputDiagnostic(detail)) {
return 'OpenCode runtime check returned no output';
}
if (lower.includes('spawn ') && lower.includes(' enoent')) {
return 'CLI binary missing';
}
@ -449,13 +477,15 @@ function getDisplayStatusText(check: ProvisioningProviderCheck): string {
}
const summarizedDetails = publicDetails
.map((detail) => summarizeDetail(detail, check.status))
.map((detail) => summarizeDetail(detail, check.status, check.providerId))
.filter((detail): detail is ProvisioningDetailSummary => Boolean(detail));
const summary =
check.status === 'failed'
? (summarizedDetails.find(
(detail) =>
detail === 'OpenCode Windows access blocked' ||
detail === 'OpenCode runtime check returned no output' ||
detail === 'OpenCode app MCP unreachable' ||
detail === 'OpenCode runtime missing' ||
detail === 'Selected model unavailable' ||
@ -472,9 +502,10 @@ function getDisplayStatusText(check: ProvisioningProviderCheck): string {
function getDetailTone(
detail: string,
status: ProvisioningProviderCheckStatus
status: ProvisioningProviderCheckStatus,
providerId?: TeamProviderId
): 'success' | 'failure' | 'checking' | 'neutral' {
const summary = summarizeDetail(detail, status);
const summary = summarizeDetail(detail, status, providerId);
if (
summary === 'Selected model verified' ||
summary === 'Selected model available' ||
@ -493,6 +524,8 @@ function getDetailTone(
summary === 'Selected model check failed' ||
summary === 'CLI binary missing' ||
summary === 'OpenCode runtime missing' ||
summary === 'OpenCode Windows access blocked' ||
summary === 'OpenCode runtime check returned no output' ||
summary === 'OpenCode app MCP unreachable' ||
summary === 'Working directory missing' ||
summary === 'CLI binary could not be started' ||
@ -510,8 +543,12 @@ function getDetailTone(
return 'neutral';
}
function getDetailColorClass(detail: string, status: ProvisioningProviderCheckStatus): string {
switch (getDetailTone(detail, status)) {
function getDetailColorClass(
detail: string,
status: ProvisioningProviderCheckStatus,
providerId?: TeamProviderId
): string {
switch (getDetailTone(detail, status, providerId)) {
case 'success':
return 'text-emerald-400';
case 'failure':
@ -551,14 +588,14 @@ export function getPrimaryProvisioningFailureDetail(
const publicDetails = getPublicProvisioningDetails(check.details);
const preferredFailure = publicDetails.find(
(detail) => getDetailTone(detail, check.status) === 'failure'
(detail) => getDetailTone(detail, check.status, check.providerId) === 'failure'
);
if (preferredFailure) {
return preferredFailure;
}
const nonSuccessDetail = publicDetails.find(
(detail) => getDetailTone(detail, check.status) !== 'success'
(detail) => getDetailTone(detail, check.status, check.providerId) !== 'success'
);
if (nonSuccessDetail) {
return nonSuccessDetail;
@ -715,6 +752,16 @@ function getProvisioningProviderSettingsActionLabel(
: null;
}
function getSupportDiagnosticsPayload(check: ProvisioningProviderCheck): string | null {
if (check.providerId !== 'opencode') {
return null;
}
const payloads = (check.supportDiagnostics ?? [])
.map((diagnostic) => diagnostic.copyText.trim())
.filter(Boolean);
return payloads.length > 0 ? payloads.join('\n\n---\n\n') : null;
}
export const ProvisioningProviderStatusList = ({
checks,
className = '',
@ -726,10 +773,29 @@ export const ProvisioningProviderStatusList = ({
suppressDetailsMatching?: string | null;
onOpenProviderSettings?: (providerId: TeamProviderId) => void;
}): React.JSX.Element | null => {
const [copiedDiagnosticsKey, setCopiedDiagnosticsKey] = React.useState<string | null>(null);
if (checks.length === 0) {
return null;
}
const copySupportDiagnostics = async (copyKey: string, payload: string): Promise<void> => {
try {
const writeText = globalThis.navigator?.clipboard?.writeText;
if (typeof writeText !== 'function') {
setCopiedDiagnosticsKey(null);
return;
}
await writeText.call(globalThis.navigator.clipboard, payload);
setCopiedDiagnosticsKey(copyKey);
globalThis.setTimeout(() => {
setCopiedDiagnosticsKey((currentKey) => (currentKey === copyKey ? null : currentKey));
}, 1500);
} catch {
setCopiedDiagnosticsKey(null);
}
};
return (
<div className={`space-y-1 pl-5 ${className}`.trim()}>
{checks.map((check) => {
@ -740,6 +806,12 @@ export const ProvisioningProviderStatusList = ({
const settingsActionLabel = onOpenProviderSettings
? getProvisioningProviderSettingsActionLabel(check)
: null;
const supportDiagnosticsPayload = getSupportDiagnosticsPayload(check);
const supportDiagnosticsKey =
supportDiagnosticsPayload && check.supportDiagnostics?.[0]
? `${check.providerId}:${check.supportDiagnostics[0].id}`
: check.providerId;
const copiedDiagnostics = copiedDiagnosticsKey === supportDiagnosticsKey;
return (
<div key={check.providerId}>
@ -758,7 +830,11 @@ export const ProvisioningProviderStatusList = ({
{visibleDetails.map((detail, index) => (
<p
key={`${check.providerId}:${index}:${detail}`}
className={`text-[10px] ${getDetailColorClass(detail, check.status)}`}
className={`text-[10px] ${getDetailColorClass(
detail,
check.status,
check.providerId
)}`}
>
{detail}
</p>
@ -781,6 +857,24 @@ export const ProvisioningProviderStatusList = ({
</button>
</div>
) : null}
{supportDiagnosticsPayload ? (
<div className="mt-1 pl-4">
<button
type="button"
className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[10px] font-medium transition-colors hover:bg-white/5"
style={{
borderColor: 'var(--color-border-subtle)',
color: 'var(--color-text-secondary)',
}}
onClick={() =>
void copySupportDiagnostics(supportDiagnosticsKey, supportDiagnosticsPayload)
}
>
{copiedDiagnostics ? <Check className="size-3" /> : <Copy className="size-3" />}
{copiedDiagnostics ? 'Copied' : 'Copy diagnostics'}
</button>
</div>
) : null}
</div>
);
})}
@ -792,6 +886,34 @@ export function getProvisioningFailureHint(
message: string | null | undefined,
checks: ProvisioningProviderCheck[]
): string {
const failedOpenCodeChecks = checks.filter(
(check) => check.providerId === 'opencode' && check.status === 'failed'
);
const hasFailedNonOpenCodeCheck = checks.some(
(check) => check.providerId !== 'opencode' && check.status === 'failed'
);
const hasOpenCodeAccessDeniedDetail = failedOpenCodeChecks.some((check) =>
check.details.some(isOpenCodeWindowsAccessDeniedDiagnostic)
);
const hasOpenCodeBridgeNoOutputDetail = failedOpenCodeChecks.some((check) =>
check.details.some(isOpenCodeBridgeNoOutputDiagnostic)
);
const normalizedMessage = message?.trim() ?? '';
const hasOpenCodeAccessDeniedMessage =
failedOpenCodeChecks.length > 0 &&
(normalizedMessage === OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE ||
(!hasFailedNonOpenCodeCheck && isOpenCodeWindowsAccessDeniedDiagnostic(normalizedMessage)));
if (hasOpenCodeAccessDeniedDetail || hasOpenCodeAccessDeniedMessage) {
return 'Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.';
}
const hasOpenCodeBridgeNoOutputMessage =
failedOpenCodeChecks.length > 0 &&
!hasFailedNonOpenCodeCheck &&
isOpenCodeBridgeNoOutputDiagnostic(normalizedMessage);
if (hasOpenCodeBridgeNoOutputDetail || hasOpenCodeBridgeNoOutputMessage) {
return 'Restart the app and OpenCode runtime, then retry. If it repeats, copy diagnostics.';
}
const combined = [message ?? '', ...checks.flatMap((check) => check.details)]
.join('\n')
.toLowerCase();

View file

@ -0,0 +1,75 @@
import { OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE } from '@shared/utils/openCodeWindowsAccessDenied';
import { describe, expect, it } from 'vitest';
import { runProviderPrepareDiagnostics } from './providerPrepareDiagnostics';
import type { TeamProvisioningPrepareResult } from '@shared/types';
describe('runProviderPrepareDiagnostics', () => {
it('normalizes OpenCode access-denied provider failures', async () => {
const result = await runProviderPrepareDiagnostics({
cwd: 'C:\\Program Files\\locked-project',
providerId: 'opencode',
selectedModelIds: [],
prepareProvisioning: async (): Promise<TeamProvisioningPrepareResult> => ({
ready: false,
message: 'OpenCode bridge failed: EPERM: operation not permitted, mkdir C:\\Program Files',
}),
});
expect(result.status).toBe('failed');
expect(result.details).toEqual([OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE]);
});
it('keeps non-OpenCode access-denied provider failures generic', async () => {
const detail = 'EACCES: permission denied, open C:\\work\\repo';
const result = await runProviderPrepareDiagnostics({
cwd: 'C:\\work\\repo',
providerId: 'anthropic',
selectedModelIds: [],
prepareProvisioning: async (): Promise<TeamProvisioningPrepareResult> => ({
ready: false,
message: detail,
}),
});
expect(result.status).toBe('failed');
expect(result.details).toEqual([detail]);
});
it('normalizes OpenCode access-denied runtime note details', async () => {
const result = await runProviderPrepareDiagnostics({
cwd: 'C:\\Program Files\\locked-project',
providerId: 'opencode',
selectedModelIds: [],
prepareProvisioning: async (): Promise<TeamProvisioningPrepareResult> => ({
ready: true,
message: '',
warnings: ['EACCES: permission denied, open C:\\Program Files\\locked-project'],
}),
});
expect(result.status).toBe('notes');
expect(result.details).toEqual([OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE]);
expect(result.warnings).toEqual([OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE]);
});
it('treats model-scoped OpenCode access-denied details as provider failures', async () => {
const result = await runProviderPrepareDiagnostics({
cwd: 'C:\\Program Files\\locked-project',
providerId: 'opencode',
selectedModelIds: ['opencode/big-pickle'],
prepareProvisioning: async (): Promise<TeamProvisioningPrepareResult> => ({
ready: false,
message: 'Selected model opencode/big-pickle is unavailable.',
details: [
'Selected model opencode/big-pickle is unavailable. EPERM: operation not permitted',
],
}),
});
expect(result.status).toBe('failed');
expect(result.details).toEqual([OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE]);
expect(result.modelResultsById).toEqual({});
});
});

View file

@ -1,4 +1,8 @@
import { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog';
import {
isOpenCodeWindowsAccessDeniedDiagnostic,
normalizeOpenCodeWindowsAccessDeniedDiagnostic,
} from '@shared/utils/openCodeWindowsAccessDenied';
import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection';
import type {
@ -6,6 +10,7 @@ import type {
TeamProvisioningModelCheckRequest,
TeamProvisioningModelVerificationMode,
TeamProvisioningPrepareResult,
TeamProvisioningSupportDiagnostic,
} from '@shared/types';
export type ProviderPrepareCheckStatus = 'ready' | 'notes' | 'failed';
@ -45,6 +50,7 @@ export interface ProviderPrepareDiagnosticsResult {
details: string[];
warnings: string[];
modelResultsById: Record<string, ProviderPrepareDiagnosticsModelResult>;
supportDiagnostics?: TeamProvisioningSupportDiagnostic[];
}
type TeamProvisioningPrepareIssue = NonNullable<TeamProvisioningPrepareResult['issues']>[number];
@ -93,6 +99,45 @@ function uniquePrepareLines(lines: (string | null | undefined)[]): string[] {
return uniqueLines;
}
function isOpenCodeBridgeNoOutputDiagnostic(value: string | null | undefined): boolean {
const lower = value?.trim().toLowerCase() ?? '';
return (
lower.includes('opencode runtime check returned no output') ||
lower.includes('bridge stdout was empty') ||
lower.includes('opencode_bridge_contract_violation') ||
(lower.includes('opencode readiness bridge failed') && lower.includes('contract_violation'))
);
}
function cloneSupportDiagnostics(
diagnostics: readonly TeamProvisioningSupportDiagnostic[] | undefined
): TeamProvisioningSupportDiagnostic[] {
return (diagnostics ?? []).map((diagnostic) => ({ ...diagnostic }));
}
function mergeSupportDiagnostics(
target: TeamProvisioningSupportDiagnostic[],
incoming: readonly TeamProvisioningSupportDiagnostic[] | undefined
): void {
for (const diagnostic of incoming ?? []) {
if (!target.some((existing) => existing.id === diagnostic.id)) {
target.push({ ...diagnostic });
}
}
}
function withSupportDiagnostics(
result: ProviderPrepareDiagnosticsResult,
supportDiagnostics: readonly TeamProvisioningSupportDiagnostic[]
): ProviderPrepareDiagnosticsResult {
return supportDiagnostics.length > 0
? {
...result,
supportDiagnostics: cloneSupportDiagnostics(supportDiagnostics),
}
: result;
}
function getModelLabel(providerId: TeamProviderId, modelId: string): string {
if (isDefaultProviderModelSelection(modelId)) {
return 'Default';
@ -287,8 +332,12 @@ function looksLikeOpenCodeRuntimeFailureReason(reason: string | null | undefined
}
return (
isOpenCodeBridgeNoOutputDiagnostic(reason) ||
isOpenCodeWindowsAccessDeniedDiagnostic(reason) ||
lower.includes('opencode /experimental/tool') ||
lower.includes('/experimental/tool') ||
lower.includes('opencode_bridge_contract_violation') ||
lower.includes('bridge stdout was empty') ||
lower.includes('mcp_unavailable') ||
lower.includes('unable to connect') ||
lower.includes('runtime store') ||
@ -339,7 +388,9 @@ function isAdvisoryOpenCodeDeepVerificationIssue(
lower.includes('/experimental/tool') ||
lower.includes('runtime store') ||
lower.includes('opencode cli') ||
lower.includes('opencode runtime binary');
lower.includes('opencode runtime binary') ||
isOpenCodeBridgeNoOutputDiagnostic(lower) ||
isOpenCodeWindowsAccessDeniedDiagnostic(lower);
if (hasHardRuntimeMarker) {
return false;
}
@ -476,27 +527,48 @@ function buildModelVerificationDeferredLine(
: `${label} - verification deferred`;
}
function createRuntimeDetailLines(result: TeamProvisioningPrepareResult): string[] {
return uniquePrepareLines([...(result.details ?? []), ...(result.warnings ?? [])]);
function createRuntimeDetailLines(
result: TeamProvisioningPrepareResult,
providerId: TeamProviderId
): string[] {
return uniquePrepareLines(
[...(result.details ?? []), ...(result.warnings ?? [])]
.map((detail) => normalizeRuntimeDetailLine(detail, providerId))
.filter(Boolean)
);
}
function createRuntimeWarningLines(result: TeamProvisioningPrepareResult): string[] {
function createRuntimeWarningLines(
result: TeamProvisioningPrepareResult,
providerId: TeamProviderId
): string[] {
return uniquePrepareLines(
(result.warnings ?? [])
.map((warning) => normalizeRuntimeFailureDetailLine(warning))
.map((warning) => normalizeRuntimeFailureDetailLine(warning, undefined, providerId))
.filter(Boolean)
);
}
function normalizeRuntimeFailureDetailLine(
detail: string | null | undefined,
code?: string | null
code?: string | null,
providerId?: TeamProviderId
): string | null {
const trimmed = detail?.trim();
if (!trimmed) {
return null;
}
if (providerId === 'opencode') {
if (isOpenCodeBridgeNoOutputDiagnostic(trimmed)) {
return 'OpenCode runtime check returned no output.';
}
const accessDeniedDiagnostic = normalizeOpenCodeWindowsAccessDeniedDiagnostic(trimmed);
if (accessDeniedDiagnostic) {
return accessDeniedDiagnostic;
}
}
if (/opencode cli (?:not detected on path|not found)/i.test(trimmed)) {
return 'OpenCode runtime binary is not installed or not reachable by launch preflight.';
}
@ -518,13 +590,33 @@ function normalizeRuntimeFailureDetailLine(
return trimmed;
}
function normalizeRuntimeDetailLine(
detail: string | null | undefined,
providerId: TeamProviderId
): string | null {
const trimmed = detail?.trim();
if (!trimmed) {
return null;
}
if (providerId !== 'opencode') {
return trimmed;
}
if (isOpenCodeBridgeNoOutputDiagnostic(trimmed)) {
return 'OpenCode runtime check returned no output.';
}
return normalizeOpenCodeWindowsAccessDeniedDiagnostic(trimmed) ?? trimmed;
}
function createRuntimeFailureDetailLines(
runtimeDetailLines: readonly string[],
message: string | null | undefined
message: string | null | undefined,
providerId: TeamProviderId
): string[] {
return uniquePrepareLines(
[...runtimeDetailLines, message]
.map((detail) => normalizeRuntimeFailureDetailLine(detail))
.map((detail) => normalizeRuntimeFailureDetailLine(detail, undefined, providerId))
.filter(Boolean)
);
}
@ -957,6 +1049,7 @@ export async function runProviderPrepareDiagnostics({
);
const hasExplicitModelChecks = (selectedModelChecks?.length ?? 0) > 0;
const orderedModelIds = Array.from(new Set(normalizedModelChecks.map((check) => check.model)));
const supportDiagnostics: TeamProvisioningSupportDiagnostic[] = [];
if (orderedModelIds.length === 0) {
const runtimeResult = await prepareProvisioning(
cwd,
@ -965,24 +1058,35 @@ export async function runProviderPrepareDiagnostics({
undefined,
limitContext
);
const runtimeDetailLines = createRuntimeDetailLines(runtimeResult);
const runtimeWarnings = createRuntimeWarningLines(runtimeResult);
mergeSupportDiagnostics(supportDiagnostics, runtimeResult.supportDiagnostics);
const runtimeDetailLines = createRuntimeDetailLines(runtimeResult, providerId);
const runtimeWarnings = createRuntimeWarningLines(runtimeResult, providerId);
if (!runtimeResult.ready) {
return {
status: 'failed',
details: createRuntimeFailureDetailLines(runtimeDetailLines, runtimeResult.message),
warnings: runtimeWarnings,
modelResultsById: {},
};
return withSupportDiagnostics(
{
status: 'failed',
details: createRuntimeFailureDetailLines(
runtimeDetailLines,
runtimeResult.message,
providerId
),
warnings: runtimeWarnings,
modelResultsById: {},
},
supportDiagnostics
);
}
return {
status: runtimeWarnings.length > 0 ? 'notes' : 'ready',
details: runtimeDetailLines,
warnings: runtimeWarnings,
modelResultsById: {},
};
return withSupportDiagnostics(
{
status: runtimeWarnings.length > 0 ? 'notes' : 'ready',
details: runtimeDetailLines,
warnings: runtimeWarnings,
modelResultsById: {},
},
supportDiagnostics
);
}
const reusableModelResultsById = cachedModelResultsById ?? {};
@ -1047,16 +1151,24 @@ export async function runProviderPrepareDiagnostics({
undefined,
limitContext
);
runtimeDetailLines = createRuntimeDetailLines(runtimeResult);
runtimeWarnings = createRuntimeWarningLines(runtimeResult);
mergeSupportDiagnostics(supportDiagnostics, runtimeResult.supportDiagnostics);
runtimeDetailLines = createRuntimeDetailLines(runtimeResult, providerId);
runtimeWarnings = createRuntimeWarningLines(runtimeResult, providerId);
if (!runtimeResult.ready) {
return {
status: 'failed',
details: createRuntimeFailureDetailLines(runtimeDetailLines, runtimeResult.message),
warnings: runtimeWarnings,
modelResultsById: {},
};
return withSupportDiagnostics(
{
status: 'failed',
details: createRuntimeFailureDetailLines(
runtimeDetailLines,
runtimeResult.message,
providerId
),
warnings: runtimeWarnings,
modelResultsById: {},
},
supportDiagnostics
);
}
} else {
const recordTerminalModelResult = (
@ -1090,10 +1202,11 @@ export async function runProviderPrepareDiagnostics({
? [selectModelChecksForIds(normalizedModelChecks, uncachedModelIds)]
: [])
);
runtimeDetailLines = createRuntimeDetailLines(compatibilityResult).filter(
mergeSupportDiagnostics(supportDiagnostics, compatibilityResult.supportDiagnostics);
runtimeDetailLines = createRuntimeDetailLines(compatibilityResult, providerId).filter(
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
);
runtimeWarnings = createRuntimeWarningLines(compatibilityResult).filter(
runtimeWarnings = createRuntimeWarningLines(compatibilityResult, providerId).filter(
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
);
@ -1116,17 +1229,21 @@ export async function runProviderPrepareDiagnostics({
const structuredProviderScopedFailure =
structuredProviderScopedIssue?.message.trim() ?? null;
if (structuredProviderScopedFailure || providerScopedFailure) {
return {
status: 'failed',
details: [
normalizeRuntimeFailureDetailLine(
structuredProviderScopedFailure ?? providerScopedFailure ?? 'OpenCode failed',
structuredProviderScopedIssue?.code
) ?? 'OpenCode failed',
],
warnings: [],
modelResultsById: {},
};
return withSupportDiagnostics(
{
status: 'failed',
details: [
normalizeRuntimeFailureDetailLine(
structuredProviderScopedFailure ?? providerScopedFailure ?? 'OpenCode failed',
structuredProviderScopedIssue?.code,
providerId
) ?? 'OpenCode failed',
],
warnings: [],
modelResultsById: {},
},
supportDiagnostics
);
}
if (
shouldSurfaceProviderRuntimeFailureInsteadOfModelFailure({
@ -1141,15 +1258,19 @@ export async function runProviderPrepareDiagnostics({
(uncachedModelIds.length > 1 ||
(!hasNonModelScopedDiagnostics && !hasSingleModelFallbackReason)))
) {
return {
status: 'failed',
details: createRuntimeFailureDetailLines(
runtimeDetailLines,
compatibilityResult.message
),
warnings: runtimeWarnings,
modelResultsById: {},
};
return withSupportDiagnostics(
{
status: 'failed',
details: createRuntimeFailureDetailLines(
runtimeDetailLines,
compatibilityResult.message,
providerId
),
warnings: runtimeWarnings,
modelResultsById: {},
},
supportDiagnostics
);
}
if (!hasModelScopedEntries && uncachedModelIds.length === 1) {
runtimeDetailLines = [];
@ -1204,19 +1325,22 @@ export async function runProviderPrepareDiagnostics({
)
);
return {
status: hasFailure
? 'failed'
: hasNotes || dedupedWarnings.length > 0
? 'notes'
: 'ready',
details: [
...filteredRuntime.runtimeDetailLines,
...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''),
],
warnings: dedupedWarnings,
modelResultsById: selectedModelResultsById,
};
return withSupportDiagnostics(
{
status: hasFailure
? 'failed'
: hasNotes || dedupedWarnings.length > 0
? 'notes'
: 'ready',
details: [
...filteredRuntime.runtimeDetailLines,
...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''),
],
warnings: dedupedWarnings,
modelResultsById: selectedModelResultsById,
},
supportDiagnostics
);
}
try {
@ -1231,10 +1355,11 @@ export async function runProviderPrepareDiagnostics({
? [selectModelChecksForIds(normalizedModelChecks, compatibilityPassedModelIds)]
: [])
);
runtimeDetailLines = createRuntimeDetailLines(batchedModelResult).filter(
mergeSupportDiagnostics(supportDiagnostics, batchedModelResult.supportDiagnostics);
runtimeDetailLines = createRuntimeDetailLines(batchedModelResult, providerId).filter(
(entry) => !isModelScopedEntryForAnyModel(compatibilityPassedModelIds, entry)
);
runtimeWarnings = createRuntimeWarningLines(batchedModelResult).filter(
runtimeWarnings = createRuntimeWarningLines(batchedModelResult, providerId).filter(
(entry) => !isModelScopedEntryForAnyModel(compatibilityPassedModelIds, entry)
);
@ -1278,17 +1403,21 @@ export async function runProviderPrepareDiagnostics({
}
handledAdvisoryDeepFailure = true;
} else {
return {
status: 'failed',
details: [
normalizeRuntimeFailureDetailLine(
failureReason,
structuredProviderScopedIssue?.code
) ?? failureReason,
],
warnings: [],
modelResultsById: {},
};
return withSupportDiagnostics(
{
status: 'failed',
details: [
normalizeRuntimeFailureDetailLine(
failureReason,
structuredProviderScopedIssue?.code,
providerId
) ?? failureReason,
],
warnings: [],
modelResultsById: {},
},
supportDiagnostics
);
}
}
if (
@ -1323,15 +1452,19 @@ export async function runProviderPrepareDiagnostics({
(compatibilityPassedModelIds.length > 1 ||
(!hasNonModelScopedDiagnostics && !hasSingleModelFallbackReason))))
) {
return {
status: 'failed',
details: createRuntimeFailureDetailLines(
runtimeDetailLines,
batchedModelResult.message
),
warnings: runtimeWarnings,
modelResultsById: {},
};
return withSupportDiagnostics(
{
status: 'failed',
details: createRuntimeFailureDetailLines(
runtimeDetailLines,
batchedModelResult.message,
providerId
),
warnings: runtimeWarnings,
modelResultsById: {},
},
supportDiagnostics
);
}
if (
!handledAdvisoryDeepFailure &&
@ -1385,10 +1518,10 @@ export async function runProviderPrepareDiagnostics({
? [selectModelChecksForIds(normalizedModelChecks, uncachedModelIds)]
: [])
);
runtimeDetailLines = createRuntimeDetailLines(compatibilityResult).filter(
runtimeDetailLines = createRuntimeDetailLines(compatibilityResult, providerId).filter(
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
);
runtimeWarnings = createRuntimeWarningLines(compatibilityResult).filter(
runtimeWarnings = createRuntimeWarningLines(compatibilityResult, providerId).filter(
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
);
@ -1410,7 +1543,8 @@ export async function runProviderPrepareDiagnostics({
status: 'failed',
details: createRuntimeFailureDetailLines(
runtimeDetailLines,
compatibilityResult.message
compatibilityResult.message,
providerId
),
warnings: runtimeWarnings,
modelResultsById: {},
@ -1445,10 +1579,10 @@ export async function runProviderPrepareDiagnostics({
limitContext,
'deep'
);
runtimeDetailLines = createRuntimeDetailLines(deepResult).filter(
runtimeDetailLines = createRuntimeDetailLines(deepResult, providerId).filter(
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
);
runtimeWarnings = createRuntimeWarningLines(deepResult).filter(
runtimeWarnings = createRuntimeWarningLines(deepResult, providerId).filter(
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
);
if (
@ -1502,13 +1636,16 @@ export async function runProviderPrepareDiagnostics({
)
);
return {
status: hasFailure ? 'failed' : hasNotes || dedupedWarnings.length > 0 ? 'notes' : 'ready',
details: [
...filteredRuntime.runtimeDetailLines,
...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''),
],
warnings: dedupedWarnings,
modelResultsById: selectedModelResultsById,
};
return withSupportDiagnostics(
{
status: hasFailure ? 'failed' : hasNotes || dedupedWarnings.length > 0 ? 'notes' : 'ready',
details: [
...filteredRuntime.runtimeDetailLines,
...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''),
],
warnings: dedupedWarnings,
modelResultsById: selectedModelResultsById,
},
supportDiagnostics
);
}

View file

@ -1500,12 +1500,24 @@ export interface TeamProvisioningPrepareIssue {
message: string;
}
export interface TeamProvisioningSupportDiagnostic {
id: string;
providerId: TeamProviderId;
kind: string;
severity: 'info' | 'warning' | 'error';
title: string;
summary: string;
copyText: string;
createdAt: string;
}
export interface TeamProvisioningPrepareResult {
ready: boolean;
message: string;
details?: string[];
warnings?: string[];
issues?: TeamProvisioningPrepareIssue[];
supportDiagnostics?: TeamProvisioningSupportDiagnostic[];
}
export interface TeamProvisioningProgress {
@ -1771,6 +1783,8 @@ export interface ToolApprovalRequest {
/** Run ID — prevents stale approvals after stop→launch race. */
runId: string;
teamName: string;
/** Runtime/provider that owns the approval, when it is not the Anthropic CLI control protocol. */
providerId?: TeamProviderId;
/** Which process sent this (e.g. 'lead'). */
source: string;
/** Tool name: 'Bash', 'Edit', 'Write', 'Read', etc. */
@ -1783,6 +1797,14 @@ export interface ToolApprovalRequest {
teamColor?: string;
/** Team display name (from config or create request). */
teamDisplayName?: string;
/** Provider runtime permission metadata used to answer non-Anthropic approval APIs. */
runtimePermission?: {
providerId: 'anthropic' | 'opencode' | 'codex';
laneId: string;
memberName: string;
providerRequestId: string;
sessionId?: string | null;
};
/** Permission suggestions from teammate runtime (only for teammate permission_request).
* FACT: Populated by Claude Code runtime, contains instructions to add permission rules.
*/
@ -1837,7 +1859,7 @@ export interface ToolApprovalAutoResolved {
requestId: string;
runId: string;
teamName: string;
reason: 'auto_allow_category' | 'timeout_allow' | 'timeout_deny';
reason: 'auto_allow_category' | 'timeout_allow' | 'timeout_deny' | 'runtime_resolved';
}
/** Union of approval events pushed from main to renderer. */

View file

@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import {
isOpenCodeWindowsAccessDeniedDiagnostic,
normalizeOpenCodeWindowsAccessDeniedDiagnostic,
OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE,
} from '../openCodeWindowsAccessDenied';
describe('OpenCode Windows access-denied diagnostics', () => {
it.each([
'EPERM: operation not permitted, mkdir C:\\Program Files\\project',
'EACCES: permission denied, open C:\\work\\repo',
'Access is denied.',
'permission denied while opening OpenCode runtime file',
'operation not permitted while starting OpenCode',
])('detects %s', (message) => {
expect(isOpenCodeWindowsAccessDeniedDiagnostic(message)).toBe(true);
expect(normalizeOpenCodeWindowsAccessDeniedDiagnostic(message)).toBe(
OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE
);
});
it('does not match unrelated OpenCode diagnostics', () => {
expect(isOpenCodeWindowsAccessDeniedDiagnostic('OpenCode app MCP is unreachable')).toBe(false);
expect(normalizeOpenCodeWindowsAccessDeniedDiagnostic('OpenCode CLI not found')).toBeNull();
});
});

View file

@ -0,0 +1,24 @@
export const OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE =
'Windows blocked OpenCode from accessing project or runtime files. Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.';
const OPENCODE_WINDOWS_ACCESS_DENIED_PATTERN =
/\b(?:EPERM|EACCES)\b|access is denied|permission denied|operation not permitted/i;
export function isOpenCodeWindowsAccessDeniedDiagnostic(value: string | null | undefined): boolean {
const trimmed = value?.trim();
if (!trimmed) {
return false;
}
return (
trimmed === OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE ||
OPENCODE_WINDOWS_ACCESS_DENIED_PATTERN.test(trimmed)
);
}
export function normalizeOpenCodeWindowsAccessDeniedDiagnostic(
value: string | null | undefined
): string | null {
return isOpenCodeWindowsAccessDeniedDiagnostic(value)
? OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE
: null;
}

View file

@ -1698,8 +1698,7 @@ describe('ClaudeMultimodelBridgeService', () => {
plugins: {
status: 'unsupported',
ownership: 'shared',
reason:
'Plugins are not currently guaranteed for codex-native sessions in the multimodel runtime.',
reason: 'Plugin support is not yet guaranteed for this agent.',
},
mcp: {
status: 'unsupported',

View file

@ -1,18 +1,18 @@
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,
redactBridgeDiagnosticText,
resolveOpenCodeBridgeProcessCwd,
type OpenCodeBridgeDiagnosticsSink,
type OpenCodeBridgeProcessRunInput,
type OpenCodeBridgeProcessRunResult,
type OpenCodeBridgeProcessRunner,
type OpenCodeBridgeProcessRunResult,
redactBridgeDiagnosticText,
resolveOpenCodeBridgeProcessCwd,
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient';
import type {
OpenCodeBridgeDiagnosticEvent,
OpenCodeBridgeSuccess,
@ -188,6 +188,34 @@ describe('OpenCodeBridgeCommandClient', () => {
});
});
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`,
@ -258,6 +286,50 @@ describe('OpenCodeBridgeCommandClient', () => {
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 = [
{
@ -289,9 +361,30 @@ describe('OpenCodeBridgeCommandClient', () => {
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 () => {

View file

@ -9,15 +9,15 @@ import {
OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION,
OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION,
OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
parseSingleBridgeJsonResult,
stableHash,
validateBridgeResultEnvelope,
validateOpenCodeBridgeHandshake,
type OpenCodeBridgeCommandEnvelope,
type OpenCodeBridgeHandshake,
type OpenCodeBridgePeerIdentity,
type OpenCodeBridgeRuntimeSnapshot,
type OpenCodeBridgeSuccess,
parseSingleBridgeJsonResult,
stableHash,
validateBridgeResultEnvelope,
validateOpenCodeBridgeHandshake,
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract';
describe('OpenCodeBridgeCommandContract', () => {
@ -135,6 +135,51 @@ describe('OpenCodeBridgeCommandContract', () => {
).toThrow('OpenCode bridge capability snapshot mismatch');
});
it('allows state mutation for a launch capability recovery result', () => {
const result = bridgeSuccess({
runtime: { capabilitySnapshotId: 'new-snapshot' },
data: {
runId: 'run-1',
idempotencyKey: 'key-1',
runtimeStoreManifestHighWatermark: 10,
diagnostics: [
{
code: 'opencode_capability_snapshot_recovery',
severity: 'warning',
message: 'Accepted fresh OpenCode capability snapshot after app recovery attempt.',
},
],
},
});
expect(() =>
assertBridgeResultCanMutateState(result, {
requestId: 'req-1',
command: 'opencode.launchTeam',
runId: 'run-1',
capabilitySnapshotId: 'old-snapshot',
allowCapabilitySnapshotRecovery: true,
})
).not.toThrow();
});
it('does not allow capability recovery without orchestrator recovery evidence', () => {
const result = bridgeSuccess({
runtime: { capabilitySnapshotId: 'new-snapshot' },
data: { runId: 'run-1' },
});
expect(() =>
assertBridgeResultCanMutateState(result, {
requestId: 'req-1',
command: 'opencode.launchTeam',
runId: 'run-1',
capabilitySnapshotId: 'old-snapshot',
allowCapabilitySnapshotRecovery: true,
})
).toThrow('OpenCode bridge capability snapshot mismatch');
});
it('allows state mutation when caller has no capability snapshot evidence to compare', () => {
const result = bridgeSuccess({
runtime: { capabilitySnapshotId: 'runtime-snapshot' },

View file

@ -0,0 +1,79 @@
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 { OpenCodeBridgeDiagnosticsStore } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeDiagnosticsStore';
let tempDir: string;
describe('OpenCodeBridgeDiagnosticsStore', () => {
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-bridge-diagnostics-'));
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
it('persists capped redacted bridge diagnostic metadata', async () => {
const store = new OpenCodeBridgeDiagnosticsStore({
directory: tempDir,
maxEventsBytes: 512,
});
await store.append({
id: 'diag-1',
type: 'opencode_bridge_contract_violation',
providerId: 'opencode',
severity: 'error',
message: 'Bridge stdout was empty',
data: {
stderrPreview: 'token=secret Authorization: Bearer live-token',
stdout: 'raw stdout should not be stored',
inputPreview: 'x'.repeat(5_000),
},
createdAt: '2026-04-21T12:00:00.000Z',
});
const latest = await fs.readFile(path.join(tempDir, 'latest.json'), 'utf8');
const events = await fs.readFile(path.join(tempDir, 'events.ndjson'), 'utf8');
expect(latest).toContain('token=[redacted]');
expect(latest).toContain('Authorization: Bearer [redacted]');
expect(latest).toContain('[truncated]');
expect(events).toContain('opencode_bridge_contract_violation');
expect(latest).not.toContain('secret');
expect(events).not.toContain('live-token');
expect(latest).toContain('"stdout": "[omitted]"');
expect(latest).not.toContain('raw stdout should not be stored');
});
it('rotates events as complete ndjson lines', async () => {
const store = new OpenCodeBridgeDiagnosticsStore({
directory: tempDir,
maxEventsBytes: 120,
});
for (let index = 0; index < 4; index += 1) {
await store.append({
id: `diag-${index}`,
type: 'opencode_bridge_contract_violation',
providerId: 'opencode',
severity: 'error',
message: `Bridge stdout was empty ${index}`,
data: { index },
createdAt: '2026-04-21T12:00:00.000Z',
});
}
const lines = (await fs.readFile(path.join(tempDir, 'events.ndjson'), 'utf8'))
.split('\n')
.filter(Boolean);
expect(lines.some((line) => line.includes('opencode_bridge_diagnostics_truncated'))).toBe(
true
);
expect(() => lines.map((line) => JSON.parse(line))).not.toThrow();
});
});

View file

@ -77,6 +77,7 @@ liveDescribe('OpenCode mixed recovery live e2e', () => {
PATH: withBunOnPath(process.env.PATH ?? ''),
XDG_DATA_HOME: path.join(tempDir, 'xdg-data-single'),
AGENT_TEAMS_MCP_CLAUDE_DIR: tempClaudeRoot,
...mcpLaunchSpec.env,
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),
@ -189,6 +190,7 @@ liveDescribe('OpenCode mixed recovery live e2e', () => {
PATH: withBunOnPath(process.env.PATH ?? ''),
XDG_DATA_HOME: path.join(tempDir, 'xdg-data-multi'),
AGENT_TEAMS_MCP_CLAUDE_DIR: tempClaudeRoot,
...mcpLaunchSpec.env,
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),

View file

@ -8,15 +8,15 @@ import {
REQUIRED_AGENT_TEAMS_APP_TOOL_IDS,
} from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability';
import type { OpenCodeTeamLaunchReadiness } from '../../../../src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness';
import type {
OpenCodeBridgeFailureKind,
OpenCodeBridgeCommandName,
OpenCodeBridgeFailureKind,
OpenCodeBridgeResult,
OpenCodeBridgeSuccess,
OpenCodeLaunchTeamCommandData,
OpenCodeSendMessageCommandData,
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract';
import type { OpenCodeTeamLaunchReadiness } from '../../../../src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness';
describe('OpenCodeReadinessBridge', () => {
it('executes the read-only opencode.readiness command and returns readiness data', async () => {
@ -86,6 +86,70 @@ describe('OpenCodeReadinessBridge', () => {
expect(bridge.getLastOpenCodeRuntimeSnapshot('/repo')).toBeNull();
});
it('adds copyable support diagnostics for bridge no-output contract failures', async () => {
const executor = fakeExecutor(
bridgeFailure(
'contract_violation',
'Bridge stdout was empty',
[
{
id: 'diag-empty-stdout',
type: 'opencode_bridge_contract_violation',
providerId: 'opencode',
severity: 'error',
message: 'Bridge stdout was empty',
data: {
command: 'opencode.readiness',
requestId: 'req-1',
attempts: 2,
exitCode: 0,
timedOut: false,
stdoutBytes: 0,
stderrBytes: 27,
outputSource: 'none',
outputFileBytes: 0,
outputReadError: 'ENOENT',
stderrPreview: 'token=secret',
},
createdAt: '2026-04-21T12:00:00.000Z',
},
],
{
attempts: 2,
outputReadError: 'ENOENT',
}
)
);
const bridge = new OpenCodeReadinessBridge(executor, {
appVersion: '1.3.0-test',
});
const result = await bridge.checkOpenCodeTeamLaunchReadiness({
projectPath: 'D:\\project\\03_codex',
selectedModel: 'qwen3.6-2b',
requireExecutionProbe: false,
});
expect(result.supportDiagnostics).toEqual([
expect.objectContaining({
id: 'diag-empty-stdout',
providerId: 'opencode',
kind: 'opencode_bridge_no_output',
severity: 'error',
title: 'OpenCode runtime check returned no output',
summary: 'OpenCode readiness bridge exited without returning diagnostic JSON.',
}),
]);
expect(result.supportDiagnostics?.[0]?.copyText).toContain(
'Agent Teams OpenCode diagnostics'
);
expect(result.supportDiagnostics?.[0]?.copyText).toContain('outputReadError: ENOENT');
expect(result.supportDiagnostics?.[0]?.copyText).toContain('appVersion: 1.3.0-test');
expect(result.supportDiagnostics?.[0]?.copyText).toContain('selectedModel: qwen3.6-2b');
expect(result.supportDiagnostics?.[0]?.copyText).toContain('token=[redacted]');
expect(result.supportDiagnostics?.[0]?.copyText).not.toContain('token=secret');
});
it('executes host cleanup through the direct bridge command', async () => {
const executor = fakeExecutor(
bridgeCommandSuccess({
@ -961,6 +1025,70 @@ describe('OpenCodeReadinessBridge', () => {
);
expect(executor.execute).not.toHaveBeenCalled();
});
it('routes OpenCode permission answers through the guarded command service', async () => {
const executor = fakeExecutor(
bridgeFailure('internal_error', 'direct bridge must not run', [])
);
const stateChangingExecute = vi.fn();
const stateChangingCommands = {
async execute<TBody, TData>(input: {
command: OpenCodeBridgeCommandName;
body: TBody;
teamName: string;
laneId?: string | null;
runId: string | null;
}): Promise<OpenCodeBridgeResult<TData>> {
stateChangingExecute(input);
return bridgeCommandSuccess<OpenCodeLaunchTeamCommandData>({
command: input.command,
requestId: 'guarded-permission-req-1',
data: {
runId: 'run-1',
teamLaunchState: 'ready',
members: {},
warnings: [],
diagnostics: [],
},
}) as unknown as OpenCodeBridgeResult<TData>;
},
};
const bridge = new OpenCodeReadinessBridge(executor, { stateChangingCommands });
await expect(
bridge.answerOpenCodeRuntimePermission({
runId: 'run-1',
laneId: 'primary',
teamId: 'team-a',
teamName: 'team-a',
projectPath: '/repo',
memberName: 'alice',
requestId: 'perm-1',
decision: 'allow',
expectedCapabilitySnapshotId: null,
manifestHighWatermark: null,
})
).resolves.toMatchObject({
runId: 'run-1',
teamLaunchState: 'ready',
});
expect(stateChangingExecute).toHaveBeenCalledWith(
expect.objectContaining({
command: 'opencode.answerPermission',
teamName: 'team-a',
laneId: 'primary',
runId: 'run-1',
capabilitySnapshotId: null,
cwd: '/repo',
body: expect.objectContaining({
requestId: 'perm-1',
decision: 'allow',
}),
})
);
expect(executor.execute).not.toHaveBeenCalled();
});
});
function fakeExecutor(
@ -1014,7 +1142,8 @@ function bridgeSuccess(
function bridgeFailure(
kind: OpenCodeBridgeFailureKind,
message: string,
diagnostics: OpenCodeBridgeResult<unknown>['diagnostics']
diagnostics: OpenCodeBridgeResult<unknown>['diagnostics'],
details?: Record<string, unknown>
): OpenCodeBridgeResult<unknown> {
return {
ok: false,
@ -1027,6 +1156,7 @@ function bridgeFailure(
kind,
message,
retryable: true,
...(details ? { details } : {}),
},
diagnostics,
};

View file

@ -1,13 +1,12 @@
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 {
createOpenCodeBridgeHandshakeIdentityHash,
OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION,
OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION,
createOpenCodeBridgeHandshakeIdentityHash,
type OpenCodeBridgeCommandName,
type OpenCodeBridgeHandshake,
type OpenCodeBridgePeerIdentity,
@ -18,13 +17,13 @@ import {
import {
createOpenCodeBridgeCommandLeaseStore,
createOpenCodeBridgeCommandLedgerStore,
type OpenCodeBridgeCommandLedger,
type OpenCodeBridgeCommandLeaseStore,
type OpenCodeBridgeCommandLedger,
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore';
import {
OpenCodeStateChangingBridgeCommandService,
type OpenCodeBridgeCommandExecutor,
type OpenCodeBridgeHandshakePort,
OpenCodeStateChangingBridgeCommandService,
type OpenCodeStateChangingBridgeDiagnosticsSink,
type RuntimeStoreManifestReader,
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
@ -381,6 +380,48 @@ describe('OpenCodeStateChangingBridgeCommandService', () => {
await expect(leaseStore.getActive('team-a')).resolves.toBeNull();
});
it('commits a launch result when recovery accepted a newer capability snapshot', async () => {
bridge.resultFactory = ({ body, command, options }) =>
bridgeSuccess({
requestId: options.requestId,
command,
runtime: {
providerId: 'opencode',
binaryPath: '/usr/local/bin/opencode',
binaryFingerprint: 'bin-1',
version: '1.0.0',
capabilitySnapshotId: 'cap-2',
},
data: {
runId: 'run-1',
idempotencyKey: body.preconditions.idempotencyKey,
runtimeStoreManifestHighWatermark: 10,
diagnostics: [
{
code: 'opencode_capability_snapshot_recovery',
severity: 'warning',
message: 'Accepted fresh OpenCode capability snapshot after app recovery attempt.',
},
],
},
});
const service = createService();
const result = await service.execute({
...buildLaunchInput(),
body: {
prompt: 'launch',
capabilitySnapshotRecoveryAttemptId: 'opencode-capability-recovery-test',
},
});
expect(result.ok).toBe(true);
const idempotencyKey = bridge.calls[0].body.preconditions.idempotencyKey;
await expect(ledger.getByIdempotencyKey(idempotencyKey)).resolves.toMatchObject({
status: 'completed',
});
});
function createService(
overrides: {
leaseAcquireTimeoutMs?: number;

View file

@ -69,6 +69,7 @@ liveDescribe('OpenCode team provisioning live e2e', () => {
PATH: withBunOnPath(process.env.PATH ?? ''),
XDG_DATA_HOME: path.join(tempDir, 'xdg-data'),
AGENT_TEAMS_MCP_CLAUDE_DIR: tempClaudeRoot,
...mcpLaunchSpec.env,
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),
@ -227,6 +228,7 @@ liveDescribe('OpenCode team provisioning live e2e', () => {
PATH: withBunOnPath(process.env.PATH ?? ''),
XDG_DATA_HOME: path.join(tempDir, 'xdg-data-default-model'),
AGENT_TEAMS_MCP_CLAUDE_DIR: tempClaudeRoot,
...mcpLaunchSpec.env,
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),

View file

@ -141,6 +141,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
expect(launchOpenCodeTeam).toHaveBeenCalledWith(
expect.objectContaining({
selectedModel: 'openai/gpt-5.4-mini',
skipPermissions: true,
expectedCapabilitySnapshotId: null,
})
);
@ -376,6 +377,32 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
);
});
it('passes manual tool approval intent with a fresh capability precondition', async () => {
const launchOpenCodeTeam = vi.fn<
NonNullable<OpenCodeTeamRuntimeBridgePort['launchOpenCodeTeam']>
>(() => Promise.resolve(successfulOpenCodeLaunchData()));
const adapter = new OpenCodeTeamRuntimeAdapter({
checkOpenCodeTeamLaunchReadiness: vi.fn(async () =>
readiness({ state: 'ready', launchAllowed: true })
),
getLastOpenCodeRuntimeSnapshot: vi.fn(() => runtimeSnapshot('cap-manual')),
launchOpenCodeTeam,
});
await expect(
adapter.launch(launchInput({ skipPermissions: false }))
).resolves.toMatchObject({
teamLaunchState: 'clean_success',
});
expect(launchOpenCodeTeam).toHaveBeenCalledWith(
expect.objectContaining({
skipPermissions: false,
expectedCapabilitySnapshotId: 'cap-manual',
})
);
});
it('launches model-less Default selections with the readiness-resolved model', async () => {
const launchOpenCodeTeam = vi.fn<
NonNullable<OpenCodeTeamRuntimeBridgePort['launchOpenCodeTeam']>
@ -1357,6 +1384,23 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
sessionId: 'oc-session-1',
launchState: 'permission_blocked',
pendingPermissionRequestIds: ['perm-1', 'perm-1', 'perm-2'],
pendingPermissions: [
{
requestId: 'perm-1',
sessionId: 'oc-session-1',
tool: 'bash',
title: 'Run git status',
kind: 'tool',
raw: {
requestID: 'perm-1',
sessionID: 'oc-session-1',
tool: 'bash',
title: 'Run git status',
kind: 'tool',
patterns: ['git status'],
},
},
],
diagnostics: ['waiting for permission approval'],
runtimePid: 123,
model: 'openai/gpt-5.4-mini',
@ -1393,6 +1437,15 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
providerId: 'opencode',
launchState: 'runtime_pending_permission',
pendingPermissionRequestIds: ['perm-1', 'perm-2'],
pendingPermissions: [
{
providerId: 'opencode',
requestId: 'perm-1',
sessionId: 'oc-session-1',
tool: 'bash',
title: 'Run git status',
},
],
runtimeAlive: false,
agentToolAccepted: true,
livenessKind: 'permission_blocked',
@ -1410,6 +1463,85 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
});
});
it('answers OpenCode runtime permissions through the bridge and remaps the lane state', async () => {
const answerOpenCodeRuntimePermission = vi.fn<
NonNullable<OpenCodeTeamRuntimeBridgePort['answerOpenCodeRuntimePermission']>
>(async () => ({
runId: 'run-1',
teamLaunchState: 'ready',
members: {
alice: {
sessionId: 'oc-session-1',
launchState: 'confirmed_alive',
runtimePid: 123,
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' },
{ kind: 'member_ready', observedAt: '2026-04-21T00:00:00.000Z' },
{ kind: 'run_ready', observedAt: '2026-04-21T00:00:00.000Z' },
],
},
},
warnings: [],
diagnostics: [],
}));
const adapter = new OpenCodeTeamRuntimeAdapter(
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
answerOpenCodeRuntimePermission,
})
);
const result = await adapter.answerRuntimePermission({
runId: 'run-1',
teamName: 'team-a',
laneId: 'primary',
cwd: '/repo',
providerId: 'opencode',
memberName: 'alice',
requestId: 'perm-1',
decision: 'allow',
expectedMembers: launchInput().expectedMembers,
previousLaunchState: null,
});
expect(answerOpenCodeRuntimePermission).toHaveBeenCalledWith({
runId: 'run-1',
laneId: 'primary',
teamId: 'team-a',
teamName: 'team-a',
projectPath: '/repo',
memberName: 'alice',
requestId: 'perm-1',
decision: 'allow',
expectedCapabilitySnapshotId: null,
manifestHighWatermark: null,
});
expect(result.teamLaunchState).toBe('clean_success');
expect(result.members.alice?.launchState).toBe('confirmed_alive');
});
it('fails runtime permission answers when the OpenCode answer bridge is unavailable', async () => {
const adapter = new OpenCodeTeamRuntimeAdapter(
bridgePort(readiness({ state: 'ready', launchAllowed: true }))
);
await expect(
adapter.answerRuntimePermission({
runId: 'run-1',
teamName: 'team-a',
laneId: 'primary',
cwd: '/repo',
providerId: 'opencode',
memberName: 'alice',
requestId: 'perm-1',
decision: 'allow',
expectedMembers: launchInput().expectedMembers,
previousLaunchState: null,
})
).rejects.toThrow('OpenCode permission answer bridge is not registered.');
});
it('does not mark created bridge members without runtimePid as runtimeAlive', async () => {
const launchOpenCodeTeam = vi.fn(
async () =>
@ -1706,7 +1838,7 @@ function launchInput(overrides: Partial<TeamRuntimeLaunchInput> = {}): TeamRunti
cwd: '/repo',
providerId: 'opencode',
model: 'openai/gpt-5.4-mini',
skipPermissions: false,
skipPermissions: true,
expectedMembers: [
{
name: 'alice',

View file

@ -0,0 +1,346 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
collectOpenCodeRuntimeApprovalEntries,
openCodeApprovalToolInput,
openCodeApprovalToolName,
} from '../../../../src/main/services/team/approvals/OpenCodeRuntimeApprovalProvider';
import {
RuntimeToolApprovalCoordinator,
type RuntimeToolApprovalEntry,
type RuntimeToolApprovalEvent,
} from '../../../../src/main/services/team/approvals/RuntimeToolApprovalCoordinator';
import {
DEFAULT_TOOL_APPROVAL_SETTINGS,
type ToolApprovalSettings,
} from '../../../../src/shared/types/team';
import type { TeamRuntimeMemberLaunchEvidence } from '../../../../src/main/services/team/runtime';
function settings(overrides: Partial<ToolApprovalSettings> = {}): ToolApprovalSettings {
return {
...DEFAULT_TOOL_APPROVAL_SETTINGS,
...overrides,
};
}
function approvalEntry(overrides: Partial<RuntimeToolApprovalEntry> = {}): RuntimeToolApprovalEntry {
const approval = overrides.approval ?? {
requestId: 'opencode:run-1:perm-1',
runId: 'run-1',
teamName: 'team-a',
providerId: 'opencode' as const,
source: 'alice',
toolName: 'Bash',
toolInput: { command: 'npm test' },
receivedAt: '2026-05-22T10:00:00.000Z',
runtimePermission: {
providerId: 'opencode' as const,
laneId: 'primary',
memberName: 'alice',
providerRequestId: 'perm-1',
sessionId: 'ses-1',
},
};
return {
providerId: 'opencode',
approval,
providerRequestId: 'perm-1',
laneId: 'primary',
memberName: 'alice',
cwd: '/repo',
expectedMembers: [
{
name: 'alice',
providerId: 'opencode',
cwd: '/repo',
},
],
...overrides,
};
}
describe('RuntimeToolApprovalCoordinator', () => {
let currentSettings: ToolApprovalSettings;
let events: RuntimeToolApprovalEvent[];
let answers: { requestId: string; allow: boolean; message?: string }[];
let coordinator: RuntimeToolApprovalCoordinator;
beforeEach(() => {
vi.useFakeTimers();
currentSettings = settings();
events = [];
answers = [];
coordinator = new RuntimeToolApprovalCoordinator({
getSettings: () => currentSettings,
answerApproval: async ({ entry, allow, message }) => {
answers.push({ requestId: entry.approval.requestId, allow, message });
},
emitApprovalEvent: (event) => {
events.push(event);
},
});
});
afterEach(() => {
coordinator.dispose();
vi.useRealTimers();
});
it('deduplicates pending runtime approvals by app request id', () => {
const entry = approvalEntry();
coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [entry]);
coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [entry]);
expect(coordinator.size('team-a')).toBe(1);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ requestId: 'opencode:run-1:perm-1' });
});
it('auto-allows matching categories without emitting a manual prompt', async () => {
currentSettings = settings({ autoAllowSafeBash: true });
coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [approvalEntry()]);
await vi.runAllTimersAsync();
expect(coordinator.size()).toBe(0);
expect(answers).toEqual([{ requestId: 'opencode:run-1:perm-1', allow: true }]);
expect(events).toEqual([
expect.objectContaining({
autoResolved: true,
reason: 'auto_allow_category',
requestId: 'opencode:run-1:perm-1',
}),
]);
});
it('resolves timeout decisions through the provider answer callback', async () => {
currentSettings = settings({ timeoutAction: 'deny', timeoutSeconds: 5 });
coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [approvalEntry()]);
await vi.advanceTimersByTimeAsync(5_000);
expect(answers).toEqual([
{
requestId: 'opencode:run-1:perm-1',
allow: false,
message: 'Timed out - auto-denied by settings',
},
]);
expect(events.at(-1)).toMatchObject({
autoResolved: true,
reason: 'timeout_deny',
requestId: 'opencode:run-1:perm-1',
});
});
it('keeps timeout-resolved approvals pending when provider answer fails', async () => {
currentSettings = settings({ timeoutAction: 'deny', timeoutSeconds: 5 });
let failNextAnswer = true;
coordinator.dispose();
coordinator = new RuntimeToolApprovalCoordinator({
getSettings: () => currentSettings,
answerApproval: async ({ entry, allow, message }) => {
if (failNextAnswer) {
failNextAnswer = false;
throw new Error('bridge unavailable');
}
answers.push({ requestId: entry.approval.requestId, allow, message });
},
emitApprovalEvent: (event) => {
events.push(event);
},
});
const entry = approvalEntry();
coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [entry]);
await vi.advanceTimersByTimeAsync(5_000);
expect(coordinator.get('team-a', 'opencode:run-1:perm-1')).toBe(entry);
expect(events.filter((event) => 'autoResolved' in event)).toEqual([]);
await vi.advanceTimersByTimeAsync(5_000);
expect(answers).toEqual([
{
requestId: 'opencode:run-1:perm-1',
allow: false,
message: 'Timed out - auto-denied by settings',
},
]);
expect(events.at(-1)).toMatchObject({
autoResolved: true,
reason: 'timeout_deny',
requestId: 'opencode:run-1:perm-1',
});
expect(coordinator.get('team-a', 'opencode:run-1:perm-1')).toBeUndefined();
});
it('removes stale lane approvals when runtime state no longer reports them', () => {
coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [approvalEntry()]);
coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, []);
expect(coordinator.size()).toBe(0);
expect(events.at(-1)).toMatchObject({
autoResolved: true,
reason: 'runtime_resolved',
requestId: 'opencode:run-1:perm-1',
});
});
it('rejects stale UI responses by run id', async () => {
coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [approvalEntry()]);
await expect(
coordinator.respond('team-a', 'run-old', 'opencode:run-1:perm-1', true)
).rejects.toThrow('Stale approval: runId mismatch');
expect(answers).toEqual([]);
});
it('keeps manual approvals pending when provider answer fails so users can retry', async () => {
let failNextAnswer = true;
coordinator.dispose();
coordinator = new RuntimeToolApprovalCoordinator({
getSettings: () => currentSettings,
answerApproval: async ({ entry, allow, message }) => {
if (failNextAnswer) {
failNextAnswer = false;
throw new Error('bridge unavailable');
}
answers.push({ requestId: entry.approval.requestId, allow, message });
},
emitApprovalEvent: (event) => {
events.push(event);
},
});
const entry = approvalEntry();
coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [entry]);
await expect(
coordinator.respond('team-a', 'run-1', 'opencode:run-1:perm-1', true)
).rejects.toThrow('bridge unavailable');
expect(coordinator.get('team-a', 'opencode:run-1:perm-1')).toBe(entry);
expect(coordinator.size('team-a')).toBe(1);
await expect(
coordinator.respond('team-a', 'run-1', 'opencode:run-1:perm-1', true, 'retry')
).resolves.toBe(true);
expect(answers).toEqual([
{ requestId: 'opencode:run-1:perm-1', allow: true, message: 'retry' },
]);
expect(coordinator.get('team-a', 'opencode:run-1:perm-1')).toBeUndefined();
});
it('leaves an in-flight approval tracked when a duplicate UI response arrives', async () => {
let releaseAnswer!: () => void;
let answerStarted!: () => void;
const answerStartedPromise = new Promise<void>((resolve) => {
answerStarted = resolve;
});
const releaseAnswerPromise = new Promise<void>((resolve) => {
releaseAnswer = resolve;
});
coordinator.dispose();
coordinator = new RuntimeToolApprovalCoordinator({
getSettings: () => currentSettings,
answerApproval: async ({ entry, allow, message }) => {
answerStarted();
await releaseAnswerPromise;
answers.push({ requestId: entry.approval.requestId, allow, message });
},
emitApprovalEvent: (event) => {
events.push(event);
},
});
const entry = approvalEntry();
coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [entry]);
const firstResponse = coordinator.respond('team-a', 'run-1', 'opencode:run-1:perm-1', true);
await answerStartedPromise;
await expect(
coordinator.respond('team-a', 'run-1', 'opencode:run-1:perm-1', false)
).resolves.toBe(true);
expect(coordinator.get('team-a', 'opencode:run-1:perm-1')).toBe(entry);
releaseAnswer();
await expect(firstResponse).resolves.toBe(true);
expect(answers).toEqual([{ requestId: 'opencode:run-1:perm-1', allow: true }]);
expect(coordinator.get('team-a', 'opencode:run-1:perm-1')).toBeUndefined();
});
});
describe('OpenCodeRuntimeApprovalProvider', () => {
it('normalizes bridge pending permissions into provider-neutral approval entries', () => {
const member: TeamRuntimeMemberLaunchEvidence = {
memberName: 'bob',
providerId: 'opencode',
launchState: 'runtime_pending_permission',
agentToolAccepted: true,
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: false,
pendingApprovals: [
{
providerId: 'opencode',
requestId: 'perm-1',
sessionId: 'ses-1',
tool: 'bash',
raw: { patterns: ['pnpm test'] },
},
],
diagnostics: [],
};
const entries = collectOpenCodeRuntimeApprovalEntries({
teamName: 'team-a',
runId: 'run-1',
laneId: 'primary',
cwd: '/repo',
members: { bob: member },
expectedMembers: [{ name: 'bob', providerId: 'opencode', cwd: '/repo' }],
nowIso: () => '2026-05-22T10:00:00.000Z',
});
expect(entries).toHaveLength(1);
expect(entries[0]?.approval).toMatchObject({
requestId: 'opencode:run-1:perm-1',
providerId: 'opencode',
source: 'bob',
toolName: 'Bash',
toolInput: {
provider: 'opencode',
providerRequestId: 'perm-1',
command: 'pnpm test',
},
runtimePermission: {
providerId: 'opencode',
laneId: 'primary',
memberName: 'bob',
providerRequestId: 'perm-1',
},
});
});
it('maps OpenCode permission display metadata without leaking protocol shape to UI', () => {
const approval = {
providerId: 'opencode' as const,
requestId: 'perm-2',
sessionId: 'ses-2',
kind: 'write',
title: 'Write file',
raw: { patterns: ['/repo/file.ts'] },
};
expect(openCodeApprovalToolName(approval)).toBe('Write');
expect(openCodeApprovalToolInput(approval)).toMatchObject({
provider: 'opencode',
providerRequestId: 'perm-2',
patterns: ['/repo/file.ts'],
title: 'Write file',
});
});
});

View file

@ -1,48 +1,13 @@
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 type {
WorkspaceTrustCoordinator,
WorkspaceTrustExecutionPlan,
} from '../../../../src/features/workspace-trust/core/application/WorkspaceTrustCoordinator';
import { ClaudeBinaryResolver } from '../../../../src/main/services/team/ClaudeBinaryResolver';
import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader';
import {
getMixedLaunchFallbackRecoveryError,
TeamProvisioningService,
} from '../../../../src/main/services/team/TeamProvisioningService';
import type {
OpenCodeTeamRuntimeMessageInput,
OpenCodeTeamRuntimeMessageResult,
} from '../../../../src/main/services/team/runtime';
import {
TeamRuntimeAdapterRegistry,
type TeamLaunchRuntimeAdapter,
type TeamRuntimeLaunchInput,
type TeamRuntimeMemberLaunchEvidence,
type TeamRuntimeMemberSpec,
type TeamRuntimeLaunchResult,
type TeamRuntimePrepareResult,
type TeamRuntimeReconcileInput,
type TeamRuntimeReconcileResult,
type TeamRuntimeStopInput,
type TeamRuntimeStopResult,
} from '../../../../src/main/services/team/runtime/TeamRuntimeAdapter';
import {
encodePath,
extractBaseDir,
getProjectsBasePath,
getTeamsBasePath,
setClaudeBasePathOverride,
} from '../../../../src/main/utils/pathDecoder';
import { createPersistedLaunchSnapshot } from '../../../../src/main/services/team/TeamLaunchStateEvaluator';
import { agentTeamsMcpHttpServer } from '../../../../src/main/services/team/AgentTeamsMcpHttpServer';
import { ClaudeBinaryResolver } from '../../../../src/main/services/team/ClaudeBinaryResolver';
import {
getOpenCodeRuntimeManifestPath,
getOpenCodeRuntimeLaneIndexPath,
getOpenCodeRuntimeManifestPath,
readCommittedOpenCodeBootstrapSessionEvidence,
readOpenCodeRuntimeLaneIndex,
setOpenCodeRuntimeActiveRunManifest,
@ -54,8 +19,49 @@ import {
OPENCODE_RUNTIME_STORE_DESCRIPTORS,
RuntimeStoreBatchWriter,
} from '../../../../src/main/services/team/opencode/store/RuntimeStoreManifest';
import {
type TeamLaunchRuntimeAdapter,
TeamRuntimeAdapterRegistry,
type TeamRuntimeLaunchInput,
type TeamRuntimeLaunchResult,
type TeamRuntimeMemberLaunchEvidence,
type TeamRuntimeMemberSpec,
type TeamRuntimePermissionAnswerInput,
type TeamRuntimePrepareResult,
type TeamRuntimeReconcileInput,
type TeamRuntimeReconcileResult,
type TeamRuntimeStopInput,
type TeamRuntimeStopResult,
} from '../../../../src/main/services/team/runtime/TeamRuntimeAdapter';
import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader';
import { createPersistedLaunchSnapshot } from '../../../../src/main/services/team/TeamLaunchStateEvaluator';
import {
getMixedLaunchFallbackRecoveryError,
TeamProvisioningService,
} from '../../../../src/main/services/team/TeamProvisioningService';
import {
encodePath,
extractBaseDir,
getProjectsBasePath,
getTeamsBasePath,
setClaudeBasePathOverride,
} from '../../../../src/main/utils/pathDecoder';
import type { InboxMessage, TaskRef, TeamProvisioningProgress } from '../../../../src/shared/types';
import type {
WorkspaceTrustCoordinator,
WorkspaceTrustExecutionPlan,
} from '../../../../src/features/workspace-trust/core/application/WorkspaceTrustCoordinator';
import type {
OpenCodeTeamRuntimeMessageInput,
OpenCodeTeamRuntimeMessageResult,
} from '../../../../src/main/services/team/runtime';
import type {
InboxMessage,
TaskRef,
TeamProvisioningProgress,
ToolApprovalEvent,
ToolApprovalRequest,
} from '../../../../src/shared/types';
const LAUNCH_MATRIX_SAFE_E2E_TIMEOUT_MS = 60_000;
const WORKSPACE_TRUST_TEST_ENV_NAMES = [
@ -375,6 +381,73 @@ describe('Team agent launch matrix safe e2e', () => {
expect(statuses.summary?.pendingCount).toBe(1);
});
it('routes OpenCode runtime approval UI responses back through the adapter', async () => {
const adapter = new FakeOpenCodeRuntimeAdapter('partial_pending');
const svc = new TeamProvisioningService();
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
const approvalEvents: ToolApprovalEvent[] = [];
svc.setToolApprovalEventEmitter((event) => approvalEvents.push(event));
const launch = await svc.createTeam(
{
teamName: 'approve-opencode-safe-e2e',
cwd: projectPath,
providerId: 'opencode',
model: 'opencode/big-pickle',
skipPermissions: false,
members: [{ name: 'alice', role: 'Developer', providerId: 'opencode' }],
},
() => undefined
);
const approval = approvalEvents.find(
(event): event is ToolApprovalRequest =>
!('dismissed' in event) && !('autoResolved' in event)
);
expect(approval).toMatchObject({
runId: launch.runId,
teamName: 'approve-opencode-safe-e2e',
providerId: 'opencode',
source: 'alice',
toolName: 'Bash',
runtimePermission: {
providerId: 'opencode',
laneId: 'primary',
memberName: 'alice',
providerRequestId: 'perm-alice',
},
});
expect(approval?.toolInput).toMatchObject({
provider: 'opencode',
providerRequestId: 'perm-alice',
command: 'git status',
});
await svc.respondToToolApproval(
'approve-opencode-safe-e2e',
launch.runId!,
approval!.requestId,
true
);
expect(adapter.permissionAnswerInputs).toEqual([
expect.objectContaining({
runId: launch.runId,
teamName: 'approve-opencode-safe-e2e',
laneId: 'primary',
memberName: 'alice',
requestId: 'perm-alice',
decision: 'allow',
}),
]);
const statuses = await svc.getMemberSpawnStatuses('approve-opencode-safe-e2e');
expect(statuses.statuses.alice).toMatchObject({
status: 'online',
launchState: 'confirmed_alive',
});
expect(statuses.statuses.alice?.pendingPermissionRequestIds).toBeUndefined();
});
it('blocks createTeam at workspace trust preflight before spawn and preserves existing launch state', async () => {
forceWorkspaceTrustPreflightEnv();
process.env.CLAUDE_CLI_PATH = await writeFakeClaudeCli(tempDir);
@ -18293,6 +18366,7 @@ class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter {
readonly providerId = 'opencode' as const;
readonly launchInputs: TeamRuntimeLaunchInput[] = [];
readonly messageInputs: OpenCodeTeamRuntimeMessageInput[] = [];
readonly permissionAnswerInputs: TeamRuntimePermissionAnswerInput[] = [];
readonly reconcileInputs: TeamRuntimeReconcileInput[] = [];
readonly stopInputs: TeamRuntimeStopInput[] = [];
@ -18359,6 +18433,30 @@ class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter {
};
}
async answerRuntimePermission(
input: TeamRuntimePermissionAnswerInput
): Promise<TeamRuntimeLaunchResult> {
this.permissionAnswerInputs.push(input);
this.memberOutcomes = {
...this.memberOutcomes,
[input.memberName]: input.decision === 'allow' ? 'confirmed' : 'failed',
};
return {
runId: input.runId,
teamName: input.teamName,
launchPhase: 'finished',
teamLaunchState: this.aggregateLaunchState(input.expectedMembers),
members: Object.fromEntries(
input.expectedMembers.map((member, index) => [
member.name,
this.buildMemberEvidence(member, index),
])
),
warnings: [],
diagnostics: ['fake OpenCode permission answer'],
};
}
async reconcile(input: TeamRuntimeReconcileInput): Promise<TeamRuntimeReconcileResult> {
this.reconcileInputs.push(input);
const members = Object.fromEntries(
@ -18437,6 +18535,26 @@ class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter {
hardFailure: failed,
hardFailureReason: failed ? 'fake_open_code_launch_failure' : undefined,
pendingPermissionRequestIds: permissionPending ? [`perm-${member.name}`] : undefined,
pendingPermissions: permissionPending
? [
{
providerId: 'opencode',
requestId: `perm-${member.name}`,
sessionId: `session-${member.name}`,
tool: 'bash',
title: `Run git status for ${member.name}`,
kind: 'tool',
raw: {
requestID: `perm-${member.name}`,
sessionID: `session-${member.name}`,
tool: 'bash',
title: `Run git status for ${member.name}`,
kind: 'tool',
patterns: ['git status'],
},
},
]
: undefined,
sessionId: failed ? undefined : `session-${member.name}`,
runtimePid: failed ? undefined : 10_000 + index,
livenessKind,

View file

@ -17786,6 +17786,189 @@ describe('TeamProvisioningService', () => {
});
});
it('tags Codex app-server control_request approvals and replies through control_response', async () => {
const write = vi.fn((_line: string, cb?: (error?: Error | null) => void) => {
cb?.();
return true;
});
const svc = new TeamProvisioningService();
const events: unknown[] = [];
svc.setToolApprovalEventEmitter((event) => events.push(event));
svc.setMainWindow({
isDestroyed: () => false,
isFocused: () => true,
} as never);
const run = {
teamName: 'codex-manual-team',
runId: 'run-codex-manual',
request: { color: '#2563eb', displayName: 'Codex Manual Team' },
child: { stdin: { writable: true, write } },
pendingApprovals: new Map(),
};
const internals = svc as unknown as {
runs: Map<string, unknown>;
aliveRunByTeam: Map<string, string>;
handleControlRequest(run: unknown, msg: Record<string, unknown>): void;
};
internals.runs.set(run.runId, run);
internals.aliveRunByTeam.set(run.teamName, run.runId);
internals.handleControlRequest(run, {
request_id: 'codex-approval-1',
request: {
subtype: 'can_use_tool',
tool_name: 'Bash',
input: {
provider: 'codex',
providerRequestId: 'codex:item/commandExecution/requestApproval:item-1',
command: 'printf ok',
},
},
});
expect(events[0]).toMatchObject({
requestId: 'codex-approval-1',
runId: run.runId,
teamName: run.teamName,
providerId: 'codex',
toolName: 'Bash',
toolInput: {
provider: 'codex',
command: 'printf ok',
},
});
await svc.respondToToolApproval(run.teamName, run.runId, 'codex-approval-1', true);
expect(write).toHaveBeenCalledTimes(1);
const firstWrite = write.mock.calls[0]?.[0];
expect(typeof firstWrite).toBe('string');
const payload = JSON.parse(firstWrite as string) as Record<string, unknown>;
expect(payload).toMatchObject({
type: 'control_response',
response: {
subtype: 'success',
request_id: 'codex-approval-1',
response: { behavior: 'allow', updatedInput: {} },
},
});
});
it('keeps control_request approvals pending when control_response write fails so retry works', async () => {
let failNextWrite = true;
const write = vi.fn((_line: string, cb?: (error?: Error | null) => void) => {
if (failNextWrite) {
failNextWrite = false;
cb?.(new Error('broken pipe'));
return false;
}
cb?.();
return true;
});
const svc = new TeamProvisioningService();
svc.setMainWindow({
isDestroyed: () => false,
isFocused: () => true,
} as never);
const run = {
teamName: 'anthropic-manual-team',
runId: 'run-anthropic-manual',
request: { color: '#7c3aed', displayName: 'Anthropic Manual Team' },
child: { stdin: { writable: true, write } },
pendingApprovals: new Map(),
};
const internals = svc as unknown as {
runs: Map<string, unknown>;
aliveRunByTeam: Map<string, string>;
handleControlRequest(run: unknown, msg: Record<string, unknown>): void;
};
internals.runs.set(run.runId, run);
internals.aliveRunByTeam.set(run.teamName, run.runId);
internals.handleControlRequest(run, {
request_id: 'anthropic-approval-retry',
request: {
subtype: 'can_use_tool',
tool_name: 'Bash',
input: { command: 'printf ok' },
},
});
await expect(
svc.respondToToolApproval(run.teamName, run.runId, 'anthropic-approval-retry', true)
).rejects.toThrow('broken pipe');
expect(
vi.mocked(console.error).mock.calls.some((args) => args.join(' ').includes('broken pipe'))
).toBe(true);
vi.mocked(console.error).mockClear();
expect(run.pendingApprovals.has('anthropic-approval-retry')).toBe(true);
await expect(
svc.respondToToolApproval(run.teamName, run.runId, 'anthropic-approval-retry', true)
).resolves.toBeUndefined();
expect(write).toHaveBeenCalledTimes(2);
expect(run.pendingApprovals.has('anthropic-approval-retry')).toBe(false);
});
it('leaves control_request approvals tracked while a duplicate UI response is in flight', async () => {
let releaseWrite: ((error?: Error | null) => void) | undefined;
const write = vi.fn((_line: string, cb?: (error?: Error | null) => void) => {
releaseWrite = cb;
return true;
});
const svc = new TeamProvisioningService();
svc.setMainWindow({
isDestroyed: () => false,
isFocused: () => true,
} as never);
const run = {
teamName: 'codex-duplicate-response-team',
runId: 'run-codex-duplicate-response',
request: { color: '#2563eb', displayName: 'Codex Duplicate Response Team' },
child: { stdin: { writable: true, write } },
pendingApprovals: new Map(),
};
const internals = svc as unknown as {
runs: Map<string, unknown>;
aliveRunByTeam: Map<string, string>;
handleControlRequest(run: unknown, msg: Record<string, unknown>): void;
};
internals.runs.set(run.runId, run);
internals.aliveRunByTeam.set(run.teamName, run.runId);
internals.handleControlRequest(run, {
request_id: 'codex-approval-duplicate',
request: {
subtype: 'can_use_tool',
tool_name: 'Bash',
input: {
provider: 'codex',
providerRequestId: 'codex:item/commandExecution/requestApproval:item-2',
command: 'printf ok',
},
},
});
const firstResponse = svc.respondToToolApproval(
run.teamName,
run.runId,
'codex-approval-duplicate',
true
);
await expect(
svc.respondToToolApproval(run.teamName, run.runId, 'codex-approval-duplicate', false)
).resolves.toBeUndefined();
expect(write).toHaveBeenCalledTimes(1);
expect(run.pendingApprovals.has('codex-approval-duplicate')).toBe(true);
releaseWrite?.();
await expect(firstResponse).resolves.toBeUndefined();
expect(run.pendingApprovals.has('codex-approval-duplicate')).toBe(false);
});
it('keeps AskUserQuestion answers in teammate fallback control responses', async () => {
const write = vi.fn((_line: string, cb?: (error?: Error | null) => void) => {
cb?.();

View file

@ -0,0 +1,119 @@
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 {
type TeamLaunchRuntimeAdapter,
TeamRuntimeAdapterRegistry,
} from '../../../../src/main/services/team/runtime';
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
let tempRoot: string;
describe('TeamProvisioningService OpenCode support diagnostics', () => {
beforeEach(async () => {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'team-provisioning-opencode-support-'));
});
afterEach(async () => {
await fs.rm(tempRoot, { recursive: true, force: true });
});
it('keeps bridge no-output selected-model failures provider-scoped with support diagnostics', async () => {
const supportDiagnostic = {
id: 'diag-empty-stdout',
providerId: 'opencode' as const,
kind: 'opencode_bridge_no_output',
severity: 'error' as const,
title: 'OpenCode runtime check returned no output',
summary: 'OpenCode readiness bridge exited without returning diagnostic JSON.',
copyText: 'Agent Teams OpenCode diagnostics\noutputReadError: ENOENT',
createdAt: '2026-04-21T12:00:00.000Z',
};
const prepare = vi.fn(async () => ({
ok: false as const,
providerId: 'opencode' as const,
reason: 'unknown_error',
retryable: false,
diagnostics: [
'OpenCode readiness bridge failed: contract_violation: Bridge stdout was empty',
],
warnings: [],
supportDiagnostics: [supportDiagnostic],
}));
const adapter: TeamLaunchRuntimeAdapter = {
providerId: 'opencode',
prepare,
launch: vi.fn(),
reconcile: vi.fn(),
stop: vi.fn(),
};
const service = new TeamProvisioningService();
service.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
const result = await service.prepareForProvisioning(tempRoot, {
providerId: 'opencode',
forceFresh: true,
modelIds: ['opencode/qwen3.6-2b'],
modelVerificationMode: 'deep',
});
expect(result.ready).toBe(false);
expect(result.message).toBe('OpenCode runtime check returned no output.');
expect(result.details).toEqual(['OpenCode runtime check returned no output.']);
expect(result.supportDiagnostics).toEqual([supportDiagnostic]);
expect(result.issues).toEqual([
{
providerId: 'opencode',
scope: 'provider',
severity: 'blocking',
code: 'unknown_error',
message: 'OpenCode runtime check returned no output.',
},
]);
});
it('uses bridge no-output diagnostics as the model-less prepare failure message', async () => {
const supportDiagnostic = {
id: 'diag-empty-stdout',
providerId: 'opencode' as const,
kind: 'opencode_bridge_no_output',
severity: 'error' as const,
title: 'OpenCode runtime check returned no output',
summary: 'OpenCode readiness bridge exited without returning diagnostic JSON.',
copyText: 'Agent Teams OpenCode diagnostics\noutputReadError: ENOENT',
createdAt: '2026-04-21T12:00:00.000Z',
};
const prepare = vi.fn(async () => ({
ok: false as const,
providerId: 'opencode' as const,
reason: 'unknown_error',
retryable: false,
diagnostics: [
'OpenCode readiness bridge failed: contract_violation: Bridge stdout was empty',
],
warnings: [],
supportDiagnostics: [supportDiagnostic],
}));
const adapter: TeamLaunchRuntimeAdapter = {
providerId: 'opencode',
prepare,
launch: vi.fn(),
reconcile: vi.fn(),
stop: vi.fn(),
};
const service = new TeamProvisioningService();
service.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
const result = await service.prepareForProvisioning(tempRoot, {
providerId: 'opencode',
forceFresh: true,
});
expect(result.ready).toBe(false);
expect(result.message).toBe('OpenCode runtime check returned no output.');
expect(result.details).toEqual(['OpenCode runtime check returned no output.']);
expect(result.supportDiagnostics).toEqual([supportDiagnostic]);
});
});

View file

@ -1,3 +1,4 @@
import { OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE } from '@shared/utils/openCodeWindowsAccessDenied';
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
import { spawn } from 'child_process';
import * as fs from 'fs';
@ -97,7 +98,10 @@ vi.mock('@main/utils/childProcess', () => ({
import { ProviderConnectionService } from '@main/services/runtime/ProviderConnectionService';
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import { TeamRuntimeAdapterRegistry } from '@main/services/team/runtime';
import {
type TeamLaunchRuntimeAdapter,
TeamRuntimeAdapterRegistry,
} from '@main/services/team/runtime';
import {
buildDirectTmuxRestartEnvAssignments,
TeamProvisioningService,
@ -886,15 +890,14 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
diagnostics: [],
warnings: [],
}));
const registry = new TeamRuntimeAdapterRegistry([
{
providerId: 'opencode',
prepare,
launch: vi.fn(),
reconcile: vi.fn(),
stop: vi.fn(),
} as any,
]);
const adapter: TeamLaunchRuntimeAdapter = {
providerId: 'opencode',
prepare,
launch: vi.fn(),
reconcile: vi.fn(),
stop: vi.fn(),
};
const registry = new TeamRuntimeAdapterRegistry([adapter]);
const svc = new TeamProvisioningService();
svc.setRuntimeAdapterRegistry(registry);
@ -931,6 +934,78 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
);
});
it('uses OpenCode access-denied warnings as the model-less prepare failure message', async () => {
const prepare = vi.fn(async () => ({
ok: false as const,
providerId: 'opencode' as const,
reason: 'unknown_error',
retryable: false,
diagnostics: [],
warnings: ['EPERM: operation not permitted, mkdir C:\\Program Files\\locked-project'],
}));
const adapter: TeamLaunchRuntimeAdapter = {
providerId: 'opencode',
prepare,
launch: vi.fn(),
reconcile: vi.fn(),
stop: vi.fn(),
};
const registry = new TeamRuntimeAdapterRegistry([adapter]);
const svc = new TeamProvisioningService();
svc.setRuntimeAdapterRegistry(registry);
const result = await svc.prepareForProvisioning(tempRoot, {
providerId: 'opencode',
forceFresh: true,
});
expect(result.ready).toBe(false);
expect(result.message).toBe(OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE);
expect(result.warnings).toEqual([OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE]);
});
it('keeps OpenCode access-denied selected-model failures provider-scoped', async () => {
const prepare = vi.fn(async () => ({
ok: false as const,
providerId: 'opencode' as const,
reason: 'unknown_error',
retryable: false,
diagnostics: ['EPERM: operation not permitted, mkdir C:\\Program Files\\locked-project'],
warnings: [],
}));
const adapter: TeamLaunchRuntimeAdapter = {
providerId: 'opencode',
prepare,
launch: vi.fn(),
reconcile: vi.fn(),
stop: vi.fn(),
};
const registry = new TeamRuntimeAdapterRegistry([adapter]);
const svc = new TeamProvisioningService();
svc.setRuntimeAdapterRegistry(registry);
const result = await svc.prepareForProvisioning(tempRoot, {
providerId: 'opencode',
forceFresh: true,
modelIds: ['opencode/big-pickle'],
modelVerificationMode: 'deep',
});
expect(result.ready).toBe(false);
expect(result.message).toBe(OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE);
expect(result.details).toEqual([OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE]);
expect(result.warnings).toEqual([OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE]);
expect(result.issues).toEqual([
{
providerId: 'opencode',
scope: 'provider',
severity: 'blocking',
code: 'unknown_error',
message: OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE,
},
]);
});
it('coalesces duplicate OpenCode compatibility preflight requests while prepare is in flight', async () => {
const prepareGate: { release?: () => void } = {};
const prepare = vi.fn(

View file

@ -76,6 +76,7 @@ export async function createOpenCodeLiveHarness(input: {
PATH: withBunOnPath(process.env.PATH ?? ''),
AGENT_TEAMS_MCP_CLAUDE_DIR: getClaudeBasePath(),
CLAUDE_TEAM_CONTROL_URL: controlApi.baseUrl,
...mcpLaunchSpec.env,
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),

View file

@ -155,6 +155,156 @@ describe('ProvisioningProviderStatusList', () => {
);
});
it('gives a concrete hint for OpenCode bridge no-output failures', () => {
expect(
getProvisioningFailureHint('Runtime environment is not available - launch is blocked', [
{
providerId: 'opencode',
status: 'failed',
backendSummary: null,
details: [
'OpenCode readiness bridge failed: contract_violation: Bridge stdout was empty',
],
},
])
).toBe(
'Restart the app and OpenCode runtime, then retry. If it repeats, copy diagnostics.'
);
});
it('renders Copy diagnostics for OpenCode support diagnostics and copies the prepared payload', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const writeText = vi.fn(async () => undefined);
Object.defineProperty(globalThis.navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(ProvisioningProviderStatusList, {
checks: [
{
providerId: 'opencode',
status: 'failed',
backendSummary: 'OpenCode CLI',
details: ['OpenCode runtime check returned no output.'],
supportDiagnostics: [
{
id: 'diag-empty-stdout',
providerId: 'opencode',
kind: 'opencode_bridge_no_output',
severity: 'error',
title: 'OpenCode runtime check returned no output',
summary: 'OpenCode readiness bridge exited without returning diagnostic JSON.',
copyText: 'Agent Teams OpenCode diagnostics\noutputReadError: ENOENT',
createdAt: '2026-04-21T12:00:00.000Z',
},
],
},
{
providerId: 'codex',
status: 'failed',
details: ['Codex failed'],
supportDiagnostics: [
{
id: 'diag-codex',
providerId: 'codex',
kind: 'codex_debug',
severity: 'error',
title: 'Codex debug',
summary: 'Codex debug summary',
copyText: 'should not render',
createdAt: '2026-04-21T12:00:00.000Z',
},
],
},
],
})
);
await Promise.resolve();
});
expect(host.textContent).toContain(
'OpenCode (OpenCode CLI): OpenCode runtime check returned no output'
);
expect(host.textContent).toContain('Copy diagnostics');
const buttons = Array.from(host.querySelectorAll('button'));
expect(buttons).toHaveLength(1);
await act(async () => {
buttons[0]?.click();
await Promise.resolve();
});
expect(writeText).toHaveBeenCalledWith(
'Agent Teams OpenCode diagnostics\noutputReadError: ENOENT'
);
expect(host.textContent).toContain('Copied');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('does not show copied when the Clipboard API is unavailable', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
Object.defineProperty(globalThis.navigator, 'clipboard', {
configurable: true,
value: undefined,
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(ProvisioningProviderStatusList, {
checks: [
{
providerId: 'opencode',
status: 'failed',
details: ['OpenCode runtime check returned no output.'],
supportDiagnostics: [
{
id: 'diag-empty-stdout',
providerId: 'opencode',
kind: 'opencode_bridge_no_output',
severity: 'error',
title: 'OpenCode runtime check returned no output',
summary: 'OpenCode readiness bridge exited without returning diagnostic JSON.',
copyText: 'Agent Teams OpenCode diagnostics',
createdAt: '2026-04-21T12:00:00.000Z',
},
],
},
],
})
);
await Promise.resolve();
});
const button = host.querySelector('button');
expect(button?.textContent).toContain('Copy diagnostics');
await act(async () => {
button?.click();
await Promise.resolve();
});
expect(host.textContent).toContain('Copy diagnostics');
expect(host.textContent).not.toContain('Copied');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('picks the first real failure detail instead of a verified line', () => {
expect(
getPrimaryProvisioningFailureDetail([

View file

@ -1,6 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { runProviderPrepareDiagnostics } from '@renderer/components/team/dialogs/providerPrepareDiagnostics';
import { describe, expect, it, vi } from 'vitest';
import type { TeamProviderId, TeamProvisioningPrepareResult } from '@shared/types';
@ -72,4 +71,47 @@ describe('runProviderPrepareDiagnostics OpenCode runtime failures', () => {
]);
expect(result.modelResultsById).toEqual({});
});
it('preserves support diagnostics for OpenCode bridge no-output provider failures', async () => {
const supportDiagnostic = {
id: 'diag-empty-stdout',
providerId: 'opencode' as const,
kind: 'opencode_bridge_no_output',
severity: 'error' as const,
title: 'OpenCode runtime check returned no output',
summary: 'OpenCode readiness bridge exited without returning diagnostic JSON.',
copyText: 'Agent Teams OpenCode diagnostics\noutputReadError: ENOENT',
createdAt: '2026-04-21T12:00:00.000Z',
};
const prepareProvisioning = vi.fn<PrepareProvisioningFn>().mockResolvedValue({
ready: false,
message: 'OpenCode readiness bridge failed: contract_violation: Bridge stdout was empty',
details: [
'OpenCode readiness bridge failed: contract_violation: Bridge stdout was empty',
],
issues: [
{
providerId: 'opencode',
scope: 'provider',
severity: 'blocking',
code: 'unknown_error',
message:
'OpenCode readiness bridge failed: contract_violation: Bridge stdout was empty',
},
],
supportDiagnostics: [supportDiagnostic],
});
const result = await runProviderPrepareDiagnostics({
cwd: '/Users/tester/project',
providerId: 'opencode',
selectedModelIds: ['opencode/qwen3.6-2b'],
prepareProvisioning,
});
expect(result.status).toBe('failed');
expect(result.details).toEqual(['OpenCode runtime check returned no output.']);
expect(result.modelResultsById).toEqual({});
expect(result.supportDiagnostics).toEqual([supportDiagnostic]);
});
});