fix(opencode): harden default model resolution
This commit is contained in:
parent
fd50f736b8
commit
635d13d804
3 changed files with 620 additions and 69 deletions
|
|
@ -674,6 +674,8 @@ const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
||||||
const RUN_TIMEOUT_MS = 300_000;
|
const RUN_TIMEOUT_MS = 300_000;
|
||||||
const VERIFY_TIMEOUT_MS = 15_000;
|
const VERIFY_TIMEOUT_MS = 15_000;
|
||||||
const MCP_PREFLIGHT_INITIALIZE_TIMEOUT_MS = 45_000;
|
const MCP_PREFLIGHT_INITIALIZE_TIMEOUT_MS = 45_000;
|
||||||
|
const PROVIDER_MODEL_LIST_TIMEOUT_MS = 30_000;
|
||||||
|
const PROVIDER_RUNTIME_STATUS_TIMEOUT_MS = 20_000;
|
||||||
const TASK_ACTIVITY_RUNTIME_PAUSE_GRACE_MS = 5_000;
|
const TASK_ACTIVITY_RUNTIME_PAUSE_GRACE_MS = 5_000;
|
||||||
|
|
||||||
function asRuntimeRecord(value: unknown): Record<string, unknown> {
|
function asRuntimeRecord(value: unknown): Record<string, unknown> {
|
||||||
|
|
@ -1344,16 +1346,77 @@ function extractJsonObjectFromCli<T>(raw: string): T {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
try {
|
try {
|
||||||
return JSON.parse(trimmed) as T;
|
return JSON.parse(trimmed) as T;
|
||||||
} catch {
|
} catch (initialError) {
|
||||||
const start = trimmed.indexOf('{');
|
const candidates: T[] = [];
|
||||||
const end = trimmed.lastIndexOf('}');
|
let lastParseError: unknown = null;
|
||||||
if (start >= 0 && end > start) {
|
for (let start = trimmed.indexOf('{'); start >= 0; start = trimmed.indexOf('{', start + 1)) {
|
||||||
return JSON.parse(trimmed.slice(start, end + 1)) as T;
|
const end = findJsonObjectEnd(trimmed, start);
|
||||||
|
if (end < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
candidates.push(JSON.parse(trimmed.slice(start, end + 1)) as T);
|
||||||
|
} catch (error) {
|
||||||
|
lastParseError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let providerResponse: T | null = null;
|
||||||
|
for (let index = candidates.length - 1; index >= 0; index -= 1) {
|
||||||
|
const record = candidates[index] as Record<string, unknown> | null;
|
||||||
|
const providers = record && typeof record === 'object' ? record.providers : null;
|
||||||
|
if (providers && typeof providers === 'object' && !Array.isArray(providers)) {
|
||||||
|
providerResponse = candidates[index];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (providerResponse) {
|
||||||
|
return providerResponse;
|
||||||
|
}
|
||||||
|
if (candidates.length > 0) {
|
||||||
|
throw new Error('No provider JSON object found in CLI output');
|
||||||
|
}
|
||||||
|
if (lastParseError instanceof Error) {
|
||||||
|
throw lastParseError;
|
||||||
|
}
|
||||||
|
if (trimmed.includes('{') && initialError instanceof Error) {
|
||||||
|
throw initialError;
|
||||||
}
|
}
|
||||||
throw new Error('No JSON object found in CLI output');
|
throw new Error('No JSON object found in CLI output');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findJsonObjectEnd(source: string, start: number): number {
|
||||||
|
let depth = 0;
|
||||||
|
let inString = false;
|
||||||
|
let escaped = false;
|
||||||
|
for (let index = start; index < source.length; index += 1) {
|
||||||
|
const char = source[index];
|
||||||
|
if (inString) {
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
} else if (char === '\\') {
|
||||||
|
escaped = true;
|
||||||
|
} else if (char === '"') {
|
||||||
|
inString = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '"') {
|
||||||
|
inString = true;
|
||||||
|
} else if (char === '{') {
|
||||||
|
depth += 1;
|
||||||
|
} else if (char === '}') {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth === 0) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
function getExplicitLaunchModelSelection(model: string | undefined): string | undefined {
|
function getExplicitLaunchModelSelection(model: string | undefined): string | undefined {
|
||||||
const trimmed = model?.trim();
|
const trimmed = model?.trim();
|
||||||
if (!trimmed || isDefaultProviderModelSelection(trimmed)) {
|
if (!trimmed || isDefaultProviderModelSelection(trimmed)) {
|
||||||
|
|
@ -7256,7 +7319,7 @@ export class TeamProvisioningService {
|
||||||
{
|
{
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
env: params.env,
|
env: params.env,
|
||||||
timeout: 10_000,
|
timeout: PROVIDER_MODEL_LIST_TIMEOUT_MS,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const runtimeStatusPromise =
|
const runtimeStatusPromise =
|
||||||
|
|
@ -20440,28 +20503,39 @@ export class TeamProvisioningService {
|
||||||
providerArgs: string[] = [],
|
providerArgs: string[] = [],
|
||||||
limitContext: boolean
|
limitContext: boolean
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const { stdout } = await execCli(
|
|
||||||
claudePath,
|
|
||||||
buildProviderCliCommandArgs(providerArgs, [
|
|
||||||
'model',
|
|
||||||
'list',
|
|
||||||
'--json',
|
|
||||||
'--provider',
|
|
||||||
providerId,
|
|
||||||
]),
|
|
||||||
{
|
|
||||||
cwd,
|
|
||||||
env,
|
|
||||||
timeout: 10_000,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
let parsed: ProviderModelListCommandResponse;
|
let parsed: ProviderModelListCommandResponse;
|
||||||
try {
|
try {
|
||||||
|
const { stdout } = await execCli(
|
||||||
|
claudePath,
|
||||||
|
buildProviderCliCommandArgs(providerArgs, [
|
||||||
|
'model',
|
||||||
|
'list',
|
||||||
|
'--json',
|
||||||
|
'--provider',
|
||||||
|
providerId,
|
||||||
|
]),
|
||||||
|
{
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
timeout: PROVIDER_MODEL_LIST_TIMEOUT_MS,
|
||||||
|
}
|
||||||
|
);
|
||||||
parsed = extractJsonObjectFromCli<ProviderModelListCommandResponse>(stdout);
|
parsed = extractJsonObjectFromCli<ProviderModelListCommandResponse>(stdout);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const fallbackDefaultModel = await this.resolveProviderDefaultModelFromRuntimeStatus(
|
||||||
|
claudePath,
|
||||||
|
cwd,
|
||||||
|
providerId,
|
||||||
|
env,
|
||||||
|
providerArgs,
|
||||||
|
limitContext
|
||||||
|
).catch(() => null);
|
||||||
|
if (fallbackDefaultModel) {
|
||||||
|
return fallbackDefaultModel;
|
||||||
|
}
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to parse runtime default model list for ${getTeamProviderLabel(providerId)} (${providerId}): ${message}`
|
`Failed to load runtime default model list for ${getTeamProviderLabel(providerId)} (${providerId}): ${message}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const defaultModel = parsed.providers?.[providerId]?.defaultModel;
|
const defaultModel = parsed.providers?.[providerId]?.defaultModel;
|
||||||
|
|
@ -20482,6 +20556,46 @@ export class TeamProvisioningService {
|
||||||
return normalizedDefaultModel;
|
return normalizedDefaultModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveProviderDefaultModelFromRuntimeStatus(
|
||||||
|
claudePath: string,
|
||||||
|
cwd: string,
|
||||||
|
providerId: TeamProviderId,
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
providerArgs: string[] = [],
|
||||||
|
limitContext: boolean
|
||||||
|
): Promise<string | null> {
|
||||||
|
const { stdout } = await execCli(
|
||||||
|
claudePath,
|
||||||
|
buildProviderCliCommandArgs(providerArgs, [
|
||||||
|
'runtime',
|
||||||
|
'status',
|
||||||
|
'--json',
|
||||||
|
'--provider',
|
||||||
|
providerId,
|
||||||
|
]),
|
||||||
|
{
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
timeout: PROVIDER_RUNTIME_STATUS_TIMEOUT_MS,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const parsed = extractJsonObjectFromCli<RuntimeStatusCommandResponse>(stdout);
|
||||||
|
const providerStatus = parsed.providers?.[providerId] ?? null;
|
||||||
|
const modelCatalog =
|
||||||
|
providerStatus?.modelCatalog?.providerId === providerId ? providerStatus.modelCatalog : null;
|
||||||
|
const defaultLaunchModel = modelCatalog?.defaultLaunchModel?.trim() || null;
|
||||||
|
|
||||||
|
if (providerId === 'anthropic') {
|
||||||
|
return resolveAnthropicLaunchModel({
|
||||||
|
limitContext,
|
||||||
|
availableLaunchModels: modelCatalog?.models.map((model) => model.launchModel) ?? [],
|
||||||
|
defaultLaunchModel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultLaunchModel;
|
||||||
|
}
|
||||||
|
|
||||||
private async materializeEffectiveTeamMemberSpecs(params: {
|
private async materializeEffectiveTeamMemberSpecs(params: {
|
||||||
claudePath: string;
|
claudePath: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
|
@ -20605,6 +20719,111 @@ export class TeamProvisioningService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async materializeOpenCodeRuntimeAdapterDefaults<
|
||||||
|
TRequest extends TeamCreateRequest | TeamLaunchRequest,
|
||||||
|
>(params: {
|
||||||
|
request: TRequest;
|
||||||
|
members: TeamCreateRequest['members'];
|
||||||
|
}): Promise<{
|
||||||
|
request: TRequest;
|
||||||
|
members: TeamCreateRequest['members'];
|
||||||
|
}> {
|
||||||
|
const effectiveMembers = buildEffectiveTeamMemberSpecs(params.members, {
|
||||||
|
providerId: params.request.providerId,
|
||||||
|
model: params.request.model,
|
||||||
|
effort: params.request.effort,
|
||||||
|
});
|
||||||
|
const explicitRootModel = getExplicitLaunchModelSelection(params.request.model);
|
||||||
|
const memberModels = [
|
||||||
|
...new Set(
|
||||||
|
effectiveMembers
|
||||||
|
.map((member) => member.model?.trim())
|
||||||
|
.filter((model): model is string => Boolean(model))
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (!explicitRootModel && memberModels.length > 1) {
|
||||||
|
throw new Error(
|
||||||
|
'OpenCode runtime adapter launch supports one selected model per lane. Select one team model or align OpenCode teammate models.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const inheritedRootModel = explicitRootModel ? undefined : memberModels[0];
|
||||||
|
const rootModel = explicitRootModel ?? inheritedRootModel;
|
||||||
|
const needsMemberModel = effectiveMembers.some((member) => {
|
||||||
|
const providerId = normalizeTeamMemberProviderId(member.providerId) ?? 'opencode';
|
||||||
|
return providerId === 'opencode' && !member.model?.trim();
|
||||||
|
});
|
||||||
|
if (rootModel && !needsMemberModel) {
|
||||||
|
return {
|
||||||
|
request: {
|
||||||
|
...params.request,
|
||||||
|
model: rootModel,
|
||||||
|
} as TRequest,
|
||||||
|
members: effectiveMembers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (rootModel) {
|
||||||
|
return {
|
||||||
|
request: {
|
||||||
|
...params.request,
|
||||||
|
model: rootModel,
|
||||||
|
} as TRequest,
|
||||||
|
members: effectiveMembers.map((member) => {
|
||||||
|
const providerId = normalizeTeamMemberProviderId(member.providerId) ?? 'opencode';
|
||||||
|
if (providerId !== 'opencode' || member.model?.trim()) {
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...member,
|
||||||
|
model: rootModel,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const claudePath = await ClaudeBinaryResolver.resolve();
|
||||||
|
if (!claudePath) {
|
||||||
|
throw buildMissingCliError();
|
||||||
|
}
|
||||||
|
const provisioningEnv = await this.buildProvisioningEnv(
|
||||||
|
'opencode',
|
||||||
|
params.request.providerBackendId
|
||||||
|
);
|
||||||
|
if (provisioningEnv.warning) {
|
||||||
|
throw new Error(provisioningEnv.warning);
|
||||||
|
}
|
||||||
|
const resolvedDefaultModel = await this.resolveProviderDefaultModel(
|
||||||
|
claudePath,
|
||||||
|
params.request.cwd,
|
||||||
|
'opencode',
|
||||||
|
provisioningEnv.env,
|
||||||
|
provisioningEnv.providerArgs ?? [],
|
||||||
|
params.request.limitContext === true
|
||||||
|
);
|
||||||
|
const normalizedDefaultModel = resolvedDefaultModel?.trim();
|
||||||
|
if (!normalizedDefaultModel) {
|
||||||
|
throw new Error(
|
||||||
|
'Could not resolve the runtime default model for OpenCode teammates. Select an explicit model and retry.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
request: {
|
||||||
|
...params.request,
|
||||||
|
model: normalizedDefaultModel,
|
||||||
|
} as TRequest,
|
||||||
|
members: effectiveMembers.map((member) => {
|
||||||
|
const providerId = normalizeTeamMemberProviderId(member.providerId) ?? 'opencode';
|
||||||
|
if (providerId !== 'opencode' || member.model?.trim()) {
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...member,
|
||||||
|
model: normalizedDefaultModel,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async resolveOpenCodeMemberWorkspacesForRuntime(params: {
|
private async resolveOpenCodeMemberWorkspacesForRuntime(params: {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
baseCwd: string;
|
baseCwd: string;
|
||||||
|
|
@ -22228,46 +22447,47 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureCwdExists(request.cwd);
|
await ensureCwdExists(request.cwd);
|
||||||
const effectiveMembers = await this.resolveOpenCodeMemberWorkspacesForRuntime({
|
const materialized = await this.materializeOpenCodeRuntimeAdapterDefaults({
|
||||||
teamName: request.teamName,
|
request,
|
||||||
baseCwd: request.cwd,
|
members: request.members,
|
||||||
leadProviderId: request.providerId,
|
|
||||||
members: buildEffectiveTeamMemberSpecs(request.members, {
|
|
||||||
providerId: request.providerId,
|
|
||||||
model: request.model,
|
|
||||||
effort: request.effort,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const teamDir = path.join(getTeamsBasePath(), request.teamName);
|
const launchRequest = materialized.request;
|
||||||
const tasksDir = path.join(getTasksBasePath(), request.teamName);
|
const effectiveMembers = await this.resolveOpenCodeMemberWorkspacesForRuntime({
|
||||||
|
teamName: launchRequest.teamName,
|
||||||
|
baseCwd: launchRequest.cwd,
|
||||||
|
leadProviderId: launchRequest.providerId,
|
||||||
|
members: materialized.members,
|
||||||
|
});
|
||||||
|
const teamDir = path.join(getTeamsBasePath(), launchRequest.teamName);
|
||||||
|
const tasksDir = path.join(getTasksBasePath(), launchRequest.teamName);
|
||||||
await fs.promises.mkdir(teamDir, { recursive: true });
|
await fs.promises.mkdir(teamDir, { recursive: true });
|
||||||
await fs.promises.mkdir(tasksDir, { recursive: true });
|
await fs.promises.mkdir(tasksDir, { recursive: true });
|
||||||
await this.teamMetaStore.writeMeta(request.teamName, {
|
await this.teamMetaStore.writeMeta(launchRequest.teamName, {
|
||||||
displayName: request.displayName,
|
displayName: launchRequest.displayName,
|
||||||
description: request.description,
|
description: launchRequest.description,
|
||||||
color: request.color,
|
color: launchRequest.color,
|
||||||
cwd: request.cwd,
|
cwd: launchRequest.cwd,
|
||||||
prompt: request.prompt,
|
prompt: launchRequest.prompt,
|
||||||
providerId: request.providerId,
|
providerId: launchRequest.providerId,
|
||||||
providerBackendId: request.providerBackendId,
|
providerBackendId: launchRequest.providerBackendId,
|
||||||
model: request.model,
|
model: launchRequest.model,
|
||||||
effort: request.effort,
|
effort: launchRequest.effort,
|
||||||
skipPermissions: request.skipPermissions,
|
skipPermissions: launchRequest.skipPermissions,
|
||||||
worktree: request.worktree,
|
worktree: launchRequest.worktree,
|
||||||
extraCliArgs: request.extraCliArgs,
|
extraCliArgs: launchRequest.extraCliArgs,
|
||||||
limitContext: request.limitContext,
|
limitContext: launchRequest.limitContext,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
});
|
});
|
||||||
const membersToWrite = this.buildMembersMetaWritePayload(effectiveMembers);
|
const membersToWrite = this.buildMembersMetaWritePayload(effectiveMembers);
|
||||||
await this.membersMetaStore.writeMembers(request.teamName, membersToWrite, {
|
await this.membersMetaStore.writeMembers(launchRequest.teamName, membersToWrite, {
|
||||||
providerBackendId: request.providerBackendId,
|
providerBackendId: launchRequest.providerBackendId,
|
||||||
});
|
});
|
||||||
await this.writeOpenCodeTeamConfig(request, effectiveMembers);
|
await this.writeOpenCodeTeamConfig(launchRequest, effectiveMembers);
|
||||||
|
|
||||||
return this.runOpenCodeTeamRuntimeAdapterLaunch({
|
return this.runOpenCodeTeamRuntimeAdapterLaunch({
|
||||||
request,
|
request: launchRequest,
|
||||||
members: effectiveMembers,
|
members: effectiveMembers,
|
||||||
prompt: request.prompt?.trim() ?? '',
|
prompt: launchRequest.prompt?.trim() ?? '',
|
||||||
sourceWarning: undefined,
|
sourceWarning: undefined,
|
||||||
onProgress,
|
onProgress,
|
||||||
});
|
});
|
||||||
|
|
@ -22291,17 +22511,18 @@ export class TeamProvisioningService {
|
||||||
configRaw,
|
configRaw,
|
||||||
request.providerId
|
request.providerId
|
||||||
);
|
);
|
||||||
const effectiveMembers = await this.resolveOpenCodeMemberWorkspacesForRuntime({
|
const materialized = await this.materializeOpenCodeRuntimeAdapterDefaults({
|
||||||
teamName: request.teamName,
|
request,
|
||||||
baseCwd: request.cwd,
|
members,
|
||||||
leadProviderId: request.providerId,
|
|
||||||
members: buildEffectiveTeamMemberSpecs(members, {
|
|
||||||
providerId: request.providerId,
|
|
||||||
model: request.model,
|
|
||||||
effort: request.effort,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
await this.updateConfigProjectPath(request.teamName, request.cwd);
|
const launchRequest = materialized.request;
|
||||||
|
const effectiveMembers = await this.resolveOpenCodeMemberWorkspacesForRuntime({
|
||||||
|
teamName: launchRequest.teamName,
|
||||||
|
baseCwd: launchRequest.cwd,
|
||||||
|
leadProviderId: launchRequest.providerId,
|
||||||
|
members: materialized.members,
|
||||||
|
});
|
||||||
|
await this.updateConfigProjectPath(launchRequest.teamName, launchRequest.cwd);
|
||||||
|
|
||||||
let existingTasks: TeamTask[] = [];
|
let existingTasks: TeamTask[] = [];
|
||||||
try {
|
try {
|
||||||
|
|
@ -22312,14 +22533,14 @@ export class TeamProvisioningService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const prompt = buildDeterministicLaunchHydrationPrompt(
|
const prompt = buildDeterministicLaunchHydrationPrompt(
|
||||||
request,
|
launchRequest,
|
||||||
effectiveMembers,
|
effectiveMembers,
|
||||||
existingTasks,
|
existingTasks,
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.runOpenCodeTeamRuntimeAdapterLaunch({
|
return this.runOpenCodeTeamRuntimeAdapterLaunch({
|
||||||
request,
|
request: launchRequest,
|
||||||
members: effectiveMembers,
|
members: effectiveMembers,
|
||||||
prompt,
|
prompt,
|
||||||
sourceWarning: warning,
|
sourceWarning: warning,
|
||||||
|
|
@ -22695,7 +22916,7 @@ export class TeamProvisioningService {
|
||||||
name: member.name,
|
name: member.name,
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
providerBackendId: undefined,
|
providerBackendId: undefined,
|
||||||
model: member.model?.trim() || undefined,
|
model: member.model?.trim() || evidence?.model?.trim() || undefined,
|
||||||
effort: member.effort,
|
effort: member.effort,
|
||||||
cwd: member.cwd?.trim() || undefined,
|
cwd: member.cwd?.trim() || undefined,
|
||||||
laneId: 'primary',
|
laneId: 'primary',
|
||||||
|
|
@ -26842,9 +27063,10 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
const activeRunMember = this.findEffectiveRunMember(run, memberName);
|
const activeRunMember = this.findEffectiveRunMember(run, memberName);
|
||||||
const activeRunModel = activeRunMember?.model?.trim();
|
const activeRunModel = activeRunMember?.model?.trim();
|
||||||
|
const evidenceModel = currentRuntimeAdapterRun?.members?.[memberName]?.model?.trim();
|
||||||
const activeRunProviderId =
|
const activeRunProviderId =
|
||||||
normalizeOptionalTeamProviderId(activeRunMember?.providerId) ??
|
normalizeOptionalTeamProviderId(activeRunMember?.providerId) ??
|
||||||
inferTeamProviderIdFromModel(activeRunModel);
|
inferTeamProviderIdFromModel(activeRunModel ?? evidenceModel);
|
||||||
const effectiveProviderId = activeRunProviderId ?? persistedMember.providerId;
|
const effectiveProviderId = activeRunProviderId ?? persistedMember.providerId;
|
||||||
const currentRuntimeAdapterEvidence = currentRuntimeAdapterRun?.members?.[memberName];
|
const currentRuntimeAdapterEvidence = currentRuntimeAdapterRun?.members?.[memberName];
|
||||||
upsertMetadata(memberName, {
|
upsertMetadata(memberName, {
|
||||||
|
|
@ -26865,9 +27087,11 @@ export class TeamProvisioningService {
|
||||||
persistedMember.lastRuntimeAliveAt,
|
persistedMember.lastRuntimeAliveAt,
|
||||||
...(activeRunModel
|
...(activeRunModel
|
||||||
? { model: activeRunModel }
|
? { model: activeRunModel }
|
||||||
: persistedMember.model?.trim()
|
: evidenceModel
|
||||||
? { model: persistedMember.model.trim() }
|
? { model: evidenceModel }
|
||||||
: {}),
|
: persistedMember.model?.trim()
|
||||||
|
? { model: persistedMember.model.trim() }
|
||||||
|
: {}),
|
||||||
...(typeof currentRuntimeAdapterEvidence?.runtimePid === 'number' &&
|
...(typeof currentRuntimeAdapterEvidence?.runtimePid === 'number' &&
|
||||||
currentRuntimeAdapterEvidence.runtimePid > 0
|
currentRuntimeAdapterEvidence.runtimePid > 0
|
||||||
? { metricsPid: currentRuntimeAdapterEvidence.runtimePid }
|
? { metricsPid: currentRuntimeAdapterEvidence.runtimePid }
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ export type TeamRuntimePrepareResult = TeamRuntimePrepareSuccess | TeamRuntimePr
|
||||||
export interface TeamRuntimeMemberLaunchEvidence {
|
export interface TeamRuntimeMemberLaunchEvidence {
|
||||||
memberName: string;
|
memberName: string;
|
||||||
providerId: TeamRuntimeProviderId;
|
providerId: TeamRuntimeProviderId;
|
||||||
|
model?: string;
|
||||||
launchState: MemberLaunchState;
|
launchState: MemberLaunchState;
|
||||||
agentToolAccepted: boolean;
|
agentToolAccepted: boolean;
|
||||||
runtimeAlive: boolean;
|
runtimeAlive: boolean;
|
||||||
|
|
|
||||||
|
|
@ -2821,6 +2821,332 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
||||||
vi.mocked(console.warn).mockClear();
|
vi.mocked(console.warn).mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resolves the OpenCode default model when CLI JSON is surrounded by noisy structured logs', async () => {
|
||||||
|
const modelList = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
providers: {
|
||||||
|
opencode: {
|
||||||
|
defaultModel: 'opencode/big-pickle',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: 'opencode/big-pickle',
|
||||||
|
label: 'Big Pickle',
|
||||||
|
description: 'Default OpenCode free model',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'opencode/minimax-m2.5-free',
|
||||||
|
label: 'MiniMax M2.5 Free',
|
||||||
|
description: 'Free OpenCode model',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
execCliMock.mockResolvedValue({
|
||||||
|
stdout: [
|
||||||
|
'debug {"event":"starting model list"}',
|
||||||
|
JSON.stringify(modelList),
|
||||||
|
'debug {"providers":"log-only"}',
|
||||||
|
].join('\n'),
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const serviceWithDefaultModelResolver = svc as unknown as {
|
||||||
|
resolveProviderDefaultModel: (
|
||||||
|
claudePath: string,
|
||||||
|
cwd: string,
|
||||||
|
providerId: string,
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
providerArgs: string[],
|
||||||
|
limitContext: boolean
|
||||||
|
) => Promise<string | null>;
|
||||||
|
};
|
||||||
|
await expect(
|
||||||
|
serviceWithDefaultModelResolver.resolveProviderDefaultModel(
|
||||||
|
'/fake/claude',
|
||||||
|
tempRoot,
|
||||||
|
'opencode',
|
||||||
|
{ PATH: '/usr/bin' },
|
||||||
|
[],
|
||||||
|
false
|
||||||
|
)
|
||||||
|
).resolves.toBe('opencode/big-pickle');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to OpenCode runtime status when the default model list is truncated', async () => {
|
||||||
|
execCliMock.mockImplementation(async (_binaryPath: string | null, args: string[]) => {
|
||||||
|
if (args[0] === 'model' && args[1] === 'list' && args.includes('opencode')) {
|
||||||
|
return {
|
||||||
|
stdout: [
|
||||||
|
'{',
|
||||||
|
' "schemaVersion": 1,',
|
||||||
|
' "providers": {',
|
||||||
|
' "opencode": {',
|
||||||
|
' "defaultModel": "opencode/big-pickle",',
|
||||||
|
' "models": [',
|
||||||
|
' {"id":"opencode/big-pickle","label":"Big Pickle","description":"Free"}',
|
||||||
|
].join('\n'),
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (args[0] === 'runtime' && args[1] === 'status' && args.includes('opencode')) {
|
||||||
|
return {
|
||||||
|
stdout: JSON.stringify({
|
||||||
|
providers: {
|
||||||
|
opencode: {
|
||||||
|
providerId: 'opencode',
|
||||||
|
modelCatalog: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
providerId: 'opencode',
|
||||||
|
source: 'app-server',
|
||||||
|
status: 'ready',
|
||||||
|
fetchedAt: new Date(0).toISOString(),
|
||||||
|
staleAt: new Date(60_000).toISOString(),
|
||||||
|
defaultModelId: 'opencode/big-pickle',
|
||||||
|
defaultLaunchModel: 'opencode/big-pickle',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: 'opencode/big-pickle',
|
||||||
|
launchModel: 'opencode/big-pickle',
|
||||||
|
displayName: 'Big Pickle',
|
||||||
|
hidden: false,
|
||||||
|
supportedReasoningEfforts: [],
|
||||||
|
defaultReasoningEffort: null,
|
||||||
|
inputModalities: ['text'],
|
||||||
|
supportsPersonality: true,
|
||||||
|
isDefault: true,
|
||||||
|
upgrade: false,
|
||||||
|
source: 'app-server',
|
||||||
|
badgeLabel: 'Free',
|
||||||
|
statusMessage: null,
|
||||||
|
metadata: { free: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
diagnostics: {
|
||||||
|
configReadState: 'ready',
|
||||||
|
appServerState: 'healthy',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return defaultExecCliMockImplementation(_binaryPath, args);
|
||||||
|
});
|
||||||
|
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const serviceWithDefaultModelResolver = svc as unknown as {
|
||||||
|
resolveProviderDefaultModel: (
|
||||||
|
claudePath: string,
|
||||||
|
cwd: string,
|
||||||
|
providerId: string,
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
providerArgs: string[],
|
||||||
|
limitContext: boolean
|
||||||
|
) => Promise<string | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
serviceWithDefaultModelResolver.resolveProviderDefaultModel(
|
||||||
|
'/fake/claude',
|
||||||
|
tempRoot,
|
||||||
|
'opencode',
|
||||||
|
{ PATH: '/usr/bin' },
|
||||||
|
[],
|
||||||
|
false
|
||||||
|
)
|
||||||
|
).resolves.toBe('opencode/big-pickle');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to OpenCode runtime status when the default model list command fails', async () => {
|
||||||
|
execCliMock.mockImplementation(async (_binaryPath: string | null, args: string[]) => {
|
||||||
|
if (args[0] === 'model' && args[1] === 'list' && args.includes('opencode')) {
|
||||||
|
const error = new Error('stdout maxBuffer exceeded');
|
||||||
|
Object.assign(error, { stdout: '{"providers":', stderr: '' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (args[0] === 'runtime' && args[1] === 'status' && args.includes('opencode')) {
|
||||||
|
return {
|
||||||
|
stdout: JSON.stringify({
|
||||||
|
providers: {
|
||||||
|
opencode: {
|
||||||
|
providerId: 'opencode',
|
||||||
|
modelCatalog: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
providerId: 'opencode',
|
||||||
|
source: 'app-server',
|
||||||
|
status: 'ready',
|
||||||
|
fetchedAt: new Date(0).toISOString(),
|
||||||
|
staleAt: new Date(60_000).toISOString(),
|
||||||
|
defaultModelId: 'opencode/big-pickle',
|
||||||
|
defaultLaunchModel: 'opencode/big-pickle',
|
||||||
|
models: [],
|
||||||
|
diagnostics: {
|
||||||
|
configReadState: 'ready',
|
||||||
|
appServerState: 'healthy',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return defaultExecCliMockImplementation(_binaryPath, args);
|
||||||
|
});
|
||||||
|
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const serviceWithDefaultModelResolver = svc as unknown as {
|
||||||
|
resolveProviderDefaultModel: (
|
||||||
|
claudePath: string,
|
||||||
|
cwd: string,
|
||||||
|
providerId: string,
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
providerArgs: string[],
|
||||||
|
limitContext: boolean
|
||||||
|
) => Promise<string | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
serviceWithDefaultModelResolver.resolveProviderDefaultModel(
|
||||||
|
'/fake/claude',
|
||||||
|
tempRoot,
|
||||||
|
'opencode',
|
||||||
|
{ PATH: '/usr/bin' },
|
||||||
|
[],
|
||||||
|
false
|
||||||
|
)
|
||||||
|
).resolves.toBe('opencode/big-pickle');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('materializes pure OpenCode runtime adapter Default selections before launch', async () => {
|
||||||
|
execCliMock.mockImplementation(async (_binaryPath: string | null, args: string[]) => {
|
||||||
|
if (args[0] === 'model' && args[1] === 'list' && args.includes('opencode')) {
|
||||||
|
return {
|
||||||
|
stdout: JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
providers: {
|
||||||
|
opencode: {
|
||||||
|
defaultModel: 'opencode/big-pickle',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: 'opencode/big-pickle',
|
||||||
|
label: 'Big Pickle',
|
||||||
|
description: 'Free OpenCode model',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return defaultExecCliMockImplementation(_binaryPath, args);
|
||||||
|
});
|
||||||
|
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const serviceWithMaterializer = svc as unknown as {
|
||||||
|
materializeOpenCodeRuntimeAdapterDefaults: (params: {
|
||||||
|
request: {
|
||||||
|
teamName: string;
|
||||||
|
cwd: string;
|
||||||
|
providerId: 'opencode';
|
||||||
|
skipPermissions: boolean;
|
||||||
|
model?: string;
|
||||||
|
};
|
||||||
|
members: Array<{
|
||||||
|
name: string;
|
||||||
|
providerId?: 'opencode';
|
||||||
|
model?: string;
|
||||||
|
}>;
|
||||||
|
}) => Promise<{
|
||||||
|
request: { model?: string };
|
||||||
|
members: Array<{ name: string; model?: string }>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await serviceWithMaterializer.materializeOpenCodeRuntimeAdapterDefaults({
|
||||||
|
request: {
|
||||||
|
teamName: 'default-opencode-team',
|
||||||
|
cwd: tempRoot,
|
||||||
|
providerId: 'opencode',
|
||||||
|
skipPermissions: true,
|
||||||
|
},
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
name: 'atlas',
|
||||||
|
providerId: 'opencode',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.request.model).toBe('opencode/big-pickle');
|
||||||
|
expect(result.members).toEqual([
|
||||||
|
{
|
||||||
|
name: 'atlas',
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'opencode/big-pickle',
|
||||||
|
effort: undefined,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(execCliMock).toHaveBeenCalledWith(
|
||||||
|
'/fake/claude',
|
||||||
|
['model', 'list', '--json', '--provider', 'opencode'],
|
||||||
|
expect.objectContaining({ cwd: tempRoot })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('materializes pure OpenCode runtime adapter root model from a saved teammate model', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const serviceWithMaterializer = svc as unknown as {
|
||||||
|
materializeOpenCodeRuntimeAdapterDefaults: (params: {
|
||||||
|
request: {
|
||||||
|
teamName: string;
|
||||||
|
cwd: string;
|
||||||
|
providerId: 'opencode';
|
||||||
|
skipPermissions: boolean;
|
||||||
|
model?: string;
|
||||||
|
};
|
||||||
|
members: Array<{
|
||||||
|
name: string;
|
||||||
|
providerId?: 'opencode';
|
||||||
|
model?: string;
|
||||||
|
}>;
|
||||||
|
}) => Promise<{
|
||||||
|
request: { model?: string };
|
||||||
|
members: Array<{ name: string; model?: string }>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await serviceWithMaterializer.materializeOpenCodeRuntimeAdapterDefaults({
|
||||||
|
request: {
|
||||||
|
teamName: 'saved-opencode-team',
|
||||||
|
cwd: tempRoot,
|
||||||
|
providerId: 'opencode',
|
||||||
|
skipPermissions: true,
|
||||||
|
},
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
name: 'atlas',
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'opencode/big-pickle',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.request.model).toBe('opencode/big-pickle');
|
||||||
|
expect(result.members[0]?.model).toBe('opencode/big-pickle');
|
||||||
|
expect(execCliMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('maps ANTHROPIC_AUTH_TOKEN into ANTHROPIC_API_KEY for headless preflight', async () => {
|
it('maps ANTHROPIC_AUTH_TOKEN into ANTHROPIC_API_KEY for headless preflight', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({
|
vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue