fix(team): preserve provider model ids and codex slash suggestions

This commit is contained in:
Илия 2026-04-14 14:56:29 +03:00 committed by GitHub
parent 5375eea19f
commit f819dd0c27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 315 additions and 70 deletions

View file

@ -16,7 +16,6 @@ import {
GEMINI_UI_DISABLED_REASON,
isGeminiUiFrozen,
} from '@renderer/utils/geminiUiFreeze';
import { stripTrailingOneMillionSuffixes } from '@renderer/utils/teamModelContext';
import {
doesTeamModelCarryProviderBrand,
getProviderScopedTeamModelLabel,
@ -27,6 +26,7 @@ import {
normalizeTeamModelForUi,
TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
} from '@renderer/utils/teamModelCatalog';
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
import { Info } from 'lucide-react';
export { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog';
@ -100,8 +100,11 @@ export function computeEffectiveTeamModel(
limitContext: boolean,
providerId: 'anthropic' | 'codex' | 'gemini' = 'anthropic'
): string | undefined {
const base = stripTrailingOneMillionSuffixes(selectedModel);
if (providerId !== 'anthropic') return base;
if (providerId !== 'anthropic') {
return selectedModel.trim() || undefined;
}
const base = extractProviderScopedBaseModel(selectedModel, providerId);
if (limitContext) return base;
if (base === 'haiku') return base;
return base ? `${base}[1m]` : 'opus[1m]';

View file

@ -1,6 +1,6 @@
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
import { stripTrailingOneMillionSuffixes } from '@renderer/utils/teamModelContext';
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
import { isLeadMember } from '@shared/utils/leadDetection';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
@ -31,12 +31,15 @@ interface LaunchDialogPrefillResult {
limitContext: boolean;
}
function normalizeModelCandidate(model: string | undefined): string {
function normalizeModelCandidate(
model: string | undefined,
providerId: TeamProviderId | undefined
): string {
const trimmed = model?.trim() ?? '';
if (!trimmed || trimmed === 'default' || trimmed === '__default__') {
return '';
}
return stripTrailingOneMillionSuffixes(trimmed) ?? '';
return extractProviderScopedBaseModel(trimmed, providerId) ?? '';
}
function canReuseModelForSelectedProvider(
@ -74,15 +77,15 @@ export function resolveLaunchDialogPrefill({
const modelCandidates = [
{
providerId: currentLeadProviderId,
model: normalizeModelCandidate(currentLead?.model),
model: normalizeModelCandidate(currentLead?.model, currentLeadProviderId),
},
{
providerId: savedRequestProviderId,
model: normalizeModelCandidate(savedRequest?.model),
model: normalizeModelCandidate(savedRequest?.model, savedRequestProviderId),
},
{
providerId: previousLaunchProviderId,
model: normalizeModelCandidate(previousLaunchParams?.model),
model: normalizeModelCandidate(previousLaunchParams?.model, previousLaunchProviderId),
},
];

View file

@ -19,6 +19,7 @@ import { serializeChipsWithText } from '@renderer/types/inlineChip';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { nameColorSet } from '@renderer/utils/projectColor';
import { getSuggestedSlashCommandsForProvider } from '@renderer/utils/providerSlashCommands';
import { buildSlashCommandSuggestions } from '@renderer/utils/skillCommandSuggestions';
import {
extractTaskRefsFromText,
@ -26,7 +27,11 @@ import {
} from '@renderer/utils/taskReferenceUtils';
import { MAX_TEXT_LENGTH } from '@shared/constants';
import { isLeadMember } from '@shared/utils/leadDetection';
import { KNOWN_SLASH_COMMANDS, parseStandaloneSlashCommand } from '@shared/utils/slashCommands';
import { parseStandaloneSlashCommand } from '@shared/utils/slashCommands';
import {
inferTeamProviderIdFromModel,
normalizeOptionalTeamProviderId,
} from '@shared/utils/teamProvider';
import { AlertCircle, Check, ChevronDown, Mic, Paperclip, Search, Send } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@ -206,6 +211,12 @@ export const MessageComposer = ({
})),
[members, colorMap]
);
const leadProviderId = useMemo(() => {
const lead = members.find((member) => isLeadMember(member));
return (
normalizeOptionalTeamProviderId(lead?.providerId) ?? inferTeamProviderIdFromModel(lead?.model)
);
}, [members]);
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName);
const { suggestions: taskSuggestions } = useTaskSuggestions(teamName);
@ -222,8 +233,13 @@ export const MessageComposer = ({
}, [fetchSkillsCatalog, projectPath]);
const slashCommandSuggestions = useMemo<MentionSuggestion[]>(
() => buildSlashCommandSuggestions(KNOWN_SLASH_COMMANDS, projectSkills, userSkills),
[projectSkills, userSkills]
() =>
buildSlashCommandSuggestions(
getSuggestedSlashCommandsForProvider(leadProviderId),
projectSkills,
userSkills
),
[leadProviderId, projectSkills, userSkills]
);
const trimmed = stripEncodedTaskReferenceMetadata(draft.text).trim();

View file

@ -6,16 +6,51 @@ import {
canDisplayTaskChangesForOptions,
type TaskChangeRequestOptions,
} from '@renderer/utils/taskChangeRequest';
import { stripTrailingOneMillionSuffixes } from '@renderer/utils/teamModelContext';
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
import { createLogger } from '@shared/utils/logger';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
import type { AppState } from '../types';
import type { AppConfig } from '@renderer/types/data';
import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode';
import type {
ActiveToolCall,
AddMemberRequest,
AddTaskCommentRequest,
CreateTaskRequest,
CrossTeamSendRequest,
EffortLevel,
GlobalTask,
InboxMessage,
KanbanColumnId,
LeadActivityState,
LeadContextUsage,
MemberSpawnStatusEntry,
MemberSpawnStatusesSnapshot,
PersistedTeamLaunchSummary,
SendMessageRequest,
SendMessageResult,
TaskChangePresenceState,
TaskComment,
TeamCreateRequest,
TeamData,
TeamLaunchRequest,
TeamProviderId,
TeamProvisioningProgress,
TeamSummary,
TeamTask,
TeamTaskStatus,
ToolApprovalRequest,
ToolApprovalSettings,
UpdateKanbanPatch,
} from '@shared/types';
import type { StateCreator } from 'zustand';
const logger = createLogger('teamSlice');
@ -484,42 +519,6 @@ async function pollProvisioningStatus(
}
}
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
import type { AppState } from '../types';
import type { AppConfig } from '@renderer/types/data';
import type {
ActiveToolCall,
AddMemberRequest,
AddTaskCommentRequest,
CreateTaskRequest,
CrossTeamSendRequest,
EffortLevel,
GlobalTask,
InboxMessage,
KanbanColumnId,
LeadActivityState,
LeadContextUsage,
MemberSpawnStatusEntry,
MemberSpawnStatusesSnapshot,
PersistedTeamLaunchSummary,
SendMessageRequest,
SendMessageResult,
TaskChangePresenceState,
TaskComment,
TeamCreateRequest,
TeamData,
TeamLaunchRequest,
TeamProvisioningProgress,
TeamSummary,
TeamTask,
TeamTaskStatus,
ToolApprovalRequest,
ToolApprovalSettings,
UpdateKanbanPatch,
} from '@shared/types';
import type { StateCreator } from 'zustand';
// --- Clarification notification tracking ---
// Native OS notifications for new inbox messages are handled in main process
// (main/index.ts → notifyNewInboxMessages). This renderer-side tracking only
@ -1220,8 +1219,8 @@ 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): string | undefined {
return stripTrailingOneMillionSuffixes(raw);
function extractBaseModel(raw?: string, providerId?: TeamProviderId): string | undefined {
return extractProviderScopedBaseModel(raw, providerId);
}
const TOOL_APPROVAL_PREFIX = 'team:toolApprovalSettings:';
@ -2587,7 +2586,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
const response = await unwrapIpc('team:create', () => api.teams.createTeam(request));
// Persist per-team launch params (model, effort, limit context)
const baseModel = extractBaseModel(request.model);
const baseModel = extractBaseModel(request.model, request.providerId);
const params: TeamLaunchParams = {
providerId: request.providerId ?? 'anthropic',
model: baseModel || 'default',
@ -2767,7 +2766,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
const response = await unwrapIpc('team:launch', () => api.teams.launchTeam(request));
// Persist per-team launch params (model, effort, limit context)
const baseModel = extractBaseModel(request.model);
const baseModel = extractBaseModel(request.model, request.providerId);
const params: TeamLaunchParams = {
providerId: request.providerId ?? 'anthropic',
model: baseModel || 'default',

View file

@ -0,0 +1,122 @@
import { KNOWN_SLASH_COMMANDS } from '@shared/utils/slashCommands';
import type { TeamProviderId } from '@shared/types';
import type { KnownSlashCommandDefinition } from '@shared/utils/slashCommands';
const CODEX_SLASH_COMMAND_SUGGESTIONS: readonly KnownSlashCommandDefinition[] = [
{
name: 'model',
command: '/model',
description: 'Choose the active model for this session.',
},
{
name: 'fast',
command: '/fast',
description: 'Toggle Fast mode on or off.',
},
{
name: 'permissions',
command: '/permissions',
description: 'Adjust approval requirements for tools and commands.',
},
{
name: 'plan',
command: '/plan',
description: 'Switch to plan mode with an optional prompt.',
},
{
name: 'review',
command: '/review',
description: 'Ask Codex to review the current working tree.',
},
{
name: 'diff',
command: '/diff',
description: 'Show the current Git diff, including untracked files.',
},
{
name: 'status',
command: '/status',
description: 'Show session configuration and token usage.',
},
{
name: 'mcp',
command: '/mcp',
description: 'List configured MCP tools for this session.',
},
{
name: 'mention',
command: '/mention',
description: 'Attach a file or folder to the conversation.',
},
{
name: 'apps',
command: '/apps',
description: 'Browse available apps and connectors.',
},
{
name: 'plugins',
command: '/plugins',
description: 'Browse and manage installed plugins.',
},
{
name: 'agent',
command: '/agent',
description: 'Switch to another agent thread.',
},
{
name: 'personality',
command: '/personality',
description: 'Change Codex response style for the current thread.',
},
{
name: 'compact',
command: '/compact',
description: 'Summarize the conversation to free tokens.',
},
{
name: 'clear',
command: '/clear',
description: 'Clear the terminal and start a fresh chat.',
},
{
name: 'new',
command: '/new',
description: 'Start a new conversation in the current session.',
},
{
name: 'copy',
command: '/copy',
description: 'Copy the latest completed Codex output.',
},
{
name: 'fork',
command: '/fork',
description: 'Fork the current conversation into a new thread.',
},
{
name: 'resume',
command: '/resume',
description: 'Resume a previous conversation.',
},
{
name: 'quit',
command: '/quit',
description: 'Exit the CLI.',
},
{
name: 'exit',
command: '/exit',
description: 'Exit the CLI.',
},
] as const;
export function getSuggestedSlashCommandsForProvider(
providerId?: TeamProviderId
): readonly KnownSlashCommandDefinition[] {
if (providerId === 'codex') {
return CODEX_SLASH_COMMAND_SUGGESTIONS;
}
return KNOWN_SLASH_COMMANDS;
}

View file

@ -1,4 +1,4 @@
import { getKnownSlashCommand, isSupportedSlashCommandName } from '@shared/utils/slashCommands';
import { isSupportedSlashCommandName } from '@shared/utils/slashCommands';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { SkillCatalogItem } from '@shared/types/extensions';
@ -9,6 +9,7 @@ export function buildSlashCommandSuggestions(
projectSkills: readonly SkillCatalogItem[],
userSkills: readonly SkillCatalogItem[]
): MentionSuggestion[] {
const builtInNames = new Set(builtIns.map((command) => command.name.trim().toLowerCase()));
const builtInSuggestions: MentionSuggestion[] = builtIns.map((command) => ({
id: `command:${command.name}`,
name: command.name,
@ -26,7 +27,7 @@ export function buildSlashCommandSuggestions(
!skill.isValid ||
!normalizedFolderName ||
!isSupportedSlashCommandName(normalizedFolderName) ||
getKnownSlashCommand(normalizedFolderName) !== null ||
builtInNames.has(normalizedFolderName) ||
seenSkillNames.has(normalizedFolderName)
) {
continue;

View file

@ -1,3 +1,7 @@
import { inferTeamProviderIdFromModel } from '@shared/utils/teamProvider';
import type { TeamProviderId } from '@shared/types';
export function stripTrailingOneMillionSuffixes(model: string | undefined): string | undefined {
const trimmed = model?.trim();
if (!trimmed) {
@ -6,3 +10,23 @@ export function stripTrailingOneMillionSuffixes(model: string | undefined): stri
return trimmed.replace(/(?:\[1m\])+$/, '') || undefined;
}
export function extractProviderScopedBaseModel(
model: string | undefined,
providerId?: TeamProviderId
): string | undefined {
const trimmed = model?.trim();
if (!trimmed) {
return undefined;
}
const effectiveProviderId =
providerId ??
inferTeamProviderIdFromModel(trimmed) ??
inferTeamProviderIdFromModel(stripTrailingOneMillionSuffixes(trimmed));
if (effectiveProviderId !== 'anthropic') {
return trimmed;
}
return stripTrailingOneMillionSuffixes(trimmed);
}

View file

@ -68,5 +68,6 @@ describe('computeEffectiveTeamModel', () => {
it('returns non-anthropic models as-is', () => {
expect(computeEffectiveTeamModel('gpt-5.4', false, 'codex')).toBe('gpt-5.4');
expect(computeEffectiveTeamModel('custom-model[1m]', false, 'codex')).toBe('custom-model[1m]');
});
});

View file

@ -66,7 +66,7 @@ describe('resolveLaunchDialogPrefill', () => {
const savedRequest = {
teamName: 'vector-room-2',
cwd: '/tmp/project',
cwd: '/Users/test/project',
providerId: 'anthropic',
model: 'haiku',
effort: 'low',
@ -181,4 +181,31 @@ describe('resolveLaunchDialogPrefill', () => {
limitContext: true,
});
});
it('preserves literal [1m] suffixes for non-anthropic providers', () => {
const result = resolveLaunchDialogPrefill({
members: [],
savedRequest: null,
previousLaunchParams: {
providerId: 'codex',
model: 'custom-model[1m]',
effort: 'medium',
},
multimodelEnabled: true,
storedProviderId: 'anthropic',
storedEffort: 'medium',
storedLimitContext: false,
getStoredModel: createStoredModelGetter({
anthropic: 'haiku',
codex: 'gpt-5.4',
}),
});
expect(result).toEqual({
providerId: 'codex',
model: 'custom-model[1m]',
effort: 'medium',
limitContext: false,
});
});
});

View file

@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { getSuggestedSlashCommandsForProvider } from '@renderer/utils/providerSlashCommands';
describe('getSuggestedSlashCommandsForProvider', () => {
it('returns Codex-specific command suggestions without Anthropic-only entries', () => {
const commands = getSuggestedSlashCommandsForProvider('codex').map(
(command) => command.command
);
expect(commands).toContain('/permissions');
expect(commands).toContain('/agent');
expect(commands).toContain('/review');
expect(commands).not.toContain('/effort');
expect(commands).not.toContain('/usage');
});
it('falls back to the default curated list for Anthropic-like providers', () => {
const commands = getSuggestedSlashCommandsForProvider('anthropic').map(
(command) => command.command
);
expect(commands).toContain('/effort');
expect(commands).toContain('/usage');
expect(commands).not.toContain('/permissions');
});
});

View file

@ -14,10 +14,10 @@ function createSkill(overrides: Partial<SkillCatalogItem>): SkillCatalogItem {
folderName: overrides.folderName ?? 'skill-name',
scope: overrides.scope ?? 'project',
rootKind: overrides.rootKind ?? 'claude',
projectRoot: overrides.projectRoot ?? '/tmp/project',
discoveryRoot: overrides.discoveryRoot ?? '/tmp/project/.claude/skills',
skillDir: overrides.skillDir ?? '/tmp/project/.claude/skills/skill-name',
skillFile: overrides.skillFile ?? '/tmp/project/.claude/skills/skill-name/SKILL.md',
projectRoot: overrides.projectRoot ?? '/Users/test/project',
discoveryRoot: overrides.discoveryRoot ?? '/Users/test/project/.claude/skills',
skillDir: overrides.skillDir ?? '/Users/test/project/.claude/skills/skill-name',
skillFile: overrides.skillFile ?? '/Users/test/project/.claude/skills/skill-name/SKILL.md',
metadata: overrides.metadata ?? {},
invocationMode: overrides.invocationMode ?? 'manual-only',
flags: overrides.flags ?? { hasScripts: false, hasReferences: false, hasAssets: false },
@ -29,18 +29,22 @@ function createSkill(overrides: Partial<SkillCatalogItem>): SkillCatalogItem {
describe('buildSlashCommandSuggestions', () => {
it('keeps built-ins and adds valid skills in a separate suggestion type', () => {
const suggestions = buildSlashCommandSuggestions(KNOWN_SLASH_COMMANDS, [
createSkill({ id: 'project-skill', folderName: 'review-skill', scope: 'project' }),
], []);
const suggestions = buildSlashCommandSuggestions(
KNOWN_SLASH_COMMANDS,
[createSkill({ id: 'project-skill', folderName: 'review-skill', scope: 'project' })],
[]
);
expect(suggestions[0]?.type).toBe('command');
expect(suggestions.some((suggestion) => suggestion.type === 'skill')).toBe(true);
expect(suggestions.find((suggestion) => suggestion.id === 'skill:project-skill')).toMatchObject({
name: 'review-skill',
command: '/review-skill',
subtitle: 'Project skill',
type: 'skill',
});
expect(suggestions.find((suggestion) => suggestion.id === 'skill:project-skill')).toMatchObject(
{
name: 'review-skill',
command: '/review-skill',
subtitle: 'Project skill',
type: 'skill',
}
);
});
it('filters slash-unsafe names and built-in collisions', () => {
@ -64,9 +68,27 @@ describe('buildSlashCommandSuggestions', () => {
[createSkill({ id: 'user', folderName: 'shared-skill', scope: 'user' })]
);
expect(suggestions.filter((suggestion) => suggestion.command === '/shared-skill')).toHaveLength(1);
expect(suggestions.filter((suggestion) => suggestion.command === '/shared-skill')).toHaveLength(
1
);
expect(suggestions.find((suggestion) => suggestion.command === '/shared-skill')?.id).toBe(
'skill:project'
);
});
it('uses the provided built-in set when filtering skill collisions', () => {
const suggestions = buildSlashCommandSuggestions(
[
{
name: 'custom-cmd',
command: '/custom-cmd',
description: 'Custom command',
},
],
[createSkill({ id: 'collision', folderName: 'custom-cmd' })],
[]
);
expect(suggestions.find((suggestion) => suggestion.id === 'skill:collision')).toBeUndefined();
});
});