diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 07c33f30..dc41b47d 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -11,7 +11,6 @@ import { canDisplayTaskChangesForOptions, type TaskChangeRequestOptions, } from '@renderer/utils/taskChangeRequest'; -import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext'; import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { getMemberColorByName } from '@shared/constants/memberColors'; @@ -19,7 +18,6 @@ import { DEFAULT_TEAM_GRAPH_LAYOUT_MODE } from '@shared/constants/teamGraphLayou import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; -import { migrateProviderBackendId } from '@shared/utils/providerBackend'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout'; import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId'; @@ -50,6 +48,11 @@ import { mapSendMessageError, shouldInvalidateCachedTeamDataForError, } from '../team/teamErrorPolicies'; +import { + areTeamLaunchParamsEqual, + buildLaunchParamsFromRuntimeRequest, + type TeamLaunchParams, +} from '../team/teamLaunchParams'; import { captureTeamLocalStateEpoch, clearAllTeamLocalStateEpochs, @@ -126,7 +129,6 @@ import type { AddTaskCommentRequest, CreateTaskRequest, CrossTeamSendRequest, - EffortLevel, GlobalTask, InboxMessage, KanbanColumnId, @@ -148,7 +150,6 @@ import type { TeamLaunchRequest, TeamMemberActivityMeta, TeamMemberSnapshot, - TeamProviderId, TeamProvisioningProgress, TeamSummary, TeamTask, @@ -167,6 +168,7 @@ export { selectTeamMemberSnapshotsForName, selectTeamTasksForName, } from '../team/teamDataSelectors'; +export type { TeamLaunchParams } from '../team/teamLaunchParams'; export type { RefreshTeamMessagesHeadResult, TeamMessagesCacheEntry, @@ -1226,16 +1228,6 @@ export interface PendingTeamSectionFocusState { section: TeamSectionTarget; } -/** Per-team launch parameters shown in the header badge. */ -export interface TeamLaunchParams { - providerId?: TeamProviderId; - providerBackendId?: string; - model?: string; // 'opus' | 'sonnet' | 'haiku' - effort?: EffortLevel; - fastMode?: 'inherit' | 'on' | 'off'; - limitContext?: boolean; -} - const resolvedMembersSelectorCache = new Map< string, { @@ -2278,77 +2270,6 @@ function saveLaunchParams(teamName: string, params: TeamLaunchParams): void { } } -/** - * Extract the base model name from the raw model string sent to CLI. - * E.g. 'opus[1m]' → 'opus', 'sonnet' → 'sonnet', undefined → undefined. - */ -function extractBaseModel(raw?: string, providerId?: TeamProviderId): string | undefined { - return extractProviderScopedBaseModel(raw, providerId); -} - -function buildLaunchParamsFromRuntimeRequest( - request: Pick< - TeamCreateRequest, - 'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode' | 'limitContext' - >, - fallback?: TeamLaunchParams -): TeamLaunchParams { - const providerId = request.providerId ?? fallback?.providerId ?? 'anthropic'; - const providerChanged = - request.providerId != null && - fallback?.providerId != null && - request.providerId !== fallback.providerId; - const hasModel = Object.hasOwn(request, 'model'); - const baseModel = - hasModel && typeof request.model === 'string' - ? extractBaseModel(request.model, providerId) - : undefined; - const rawProviderBackendId = Object.hasOwn(request, 'providerBackendId') - ? request.providerBackendId - : providerChanged - ? undefined - : fallback?.providerBackendId; - return { - providerId, - providerBackendId: migrateProviderBackendId(providerId, rawProviderBackendId), - model: hasModel - ? baseModel || 'default' - : (providerChanged ? undefined : fallback?.model) || 'default', - effort: Object.hasOwn(request, 'effort') - ? request.effort - : providerChanged - ? undefined - : fallback?.effort, - fastMode: Object.hasOwn(request, 'fastMode') - ? request.fastMode - : providerChanged - ? undefined - : fallback?.fastMode, - limitContext: - typeof request.limitContext === 'boolean' - ? request.limitContext - : providerChanged - ? false - : (fallback?.limitContext ?? false), - }; -} - -function areTeamLaunchParamsEqual( - left: TeamLaunchParams | undefined, - right: TeamLaunchParams | undefined -): boolean { - if (left === right) return true; - if (!left || !right) return false; - return ( - left.providerId === right.providerId && - left.providerBackendId === right.providerBackendId && - left.model === right.model && - left.effort === right.effort && - left.fastMode === right.fastMode && - left.limitContext === right.limitContext - ); -} - const TOOL_APPROVAL_PREFIX = 'team:toolApprovalSettings:'; function parseToolApprovalSettings(raw: string | null): ToolApprovalSettings { diff --git a/src/renderer/store/team/teamLaunchParams.ts b/src/renderer/store/team/teamLaunchParams.ts new file mode 100644 index 00000000..02ab0f2b --- /dev/null +++ b/src/renderer/store/team/teamLaunchParams.ts @@ -0,0 +1,89 @@ +import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext'; +import { migrateProviderBackendId } from '@shared/utils/providerBackend'; + +import type { + EffortLevel, + TeamCreateRequest, + TeamFastMode, + TeamProviderId, +} from '@shared/types'; + +/** Per-team launch parameters shown in the header badge. */ +export interface TeamLaunchParams { + providerId?: TeamProviderId; + providerBackendId?: string; + model?: string; + effort?: EffortLevel; + fastMode?: TeamFastMode; + limitContext?: boolean; +} + +export function extractBaseModel( + raw?: string, + providerId?: TeamProviderId +): string | undefined { + return extractProviderScopedBaseModel(raw, providerId); +} + +export function buildLaunchParamsFromRuntimeRequest( + request: Pick< + TeamCreateRequest, + 'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode' | 'limitContext' + >, + fallback?: TeamLaunchParams +): TeamLaunchParams { + const providerId = request.providerId ?? fallback?.providerId ?? 'anthropic'; + const providerChanged = + request.providerId != null && + fallback?.providerId != null && + request.providerId !== fallback.providerId; + const hasModel = Object.hasOwn(request, 'model'); + const baseModel = + hasModel && typeof request.model === 'string' + ? extractBaseModel(request.model, providerId) + : undefined; + const rawProviderBackendId = Object.hasOwn(request, 'providerBackendId') + ? request.providerBackendId + : providerChanged + ? undefined + : fallback?.providerBackendId; + return { + providerId, + providerBackendId: migrateProviderBackendId(providerId, rawProviderBackendId), + model: hasModel + ? baseModel || 'default' + : (providerChanged ? undefined : fallback?.model) || 'default', + effort: Object.hasOwn(request, 'effort') + ? request.effort + : providerChanged + ? undefined + : fallback?.effort, + fastMode: Object.hasOwn(request, 'fastMode') + ? request.fastMode + : providerChanged + ? undefined + : fallback?.fastMode, + limitContext: + typeof request.limitContext === 'boolean' + ? request.limitContext + : providerChanged + ? false + : (fallback?.limitContext ?? false), + }; +} + +export function areTeamLaunchParamsEqual( + left: TeamLaunchParams | undefined, + right: TeamLaunchParams | undefined +): boolean { + if (left === right) return true; + if (!left || !right) return false; + return ( + left.providerId === right.providerId && + left.providerBackendId === right.providerBackendId && + left.model === right.model && + left.effort === right.effort && + left.fastMode === right.fastMode && + left.limitContext === right.limitContext + ); +} diff --git a/test/renderer/store/teamLaunchParams.test.ts b/test/renderer/store/teamLaunchParams.test.ts new file mode 100644 index 00000000..fc898ffc --- /dev/null +++ b/test/renderer/store/teamLaunchParams.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest'; + +import { + areTeamLaunchParamsEqual, + buildLaunchParamsFromRuntimeRequest, + extractBaseModel, +} from '../../../src/renderer/store/team/teamLaunchParams'; + +import type { TeamLaunchParams } from '../../../src/renderer/store/team/teamLaunchParams'; + +const codexFallback: TeamLaunchParams = { + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.5', + effort: 'medium', + fastMode: 'on', + limitContext: true, +}; + +describe('teamLaunchParams', () => { + it('extracts provider-scoped base models', () => { + expect(extractBaseModel(' opus[1m] ', 'anthropic')).toBe('opus'); + expect(extractBaseModel('sonnet', 'anthropic')).toBe('sonnet'); + expect(extractBaseModel('gpt-5.5[1m]', 'codex')).toBe('gpt-5.5[1m]'); + expect(extractBaseModel(' ', 'anthropic')).toBeUndefined(); + expect(extractBaseModel(undefined, 'anthropic')).toBeUndefined(); + }); + + it('builds default anthropic launch params without fallback', () => { + expect(buildLaunchParamsFromRuntimeRequest({})).toEqual({ + providerId: 'anthropic', + providerBackendId: undefined, + model: 'default', + effort: undefined, + fastMode: undefined, + limitContext: false, + }); + }); + + it('preserves fallback values for metadata-only requests on the same provider', () => { + expect(buildLaunchParamsFromRuntimeRequest({}, codexFallback)).toEqual(codexFallback); + }); + + it('resets provider-scoped values when the provider changes without explicit fields', () => { + expect( + buildLaunchParamsFromRuntimeRequest( + { + providerId: 'anthropic', + }, + codexFallback + ) + ).toEqual({ + providerId: 'anthropic', + providerBackendId: undefined, + model: 'default', + effort: undefined, + fastMode: undefined, + limitContext: false, + }); + }); + + it('uses explicit model, effort, fast mode, and limitContext when present', () => { + expect( + buildLaunchParamsFromRuntimeRequest( + { + providerId: 'anthropic', + model: 'haiku[1m]', + effort: 'low', + fastMode: 'off', + limitContext: false, + }, + codexFallback + ) + ).toEqual({ + providerId: 'anthropic', + providerBackendId: undefined, + model: 'haiku', + effort: 'low', + fastMode: 'off', + limitContext: false, + }); + }); + + it('treats an explicit undefined model as Default for the active provider', () => { + expect( + buildLaunchParamsFromRuntimeRequest( + { + providerId: 'codex', + providerBackendId: 'codex-native', + model: undefined, + effort: 'low', + }, + codexFallback + ) + ).toEqual({ + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'default', + effort: 'low', + fastMode: 'on', + limitContext: true, + }); + }); + + it('migrates legacy provider backend ids for codex requests', () => { + expect( + buildLaunchParamsFromRuntimeRequest({ + providerId: 'codex', + providerBackendId: 'api', + }) + ).toEqual({ + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'default', + effort: undefined, + fastMode: undefined, + limitContext: false, + }); + }); + + it('compares launch params by all persisted fields', () => { + expect(areTeamLaunchParamsEqual(codexFallback, { ...codexFallback })).toBe(true); + expect( + areTeamLaunchParamsEqual(codexFallback, { + ...codexFallback, + fastMode: 'off', + }) + ).toBe(false); + expect(areTeamLaunchParamsEqual(undefined, undefined)).toBe(true); + expect(areTeamLaunchParamsEqual(undefined, codexFallback)).toBe(false); + }); +});