diff --git a/src/main/http/teams.ts b/src/main/http/teams.ts index 0166e058..28439548 100644 --- a/src/main/http/teams.ts +++ b/src/main/http/teams.ts @@ -100,6 +100,7 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest throw new HttpBadRequestError('providerId must be anthropic, codex, or gemini'); })(); const prompt = assertOptionalString(payload.prompt, 'prompt'); + const providerBackendId = assertOptionalString(payload.providerBackendId, 'providerBackendId'); const model = assertOptionalString(payload.model, 'model'); const effort = assertOptionalEffort(payload.effort); const clearContext = assertOptionalBoolean(payload.clearContext, 'clearContext'); @@ -111,6 +112,9 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest teamName, cwd: assertAbsoluteCwd(payload.cwd), providerId, + ...(providerBackendId && { + providerBackendId, + }), ...(prompt && { prompt, }), diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index dcfdc648..a20fcc09 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -1126,6 +1126,25 @@ function parseOptionalMemberProviderId( return { valid: false, error: 'member providerId must be anthropic, codex, or gemini' }; } +function parseOptionalProviderBackendId( + value: unknown +): { valid: true; value: string | undefined } | { valid: false; error: string } { + if (value === undefined || value === null || value === '') { + return { valid: true, value: undefined }; + } + if (typeof value !== 'string') { + return { valid: false, error: 'providerBackendId must be a string' }; + } + const trimmed = value.trim(); + if (!trimmed) { + return { valid: true, value: undefined }; + } + if (trimmed.length > 64) { + return { valid: false, error: 'providerBackendId too long (max 64)' }; + } + return { valid: true, value: trimmed }; +} + function parseOptionalMemberEffort( value: unknown ): { valid: true; value: EffortLevel | undefined } | { valid: false; error: string } { @@ -1219,6 +1238,10 @@ async function validateProvisioningRequest( if (payload.prompt !== undefined && typeof payload.prompt !== 'string') { return { valid: false, error: 'prompt must be a string' }; } + const providerBackendValidation = parseOptionalProviderBackendId(payload.providerBackendId); + if (!providerBackendValidation.valid) { + return { valid: false, error: providerBackendValidation.error }; + } try { await fs.promises.mkdir(cwd, { recursive: true }); @@ -1276,6 +1299,7 @@ async function validateProvisioningRequest( : payload.providerId === 'gemini' ? 'gemini' : 'anthropic', + providerBackendId: providerBackendValidation.value, model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined, effort: isValidEffort(payload.effort) ? payload.effort : undefined, skipPermissions: @@ -1385,6 +1409,10 @@ async function handleLaunchTeam( if (payload.model !== undefined && typeof payload.model !== 'string') { return { success: false, error: 'model must be a string' }; } + const providerBackendValidation = parseOptionalProviderBackendId(payload.providerBackendId); + if (!providerBackendValidation.valid) { + return { success: false, error: providerBackendValidation.error }; + } // Detect draft team: team.meta.json exists but config.json doesn't. // This happens when user created team config without launching (launchTeam=false), @@ -1403,7 +1431,8 @@ async function handleLaunchTeam( if (isDraft) { const meta = await teamMetaStore.getMeta(tn); const membersStore = new TeamMembersMetaStore(); - const members = await membersStore.getMembers(tn); + const membersMeta = await membersStore.getMeta(tn); + const members = membersMeta?.members ?? []; const createRequest: TeamCreateRequest = { teamName: tn, @@ -1422,6 +1451,10 @@ async function handleLaunchTeam( : meta?.providerId === 'gemini' ? 'gemini' : 'anthropic', + providerBackendId: + providerBackendValidation.value ?? + meta?.providerBackendId ?? + membersMeta?.providerBackendId, model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined, effort: isValidEffort(payload.effort) ? payload.effort : undefined, limitContext: typeof payload.limitContext === 'boolean' ? payload.limitContext : undefined, @@ -1468,6 +1501,7 @@ async function handleLaunchTeam( : payload.providerId === 'gemini' ? 'gemini' : 'anthropic', + providerBackendId: providerBackendValidation.value, model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined, effort: isValidEffort(payload.effort) ? payload.effort : undefined, clearContext: payload.clearContext === true ? true : undefined, @@ -2552,6 +2586,10 @@ async function handleCreateConfig( return { success: false, error: 'cwd must be an absolute path' }; } } + const providerBackendValidation = parseOptionalProviderBackendId(payload.providerBackendId); + if (!providerBackendValidation.valid) { + return { success: false, error: providerBackendValidation.error }; + } const seenNames = new Set(); const members: TeamCreateConfigRequest['members'] = []; @@ -2609,6 +2647,7 @@ async function handleCreateConfig( color: typeof payload.color === 'string' ? payload.color.trim() || undefined : undefined, members, cwd: typeof payload.cwd === 'string' ? payload.cwd.trim() || undefined : undefined, + providerBackendId: providerBackendValidation.value, }) ); } @@ -3884,7 +3923,8 @@ async function handleGetSavedRequest( } const membersStore = new TeamMembersMetaStore(); - const members = await membersStore.getMembers(tn); + const membersMeta = await membersStore.getMeta(tn); + const members = membersMeta?.members ?? []; return { success: true, @@ -3896,6 +3936,7 @@ async function handleGetSavedRequest( cwd: meta.cwd, prompt: meta.prompt, providerId: meta.providerId ?? 'anthropic', + providerBackendId: meta.providerBackendId ?? membersMeta?.providerBackendId, model: meta.model, effort: meta.effort as TeamCreateRequest['effort'], skipPermissions: meta.skipPermissions, diff --git a/src/main/services/runtime/ProviderConnectionService.ts b/src/main/services/runtime/ProviderConnectionService.ts index 34b3fb3f..08c1451a 100644 --- a/src/main/services/runtime/ProviderConnectionService.ts +++ b/src/main/services/runtime/ProviderConnectionService.ts @@ -74,7 +74,8 @@ export class ProviderConnectionService { async applyConfiguredConnectionEnv( env: NodeJS.ProcessEnv, - providerId: CliProviderId + providerId: CliProviderId, + runtimeBackendOverride?: string | null ): Promise { if (providerId === 'anthropic') { const authMode = this.getConfiguredAuthMode(providerId); @@ -109,8 +110,8 @@ export class ProviderConnectionService { } const codexConnection = this.configManager.getConfig().providerConnections.codex; - const codexRuntimeBackend = this.getConfiguredCodexRuntimeBackend(); - if (!this.shouldExposeCodexConnectionModes()) { + const codexRuntimeBackend = this.getConfiguredCodexRuntimeBackend(runtimeBackendOverride); + if (!this.shouldExposeCodexConnectionModes(runtimeBackendOverride)) { delete env[CODEX_API_KEY_BETA_ENV_VAR]; delete env.OPENAI_API_KEY; delete env[CODEX_NATIVE_API_KEY_ENV_VAR]; @@ -172,7 +173,8 @@ export class ProviderConnectionService { async augmentConfiguredConnectionEnv( env: NodeJS.ProcessEnv, - providerId: CliProviderId + providerId: CliProviderId, + runtimeBackendOverride?: string | null ): Promise { if (providerId === 'anthropic') { if (this.getConfiguredAuthMode(providerId) !== 'api_key') { @@ -191,8 +193,8 @@ export class ProviderConnectionService { } const codexConnection = this.configManager.getConfig().providerConnections.codex; - const codexRuntimeBackend = this.getConfiguredCodexRuntimeBackend(); - if (!this.shouldExposeCodexConnectionModes()) { + const codexRuntimeBackend = this.getConfiguredCodexRuntimeBackend(runtimeBackendOverride); + if (!this.shouldExposeCodexConnectionModes(runtimeBackendOverride)) { return env; } @@ -248,7 +250,8 @@ export class ProviderConnectionService { async getConfiguredConnectionIssue( env: NodeJS.ProcessEnv, - providerId: CliProviderId + providerId: CliProviderId, + runtimeBackendOverride?: string | null ): Promise { if (providerId === 'anthropic') { if (this.getConfiguredAuthMode(providerId) !== 'api_key') { @@ -270,8 +273,11 @@ export class ProviderConnectionService { } const codexConnection = this.configManager.getConfig().providerConnections.codex; - const codexRuntimeBackend = this.getConfiguredCodexRuntimeBackend(); - if (!this.shouldExposeCodexConnectionModes() || codexConnection.authMode !== 'api_key') { + const codexRuntimeBackend = this.getConfiguredCodexRuntimeBackend(runtimeBackendOverride); + if ( + !this.shouldExposeCodexConnectionModes(runtimeBackendOverride) || + codexConnection.authMode !== 'api_key' + ) { return null; } @@ -294,12 +300,17 @@ export class ProviderConnectionService { async getConfiguredConnectionIssues( env: NodeJS.ProcessEnv, - providerIds: readonly CliProviderId[] = ['anthropic', 'codex', 'gemini'] + providerIds: readonly CliProviderId[] = ['anthropic', 'codex', 'gemini'], + runtimeBackendOverrides?: Partial> ): Promise>> { const issues: Partial> = {}; for (const providerId of providerIds) { - const issue = await this.getConfiguredConnectionIssue(env, providerId); + const issue = await this.getConfiguredConnectionIssue( + env, + providerId, + runtimeBackendOverrides?.[providerId] + ); if (issue) { issues[providerId] = issue; } @@ -369,15 +380,25 @@ export class ProviderConnectionService { return this.apiKeyService.lookupPreferred(envVarName); } - private getConfiguredCodexRuntimeBackend(): 'auto' | 'adapter' | 'api' | 'codex-native' { + private getConfiguredCodexRuntimeBackend( + runtimeBackendOverride?: string | null + ): 'auto' | 'adapter' | 'api' | 'codex-native' { + if ( + runtimeBackendOverride === 'auto' || + runtimeBackendOverride === 'adapter' || + runtimeBackendOverride === 'api' || + runtimeBackendOverride === CODEX_NATIVE_BACKEND_ID + ) { + return runtimeBackendOverride; + } return this.configManager.getConfig().runtime.providerBackends.codex; } - private shouldExposeCodexConnectionModes(): boolean { + private shouldExposeCodexConnectionModes(runtimeBackendOverride?: string | null): boolean { const config = this.configManager.getConfig(); return ( config.providerConnections.codex.apiKeyBetaEnabled || - config.runtime.providerBackends.codex === CODEX_NATIVE_BACKEND_ID + this.getConfiguredCodexRuntimeBackend(runtimeBackendOverride) === CODEX_NATIVE_BACKEND_ID ); } diff --git a/src/main/services/runtime/providerAwareCliEnv.ts b/src/main/services/runtime/providerAwareCliEnv.ts index 2793d710..bc5e4a93 100644 --- a/src/main/services/runtime/providerAwareCliEnv.ts +++ b/src/main/services/runtime/providerAwareCliEnv.ts @@ -17,6 +17,7 @@ type ProviderEnvTargetId = CliProviderId | TeamProviderId | undefined; export interface ProviderAwareCliEnvOptions { binaryPath?: string | null; providerId?: ProviderEnvTargetId; + providerBackendId?: string | null; shellEnv?: NodeJS.ProcessEnv | null; env?: NodeJS.ProcessEnv; connectionMode?: 'strict' | 'augment'; @@ -71,21 +72,39 @@ export async function buildProviderAwareCliEnv( if (options.providerId) { const resolvedProviderId = resolveTeamProviderId(options.providerId); applyProviderRuntimeEnv(env, options.providerId); + if (resolvedProviderId === 'codex' && options.providerBackendId?.trim()) { + env.CLAUDE_CODE_CODEX_BACKEND = options.providerBackendId.trim(); + } + if (resolvedProviderId === 'gemini' && options.providerBackendId?.trim()) { + env.CLAUDE_CODE_GEMINI_BACKEND = options.providerBackendId.trim(); + } if (connectionMode === 'augment') { - await providerConnectionService.augmentConfiguredConnectionEnv(env, resolvedProviderId); + await providerConnectionService.augmentConfiguredConnectionEnv( + env, + resolvedProviderId, + options.providerBackendId + ); return { env, connectionIssues: {}, }; } - await providerConnectionService.applyConfiguredConnectionEnv(env, resolvedProviderId); + await providerConnectionService.applyConfiguredConnectionEnv( + env, + resolvedProviderId, + options.providerBackendId + ); return { env, - connectionIssues: await providerConnectionService.getConfiguredConnectionIssues(env, [ - resolvedProviderId, - ]), + connectionIssues: await providerConnectionService.getConfiguredConnectionIssues( + env, + [resolvedProviderId], + resolvedProviderId === 'codex' || resolvedProviderId === 'gemini' + ? { [resolvedProviderId]: options.providerBackendId?.trim() || undefined } + : undefined + ), }; } diff --git a/src/main/services/team/TeamBackupService.ts b/src/main/services/team/TeamBackupService.ts index 707ae2be..932a3a09 100644 --- a/src/main/services/team/TeamBackupService.ts +++ b/src/main/services/team/TeamBackupService.ts @@ -58,6 +58,7 @@ const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024; const TEAM_ROOT_FILES = [ 'config.json', + 'team.meta.json', 'kanban-state.json', 'sentMessages.json', 'sent-cross-team.json', diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 5d29e6db..5809c294 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -2319,6 +2319,7 @@ export class TeamDataService { description: request.description, color: request.color, cwd: request.cwd?.trim() || '', + providerBackendId: request.providerBackendId, createdAt: joinedAt, }); @@ -2349,7 +2350,10 @@ export class TeamDataService { agentType: 'general-purpose', color: getMemberColorByName(member.name.trim()), joinedAt, - })) + })), + { + providerBackendId: request.providerBackendId, + } ); } diff --git a/src/main/services/team/TeamMembersMetaStore.ts b/src/main/services/team/TeamMembersMetaStore.ts index 4baf9776..064b0273 100644 --- a/src/main/services/team/TeamMembersMetaStore.ts +++ b/src/main/services/team/TeamMembersMetaStore.ts @@ -9,13 +9,22 @@ import { atomicWriteAsync } from './atomicWrite'; import type { TeamMember } from '@shared/types'; -interface TeamMembersMetaFile { +export interface TeamMembersMetaFile { version: 1; + providerBackendId?: string; members: TeamMember[]; } const MAX_META_FILE_BYTES = 256 * 1024; +function normalizeOptionalBackendId(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + function normalizeMember(member: TeamMember): TeamMember | null { const trimmedName = member.name?.trim(); if (!trimmedName) { @@ -45,15 +54,15 @@ export class TeamMembersMetaStore { return path.join(getTeamsBasePath(), teamName, 'members.meta.json'); } - async getMembers(teamName: string): Promise { + async getMeta(teamName: string): Promise { const metaPath = this.getMetaPath(teamName); try { const stat = await fs.promises.stat(metaPath); if (!stat.isFile()) { - return []; + return null; } if (stat.isFile() && stat.size > MAX_META_FILE_BYTES) { - return []; + return null; } } catch { // ignore - readFile below will handle ENOENT and throw on other errors @@ -63,10 +72,10 @@ export class TeamMembersMetaStore { raw = await readFileUtf8WithTimeout(metaPath, 5_000); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return []; + return null; } if (error instanceof FileReadTimeoutError) { - return []; + return null; } throw error; } @@ -75,15 +84,15 @@ export class TeamMembersMetaStore { try { parsed = JSON.parse(raw) as unknown; } catch { - return []; + return null; } if (!parsed || typeof parsed !== 'object') { - return []; + return null; } const file = parsed as Partial; if (!Array.isArray(file.members)) { - return []; + return null; } const deduped = new Map(); @@ -107,10 +116,22 @@ export class TeamMembersMetaStore { } } - return Array.from(deduped.values()).sort((a, b) => a.name.localeCompare(b.name)); + return { + version: 1, + providerBackendId: normalizeOptionalBackendId(file.providerBackendId), + members: Array.from(deduped.values()).sort((a, b) => a.name.localeCompare(b.name)), + }; } - async writeMembers(teamName: string, members: TeamMember[]): Promise { + async getMembers(teamName: string): Promise { + return (await this.getMeta(teamName))?.members ?? []; + } + + async writeMembers( + teamName: string, + members: TeamMember[], + options?: { providerBackendId?: string } + ): Promise { const deduped = new Map(); for (const member of members) { const normalized = normalizeMember(member); @@ -131,6 +152,7 @@ export class TeamMembersMetaStore { const payload: TeamMembersMetaFile = { version: 1, + providerBackendId: normalizeOptionalBackendId(options?.providerBackendId), members: Array.from(deduped.values()).sort((a, b) => a.name.localeCompare(b.name)), }; diff --git a/src/main/services/team/TeamMetaStore.ts b/src/main/services/team/TeamMetaStore.ts index a8bce4dc..a27fc165 100644 --- a/src/main/services/team/TeamMetaStore.ts +++ b/src/main/services/team/TeamMetaStore.ts @@ -19,6 +19,7 @@ export interface TeamMetaFile { cwd: string; prompt?: string; providerId?: 'anthropic' | 'codex' | 'gemini'; + providerBackendId?: string; model?: string; effort?: string; skipPermissions?: boolean; @@ -30,6 +31,14 @@ export interface TeamMetaFile { const MAX_META_FILE_BYTES = 256 * 1024; +function normalizeOptionalBackendId(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + export class TeamMetaStore { private getMetaPath(teamName: string): string { return path.join(getTeamsBasePath(), teamName, 'team.meta.json'); @@ -89,6 +98,7 @@ export class TeamMetaStore { file.providerId === 'gemini' ? file.providerId : undefined, + providerBackendId: normalizeOptionalBackendId(file.providerBackendId), model: typeof file.model === 'string' ? file.model.trim() || undefined : undefined, effort: typeof file.effort === 'string' ? file.effort.trim() || undefined : undefined, skipPermissions: typeof file.skipPermissions === 'boolean' ? file.skipPermissions : undefined, @@ -109,6 +119,7 @@ export class TeamMetaStore { cwd: data.cwd.trim(), prompt: data.prompt?.trim() || undefined, providerId: data.providerId, + providerBackendId: normalizeOptionalBackendId(data.providerBackendId), model: data.model?.trim() || undefined, effort: data.effort?.trim() || undefined, skipPermissions: data.skipPermissions, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 5bf9f284..5c655f93 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -408,7 +408,7 @@ function mergeProvisioningWarnings( } function buildRuntimeLaunchWarning( - request: Pick, + request: Pick, env: NodeJS.ProcessEnv, options?: { geminiRuntimeAuth?: GeminiRuntimeAuthState | null; @@ -420,7 +420,7 @@ function buildRuntimeLaunchWarning( const providerLabel = getTeamProviderLabel(providerId); const modelLabel = request.model?.trim() || 'default'; const effortLabel = request.effort ?? 'default'; - const backend = getConfiguredRuntimeBackend(providerId); + const backend = request.providerBackendId?.trim() || getConfiguredRuntimeBackend(providerId); const flags: string[] = []; if (env.CLAUDE_CODE_USE_GEMINI === '1') flags.push('USE_GEMINI'); if (env.CLAUDE_CODE_USE_OPENAI === '1') flags.push('USE_OPENAI'); @@ -455,7 +455,7 @@ function logRuntimeLaunchSnapshot( teamName: string, claudePath: string, args: string[], - request: Pick, + request: Pick, env: NodeJS.ProcessEnv, options?: { geminiRuntimeAuth?: GeminiRuntimeAuthState | null; @@ -466,9 +466,10 @@ function logRuntimeLaunchSnapshot( const providerId = resolveTeamProviderId(request.providerId); const snapshot = { providerId, + providerBackendId: request.providerBackendId ?? null, model: request.model ?? null, effort: request.effort ?? null, - configuredBackend: getConfiguredRuntimeBackend(providerId), + configuredBackend: request.providerBackendId?.trim() || getConfiguredRuntimeBackend(providerId), promptSize: options?.promptSize ?? null, expectedMembersCount: options?.expectedMembersCount ?? null, geminiRuntimeAuth: @@ -1149,14 +1150,24 @@ function buildEffectiveTeamMemberSpecs( } function shouldSkipResumeForProviderRuntimeChange( - request: Pick, - config: Record + request: Pick, + config: Record, + persistedProviderBackendId?: string | null ): { skip: boolean; reason?: string } { const providerId = normalizeTeamMemberProviderId(request.providerId); if (providerId !== 'gemini' && providerId !== 'codex') { return { skip: false }; } + const requestedBackendId = request.providerBackendId?.trim() || null; + const previousBackendId = persistedProviderBackendId?.trim() || null; + if (requestedBackendId && previousBackendId && requestedBackendId !== previousBackendId) { + return { + skip: true, + reason: `runtime backend changed (${previousBackendId} -> ${requestedBackendId})`, + }; + } + const members = Array.isArray(config.members) ? (config.members as Record[]) : []; @@ -4193,6 +4204,7 @@ export class TeamProvisioningService { const updatedAt = nowIso(); const runId = this.getTrackedRunId(teamName); const run = runId ? (this.runs.get(runId) ?? null) : null; + const persistedTeamMeta = await this.teamMetaStore.getMeta(teamName).catch(() => null); let configuredMembers: TeamConfig['members'] = []; try { @@ -4294,6 +4306,7 @@ export class TeamProvisioningService { teamName, updatedAt, runId: run?.runId ?? null, + providerBackendId: run?.request.providerBackendId ?? persistedTeamMeta?.providerBackendId, members: snapshotMembers, }; @@ -5838,7 +5851,10 @@ export class TeamProvisioningService { throw new Error('Claude CLI not found; install it or provide a valid path'); } - const provisioningEnv = await this.buildProvisioningEnv(request.providerId); + const provisioningEnv = await this.buildProvisioningEnv( + request.providerId, + request.providerBackendId + ); const { env: shellEnv, geminiRuntimeAuth, warning: envWarning } = provisioningEnv; if (envWarning) { throw new Error(envWarning); @@ -6042,6 +6058,7 @@ export class TeamProvisioningService { cwd: request.cwd, prompt: request.prompt, providerId: request.providerId, + providerBackendId: request.providerBackendId, model: request.model, effort: request.effort, skipPermissions: request.skipPermissions, @@ -6065,7 +6082,10 @@ export class TeamProvisioningService { agentType: 'general-purpose' as const, color: getMemberColorByName(m.name.trim()), joinedAt: Date.now(), - })) + })), + { + providerBackendId: request.providerBackendId, + } ); if (request.skipPermissions === false) { await this.seedTeammateOperationalPermissionRules(request.teamName, request.cwd); @@ -6310,7 +6330,14 @@ export class TeamProvisioningService { if (!skipResume) { try { const configParsed = JSON.parse(configRaw) as Record; - const resumeGuard = shouldSkipResumeForProviderRuntimeChange(request, configParsed); + const persistedTeamMeta = await this.teamMetaStore + .getMeta(request.teamName) + .catch(() => null); + const resumeGuard = shouldSkipResumeForProviderRuntimeChange( + request, + configParsed, + persistedTeamMeta?.providerBackendId ?? null + ); if (resumeGuard.skip) { logger.info( `[${request.teamName}] Skipping session resume — ${resumeGuard.reason ?? 'runtime changed'}` @@ -6395,7 +6422,10 @@ export class TeamProvisioningService { const runId = randomUUID(); const startedAt = nowIso(); - const provisioningEnv = await this.buildProvisioningEnv(request.providerId); + const provisioningEnv = await this.buildProvisioningEnv( + request.providerId, + request.providerBackendId + ); const { env: shellEnv, geminiRuntimeAuth, warning: envWarning } = provisioningEnv; if (envWarning) { throw new Error(envWarning); @@ -6421,6 +6451,7 @@ export class TeamProvisioningService { members: effectiveMemberSpecs, cwd: request.cwd, providerId: request.providerId, + providerBackendId: request.providerBackendId, model: request.model, effort: request.effort, skipPermissions: request.skipPermissions, @@ -6643,6 +6674,42 @@ export class TeamProvisioningService { }); // --resume is added above when a valid previous session JSONL exists. // Without it, CLI creates a fresh session ID automatically. + await this.teamMetaStore.writeMeta(request.teamName, { + displayName: syntheticRequest.displayName, + description: syntheticRequest.description, + color: syntheticRequest.color, + cwd: request.cwd, + prompt: request.prompt, + providerId: request.providerId, + providerBackendId: request.providerBackendId, + model: request.model, + effort: request.effort, + skipPermissions: request.skipPermissions, + worktree: request.worktree, + extraCliArgs: request.extraCliArgs, + limitContext: request.limitContext, + createdAt: Date.now(), + }); + await this.membersMetaStore.writeMembers( + request.teamName, + effectiveMemberSpecs.map((member) => ({ + name: member.name.trim(), + role: member.role?.trim() || undefined, + workflow: member.workflow?.trim() || undefined, + providerId: normalizeOptionalTeamProviderId(member.providerId), + model: member.model?.trim() || undefined, + effort: + member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' + ? member.effort + : undefined, + agentType: 'general-purpose', + color: getMemberColorByName(member.name.trim()), + joinedAt: Date.now(), + })), + { + providerBackendId: request.providerBackendId, + } + ); try { if (request.skipPermissions === false) { @@ -11253,9 +11320,6 @@ export class TeamProvisioningService { } ); - // Clean up team.meta.json — provisioning succeeded, config.json is now authoritative. - await this.teamMetaStore.deleteMeta(run.teamName).catch(() => {}); - // Audit: flag any expected member not registered in config.json after provisioning. await this.refreshMemberSpawnStatusesFromLeadInbox(run); await this.maybeAuditMemberSpawnStatuses(run, { force: true }); @@ -12182,7 +12246,8 @@ export class TeamProvisioningService { } private async buildProvisioningEnv( - providerId: TeamProviderId | undefined = 'anthropic' + providerId: TeamProviderId | undefined = 'anthropic', + providerBackendId?: string | null ): Promise { const shellEnv = await resolveInteractiveShellEnv(); // getHomeDir() uses Electron's app.getPath('home') which handles Unicode @@ -12228,6 +12293,7 @@ export class TeamProvisioningService { const resolvedProviderId = resolveTeamProviderId(providerId); const providerEnvResult = await buildProviderAwareCliEnv({ providerId, + providerBackendId, shellEnv, env, }); @@ -13059,7 +13125,10 @@ export class TeamProvisioningService { agentType: 'general-purpose', color: getMemberColorByName(member.name.trim()), joinedAt, - })) + })), + { + providerBackendId: request.providerBackendId, + } ); } catch (error) { logger.warn( diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index f6216327..ccb965d5 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -41,6 +41,7 @@ import { normalizeCreateLaunchProviderForUi, } from '@renderer/utils/geminiUiFreeze'; import { normalizePath } from '@renderer/utils/pathNormalize'; +import { resolveEffectiveProviderBackendId } from '@renderer/utils/providerBackendIdentity'; import { getTeamModelSelectionError, normalizeExplicitTeamModelForUi, @@ -972,6 +973,9 @@ export const CreateTeamDialog = ({ cwd: effectiveCwd, prompt: prompt.trim() || undefined, providerId: selectedProviderId, + providerBackendId: + resolveEffectiveProviderBackendId(runtimeProviderStatusById.get(selectedProviderId)) ?? + undefined, model: effectiveModel, effort: (selectedEffort as EffortLevel) || undefined, limitContext, @@ -988,6 +992,7 @@ export const CreateTeamDialog = ({ effectiveCwd, prompt, selectedProviderId, + runtimeProviderStatusById, effectiveModel, selectedEffort, limitContext, diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 7574de53..beb307f4 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -45,6 +45,7 @@ import { normalizeCreateLaunchProviderForUi, } from '@renderer/utils/geminiUiFreeze'; import { normalizePath } from '@renderer/utils/pathNormalize'; +import { resolveEffectiveProviderBackendId } from '@renderer/utils/providerBackendIdentity'; import { nameColorSet } from '@renderer/utils/projectColor'; import { getTeamModelSelectionError, @@ -319,6 +320,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ); const members = isLaunch ? props.members : storeMembers; const [savedLaunchProviderId, setSavedLaunchProviderId] = useState(null); + const [savedLaunchProviderBackendId, setSavedLaunchProviderBackendId] = useState( + null + ); // Advanced CLI section state (with localStorage persistence) const [worktreeEnabled, setWorktreeEnabledRaw] = useState( @@ -623,6 +627,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen : savedRequest?.providerId === 'anthropic' ? 'anthropic' : null; + const savedProviderBackendId = + typeof savedRequest?.providerBackendId === 'string' && + savedRequest.providerBackendId.trim().length > 0 + ? savedRequest.providerBackendId.trim() + : null; const storedProviderId = normalizeProviderForMode(getStoredTeamProvider(), multimodelEnabled); const launchPrefill = resolveLaunchDialogPrefill({ members, @@ -635,6 +644,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen getStoredModel: getStoredTeamModel, }); setSavedLaunchProviderId(savedProviderId); + setSavedLaunchProviderBackendId( + launchPrefill.providerBackendId ?? savedProviderBackendId ?? null + ); setMembersDrafts( createMemberDraftsFromInputs(editableMembersSource).map((member) => @@ -1390,6 +1402,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen cwd: effectiveCwd, prompt: promptDraft.value.trim() || undefined, providerId: selectedProviderId, + providerBackendId: + resolveEffectiveProviderBackendId( + runtimeProviderStatusById.get(selectedProviderId) + ) ?? + previousLaunchParams?.providerBackendId ?? + savedLaunchProviderBackendId ?? + undefined, model: computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId), effort: (selectedEffort as EffortLevel) || undefined, limitContext, diff --git a/src/renderer/components/team/dialogs/launchDialogPrefill.ts b/src/renderer/components/team/dialogs/launchDialogPrefill.ts index 242bfb42..65a908b7 100644 --- a/src/renderer/components/team/dialogs/launchDialogPrefill.ts +++ b/src/renderer/components/team/dialogs/launchDialogPrefill.ts @@ -8,6 +8,7 @@ import type { ResolvedTeamMember, TeamCreateRequest, TeamProviderId } from '@sha interface PreviousLaunchParamsLike { providerId?: TeamProviderId; + providerBackendId?: string; model?: string; effort?: string; limitContext?: boolean; @@ -26,6 +27,7 @@ interface LaunchDialogPrefillInput { interface LaunchDialogPrefillResult { providerId: TeamProviderId; + providerBackendId?: string; model: string; effort: string; limitContext: boolean; @@ -101,6 +103,10 @@ export function resolveLaunchDialogPrefill({ return { providerId, + providerBackendId: + previousLaunchParams?.providerBackendId?.trim() || + savedRequest?.providerBackendId?.trim() || + undefined, model: matchingModel ? normalizeExplicitTeamModelForUi(providerId, matchingModel) : getStoredModel(providerId), diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index ece0043c..a9785ecd 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -166,6 +166,7 @@ function areLaunchParamsEquivalent( if (!left || !right) return left === right; return ( left.providerId === right.providerId && + left.providerBackendId === right.providerBackendId && left.model === right.model && left.effort === right.effort ); diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 0b249482..5ec1d41f 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -1523,6 +1523,7 @@ export interface GlobalTaskDetailState { /** Per-team launch parameters shown in the header badge. */ export interface TeamLaunchParams { providerId?: 'anthropic' | 'codex' | 'gemini'; + providerBackendId?: string; model?: string; // 'opus' | 'sonnet' | 'haiku' effort?: EffortLevel; limitContext?: boolean; @@ -4419,6 +4420,7 @@ export const createTeamSlice: StateCreator = (set, const baseModel = extractBaseModel(request.model, request.providerId); const params: TeamLaunchParams = { providerId: request.providerId ?? 'anthropic', + providerBackendId: request.providerBackendId, model: baseModel || 'default', effort: request.effort, limitContext: request.limitContext ?? false, @@ -4590,6 +4592,7 @@ export const createTeamSlice: StateCreator = (set, const baseModel = extractBaseModel(request.model, request.providerId); const params: TeamLaunchParams = { providerId: request.providerId ?? 'anthropic', + providerBackendId: request.providerBackendId, model: baseModel || 'default', effort: request.effort, limitContext: request.limitContext ?? false, diff --git a/src/renderer/utils/memberRuntimeSummary.ts b/src/renderer/utils/memberRuntimeSummary.ts index 45626882..9151f6b2 100644 --- a/src/renderer/utils/memberRuntimeSummary.ts +++ b/src/renderer/utils/memberRuntimeSummary.ts @@ -1,5 +1,6 @@ import { formatTeamModelSummary } from '@renderer/components/team/dialogs/TeamModelSelector'; import { formatBytes } from '@renderer/utils/formatters'; +import { formatTeamProviderBackendLabel } from '@renderer/utils/providerBackendIdentity'; import { inferTeamProviderIdFromModel } from '@shared/utils/teamProvider'; import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice'; @@ -34,6 +35,10 @@ export function resolveMemberRuntimeSummary( const configuredModel = member.model?.trim() || launchParams?.model?.trim() || ''; const configuredEffort = member.effort ?? launchParams?.effort; const runtimeModel = spawnEntry?.runtimeModel?.trim() || runtimeEntry?.runtimeModel?.trim(); + const backendLabel = formatTeamProviderBackendLabel( + configuredProvider, + launchParams?.providerBackendId + ); const memorySuffix = typeof runtimeEntry?.rssBytes === 'number' && runtimeEntry.rssBytes > 0 ? ` · ${formatBytes(runtimeEntry.rssBytes)}` @@ -41,12 +46,14 @@ export function resolveMemberRuntimeSummary( if (runtimeModel && (isMemberLaunchPending(spawnEntry) || configuredModel.length === 0)) { const runtimeProvider = inferTeamProviderIdFromModel(runtimeModel) ?? configuredProvider; - return `${formatTeamModelSummary(runtimeProvider, runtimeModel, configuredEffort)}${memorySuffix}`; + const summary = formatTeamModelSummary(runtimeProvider, runtimeModel, configuredEffort); + return `${summary}${backendLabel ? ` · ${backendLabel}` : ''}${memorySuffix}`; } if (isMemberLaunchPending(spawnEntry)) { return undefined; } - return `${formatTeamModelSummary(configuredProvider, configuredModel, configuredEffort)}${memorySuffix}`; + const summary = formatTeamModelSummary(configuredProvider, configuredModel, configuredEffort); + return `${summary}${backendLabel ? ` · ${backendLabel}` : ''}${memorySuffix}`; } diff --git a/src/renderer/utils/providerBackendIdentity.ts b/src/renderer/utils/providerBackendIdentity.ts new file mode 100644 index 00000000..2346c71b --- /dev/null +++ b/src/renderer/utils/providerBackendIdentity.ts @@ -0,0 +1,53 @@ +import type { CliProviderStatus, TeamProviderId } from '@shared/types'; + +function normalizeOptionalBackendId(value: string | null | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +export function resolveEffectiveProviderBackendId( + provider: Pick | null | undefined +): string | undefined { + return normalizeOptionalBackendId(provider?.resolvedBackendId ?? provider?.selectedBackendId); +} + +export function formatTeamProviderBackendLabel( + providerId: TeamProviderId | undefined, + providerBackendId: string | undefined +): string | undefined { + const normalizedProviderId = providerId ?? 'anthropic'; + const normalizedBackendId = normalizeOptionalBackendId(providerBackendId); + if (!normalizedBackendId) { + return undefined; + } + + if (normalizedProviderId === 'codex') { + switch (normalizedBackendId) { + case 'codex-native': + return 'Codex native'; + case 'adapter': + return 'Default adapter'; + case 'api': + return 'OpenAI API'; + case 'auto': + return undefined; + default: + return normalizedBackendId; + } + } + + if (normalizedProviderId === 'gemini') { + switch (normalizedBackendId) { + case 'cli-sdk': + return 'CLI SDK'; + case 'api': + return 'API'; + case 'auto': + return undefined; + default: + return normalizedBackendId; + } + } + + return normalizedBackendId; +} diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 195fc79d..9748313d 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -784,12 +784,14 @@ export interface TeamViewSnapshot { export type EffortLevel = 'low' | 'medium' | 'high'; export type TeamProviderId = 'anthropic' | 'codex' | 'gemini'; +export type TeamProviderBackendId = string; export interface TeamLaunchRequest { teamName: string; cwd: string; prompt?: string; providerId?: TeamProviderId; + providerBackendId?: TeamProviderBackendId; model?: string; effort?: EffortLevel; /** When true, context window is limited to 200K tokens instead of the default. */ @@ -928,6 +930,7 @@ export interface TeamAgentRuntimeSnapshot { teamName: string; updatedAt: string; runId: string | null; + providerBackendId?: TeamProviderBackendId; members: Record; } @@ -1037,6 +1040,7 @@ export interface TeamCreateRequest { cwd: string; prompt?: string; providerId?: TeamProviderId; + providerBackendId?: TeamProviderBackendId; model?: string; effort?: EffortLevel; /** When true, context window is limited to 200K tokens instead of the default. */ @@ -1056,6 +1060,7 @@ export interface TeamCreateConfigRequest { color?: string; members: TeamProvisioningMemberInput[]; cwd?: string; + providerBackendId?: TeamProviderBackendId; } export interface TeamCreateResponse { diff --git a/test/main/services/runtime/providerAwareCliEnv.test.ts b/test/main/services/runtime/providerAwareCliEnv.test.ts index 0bcada35..666f9de5 100644 --- a/test/main/services/runtime/providerAwareCliEnv.test.ts +++ b/test/main/services/runtime/providerAwareCliEnv.test.ts @@ -98,7 +98,8 @@ describe('buildProviderAwareCliEnv', () => { CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: '1', CLAUDE_CODE_ENTRY_PROVIDER: 'anthropic', }), - 'anthropic' + 'anthropic', + undefined ); expect(result.connectionIssues).toEqual({ anthropic: 'missing key', @@ -163,7 +164,8 @@ describe('buildProviderAwareCliEnv', () => { HOME: '/Users/electron-home', USERPROFILE: '/Users/electron-home', }), - 'anthropic' + 'anthropic', + undefined ); expect(result.env.HOME).toBe('/Users/electron-home'); expect(result.env.USERPROFILE).toBe('/Users/electron-home'); @@ -208,7 +210,8 @@ describe('buildProviderAwareCliEnv', () => { CLAUDE_CODE_CODEX_BACKEND: 'adapter', CLAUDE_CODE_CODEX_NATIVE_INTERNAL_UNLOCK: '1', }), - 'codex' + 'codex', + undefined ); expect(result.env.CLAUDE_CODE_CODEX_NATIVE_INTERNAL_UNLOCK).toBe('1'); }); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 232f910e..5a09ba9d 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -442,6 +442,50 @@ describe('TeamProvisioningService', () => { }); }); + it('exposes providerBackendId from the live run request when available', async () => { + const svc = new TeamProvisioningService(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + members: [{ name: 'team-lead', agentType: 'team-lead' }], + })), + }; + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ providerBackendId: 'adapter' })), + }; + (svc as any).aliveRunByTeam.set('runtime-team', 'run-1'); + (svc as any).runs.set('run-1', { + runId: 'run-1', + child: { pid: 111 }, + request: { model: 'gpt-5.4', providerBackendId: 'codex-native' }, + processKilled: false, + cancelRequested: false, + spawnContext: null, + }); + vi.mocked(pidusage).mockResolvedValueOnce({ + '111': createPidusageStat(111, 123_000_000), + } as any); + + const snapshot = await svc.getTeamAgentRuntimeSnapshot('runtime-team'); + + expect(snapshot.providerBackendId).toBe('codex-native'); + }); + + it('falls back to persisted team meta backend when no live run exists', async () => { + const svc = new TeamProvisioningService(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + members: [{ name: 'team-lead', agentType: 'team-lead' }], + })), + }; + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ providerBackendId: 'codex-native' })), + }; + + const snapshot = await svc.getTeamAgentRuntimeSnapshot('runtime-team'); + + expect(snapshot.providerBackendId).toBe('codex-native'); + }); + it('falls back to per-pid pidusage reads when batched sampling fails', async () => { const svc = new TeamProvisioningService(); (svc as any).configReader = { @@ -969,6 +1013,63 @@ describe('TeamProvisioningService', () => { expect(launchArgs).toContain(leadSessionId); }); + it('skips --resume when the persisted runtime backend lane changed', async () => { + allowConsoleLogs(); + const teamName = 'resume-backend-change-team'; + const leadSessionId = 'lead-session-backend-change'; + writeLaunchConfig(teamName, tempClaudeRoot, leadSessionId, ['alice']); + + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude'); + vi.mocked(spawnCli).mockImplementation(() => { + throw new Error('launch spawn EINVAL'); + }); + + const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, { + writeConfigFile: vi.fn(async () => '/mock/mcp-config-launch.json'), + removeConfigFile: vi.fn(async () => {}), + } as any); + (svc as any).buildProvisioningEnv = vi.fn(async () => ({ + env: { CODEX_API_KEY: 'test' }, + authSource: 'codex_runtime', + })); + (svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({ + members: [{ name: 'alice' }], + source: 'members-meta', + warning: undefined, + })); + (svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {}); + (svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {}); + (svc as any).updateConfigProjectPath = vi.fn(async () => {}); + (svc as any).restorePrelaunchConfig = vi.fn(async () => {}); + (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {}); + (svc as any).pathExists = vi.fn(async (targetPath: string) => + targetPath.endsWith(`${leadSessionId}.jsonl`) + ); + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ providerBackendId: 'adapter' })), + writeMeta: vi.fn(async () => {}), + deleteMeta: vi.fn(async () => {}), + }; + + await expect( + svc.launchTeam( + { + teamName, + cwd: tempClaudeRoot, + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + }, + () => {} + ) + ).rejects.toThrow('launch spawn EINVAL'); + + const launchArgs = vi.mocked(spawnCli).mock.calls.at(-1)?.[1] as string[]; + expect(launchArgs).toBeTruthy(); + expect(launchArgs).not.toContain('--resume'); + expect(launchArgs).not.toContain(leadSessionId); + }); + it('seeds the current lead session id immediately when launch resumes an existing session', async () => { allowConsoleLogs(); const teamName = 'resume-seed-session-team'; diff --git a/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts b/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts index 7c6d2aef..5a75d5de 100644 --- a/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts +++ b/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts @@ -101,6 +101,7 @@ describe('resolveLaunchDialogPrefill', () => { savedRequest: null, previousLaunchParams: { providerId: 'codex', + providerBackendId: 'codex-native', model: 'gpt-5.3-codex', effort: 'high', }, @@ -116,12 +117,45 @@ describe('resolveLaunchDialogPrefill', () => { expect(result).toEqual({ providerId: 'codex', + providerBackendId: 'codex-native', model: 'gpt-5.3-codex', effort: 'high', limitContext: false, }); }); + it('falls back to a saved request backend lane when no previous launch params exist', () => { + const result = resolveLaunchDialogPrefill({ + members: [], + savedRequest: { + teamName: 'vector-room-2', + cwd: '/Users/test/project', + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + effort: 'medium', + members: [], + } as TeamCreateRequest, + previousLaunchParams: undefined, + multimodelEnabled: true, + storedProviderId: 'anthropic', + storedEffort: 'medium', + storedLimitContext: false, + getStoredModel: createStoredModelGetter({ + anthropic: 'haiku', + codex: 'gpt-5.4', + }), + }); + + expect(result).toEqual({ + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + effort: 'medium', + limitContext: false, + }); + }); + it('does not carry a frozen Gemini model into an Anthropic fallback', () => { const members = [ { diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index 20f77cc5..622d7f79 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -2934,6 +2934,49 @@ describe('teamSlice actions', () => { }); describe('provisioning run scoping', () => { + it('persists providerBackendId into createTeam launch params', async () => { + const store = createSliceStore(); + + await store.getState().createTeam({ + teamName: 'my-team', + cwd: '/tmp/project', + members: [], + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + effort: 'medium', + }); + + expect(store.getState().launchParamsByTeam['my-team']).toEqual({ + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + effort: 'medium', + limitContext: false, + }); + }); + + it('persists providerBackendId into launchTeam launch params', async () => { + const store = createSliceStore(); + + await store.getState().launchTeam({ + teamName: 'my-team', + cwd: '/tmp/project', + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + effort: 'medium', + }); + + expect(store.getState().launchParamsByTeam['my-team']).toEqual({ + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + effort: 'medium', + limitContext: false, + }); + }); + it('rolls back optimistic pending run on early createTeam failure', async () => { const store = createSliceStore(); hoisted.createTeam.mockRejectedValue(new Error('create failed')); diff --git a/test/renderer/utils/memberRuntimeSummary.test.ts b/test/renderer/utils/memberRuntimeSummary.test.ts index 5c04ea15..f19569ac 100644 --- a/test/renderer/utils/memberRuntimeSummary.test.ts +++ b/test/renderer/utils/memberRuntimeSummary.test.ts @@ -80,4 +80,22 @@ describe('resolveMemberRuntimeSummary', () => { '5.4 Mini · Medium · 256.0 MB' ); }); + + it('keeps the persisted backend lane visible in the runtime summary', () => { + const member = createMember({ model: 'gpt-5.4-mini' }); + + expect( + resolveMemberRuntimeSummary( + member, + { + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4-mini', + effort: 'medium', + limitContext: false, + }, + undefined + ) + ).toBe('5.4 Mini · Medium · Codex native'); + }); });