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 777genius
parent 15b2b655ea
commit 8b53f63e97
11 changed files with 358 additions and 69 deletions

View file

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

View file

@ -1,5 +1,6 @@
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze'; import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability'; import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
import { isLeadMember } from '@shared/utils/leadDetection'; import { isLeadMember } from '@shared/utils/leadDetection';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
@ -27,12 +28,15 @@ interface LaunchDialogPrefillResult {
effort: string; effort: string;
} }
function normalizeModelCandidate(model: string | undefined): string { function normalizeModelCandidate(
model: string | undefined,
providerId: TeamProviderId | undefined
): string {
const trimmed = model?.trim() ?? ''; const trimmed = model?.trim() ?? '';
if (!trimmed || trimmed === 'default' || trimmed === '__default__') { if (!trimmed || trimmed === 'default' || trimmed === '__default__') {
return ''; return '';
} }
return trimmed; return extractProviderScopedBaseModel(trimmed, providerId) ?? '';
} }
function canReuseModelForSelectedProvider( function canReuseModelForSelectedProvider(
@ -69,15 +73,15 @@ export function resolveLaunchDialogPrefill({
const modelCandidates = [ const modelCandidates = [
{ {
providerId: currentLeadProviderId, providerId: currentLeadProviderId,
model: normalizeModelCandidate(currentLead?.model), model: normalizeModelCandidate(currentLead?.model, currentLeadProviderId),
}, },
{ {
providerId: savedRequestProviderId, providerId: savedRequestProviderId,
model: normalizeModelCandidate(savedRequest?.model), model: normalizeModelCandidate(savedRequest?.model, savedRequestProviderId),
}, },
{ {
providerId: previousLaunchProviderId, 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 { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { nameColorSet } from '@renderer/utils/projectColor'; import { nameColorSet } from '@renderer/utils/projectColor';
import { getSuggestedSlashCommandsForProvider } from '@renderer/utils/providerSlashCommands';
import { buildSlashCommandSuggestions } from '@renderer/utils/skillCommandSuggestions'; import { buildSlashCommandSuggestions } from '@renderer/utils/skillCommandSuggestions';
import { import {
extractTaskRefsFromText, extractTaskRefsFromText,
@ -26,7 +27,11 @@ import {
} from '@renderer/utils/taskReferenceUtils'; } from '@renderer/utils/taskReferenceUtils';
import { MAX_TEXT_LENGTH } from '@shared/constants'; import { MAX_TEXT_LENGTH } from '@shared/constants';
import { isLeadMember } from '@shared/utils/leadDetection'; 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 { AlertCircle, Check, ChevronDown, Mic, Paperclip, Search, Send } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
@ -206,6 +211,12 @@ export const MessageComposer = ({
})), })),
[members, colorMap] [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: teamMentionSuggestions } = useTeamSuggestions(teamName);
const { suggestions: taskSuggestions } = useTaskSuggestions(teamName); const { suggestions: taskSuggestions } = useTaskSuggestions(teamName);
@ -222,8 +233,13 @@ export const MessageComposer = ({
}, [fetchSkillsCatalog, projectPath]); }, [fetchSkillsCatalog, projectPath]);
const slashCommandSuggestions = useMemo<MentionSuggestion[]>( 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(); const trimmed = stripEncodedTaskReferenceMetadata(draft.text).trim();

View file

@ -6,15 +6,51 @@ import {
canDisplayTaskChangesForOptions, canDisplayTaskChangesForOptions,
type TaskChangeRequestOptions, type TaskChangeRequestOptions,
} from '@renderer/utils/taskChangeRequest'; } from '@renderer/utils/taskChangeRequest';
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { getTaskKanbanColumn } from '@shared/utils/reviewState'; import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { getWorktreeNavigationState } from '../utils/stateResetHelpers'; 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 { 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'); const logger = createLogger('teamSlice');
@ -483,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 --- // --- Clarification notification tracking ---
// Native OS notifications for new inbox messages are handled in main process // Native OS notifications for new inbox messages are handled in main process
// (main/index.ts → notifyNewInboxMessages). This renderer-side tracking only // (main/index.ts → notifyNewInboxMessages). This renderer-side tracking only
@ -1219,9 +1219,8 @@ function saveLaunchParams(teamName: string, params: TeamLaunchParams): void {
* Extract the base model name from the raw model string sent to CLI. * Extract the base model name from the raw model string sent to CLI.
* E.g. 'opus[1m]' 'opus', 'sonnet' 'sonnet', undefined undefined. * E.g. 'opus[1m]' 'opus', 'sonnet' 'sonnet', undefined undefined.
*/ */
function extractBaseModel(raw?: string): string | undefined { function extractBaseModel(raw?: string, providerId?: TeamProviderId): string | undefined {
if (!raw) return undefined; return extractProviderScopedBaseModel(raw, providerId);
return raw.replace(/\[1m\]$/, '') || undefined;
} }
const TOOL_APPROVAL_PREFIX = 'team:toolApprovalSettings:'; 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)); const response = await unwrapIpc('team:create', () => api.teams.createTeam(request));
// Persist per-team launch params (model, effort, limit context) // Persist per-team launch params (model, effort, limit context)
const baseModel = extractBaseModel(request.model); const baseModel = extractBaseModel(request.model, request.providerId);
const params: TeamLaunchParams = { const params: TeamLaunchParams = {
providerId: request.providerId ?? 'anthropic', providerId: request.providerId ?? 'anthropic',
model: baseModel || 'default', model: baseModel || 'default',
@ -2767,7 +2766,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
const response = await unwrapIpc('team:launch', () => api.teams.launchTeam(request)); const response = await unwrapIpc('team:launch', () => api.teams.launchTeam(request));
// Persist per-team launch params (model, effort, limit context) // Persist per-team launch params (model, effort, limit context)
const baseModel = extractBaseModel(request.model); const baseModel = extractBaseModel(request.model, request.providerId);
const params: TeamLaunchParams = { const params: TeamLaunchParams = {
providerId: request.providerId ?? 'anthropic', providerId: request.providerId ?? 'anthropic',
model: baseModel || 'default', 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 { MentionSuggestion } from '@renderer/types/mention';
import type { SkillCatalogItem } from '@shared/types/extensions'; import type { SkillCatalogItem } from '@shared/types/extensions';
@ -9,6 +9,7 @@ export function buildSlashCommandSuggestions(
projectSkills: readonly SkillCatalogItem[], projectSkills: readonly SkillCatalogItem[],
userSkills: readonly SkillCatalogItem[] userSkills: readonly SkillCatalogItem[]
): MentionSuggestion[] { ): MentionSuggestion[] {
const builtInNames = new Set(builtIns.map((command) => command.name.trim().toLowerCase()));
const builtInSuggestions: MentionSuggestion[] = builtIns.map((command) => ({ const builtInSuggestions: MentionSuggestion[] = builtIns.map((command) => ({
id: `command:${command.name}`, id: `command:${command.name}`,
name: command.name, name: command.name,
@ -26,7 +27,7 @@ export function buildSlashCommandSuggestions(
!skill.isValid || !skill.isValid ||
!normalizedFolderName || !normalizedFolderName ||
!isSupportedSlashCommandName(normalizedFolderName) || !isSupportedSlashCommandName(normalizedFolderName) ||
getKnownSlashCommand(normalizedFolderName) !== null || builtInNames.has(normalizedFolderName) ||
seenSkillNames.has(normalizedFolderName) seenSkillNames.has(normalizedFolderName)
) { ) {
continue; continue;

View file

@ -0,0 +1,32 @@
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) {
return undefined;
}
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

@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { formatTeamModelSummary } from '@renderer/components/team/dialogs/TeamModelSelector'; import {
computeEffectiveTeamModel,
formatTeamModelSummary,
} from '@renderer/components/team/dialogs/TeamModelSelector';
import { import {
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON, GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON, GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON,
@ -36,3 +39,35 @@ describe('formatTeamModelSummary', () => {
expect(normalizeTeamModelForUi('codex', 'gpt-5.4-mini')).toBe('gpt-5.4-mini'); expect(normalizeTeamModelForUi('codex', 'gpt-5.4-mini')).toBe('gpt-5.4-mini');
}); });
}); });
describe('computeEffectiveTeamModel', () => {
it('appends [1m] for anthropic models', () => {
expect(computeEffectiveTeamModel('opus', false, 'anthropic')).toBe('opus[1m]');
expect(computeEffectiveTeamModel('sonnet', false, 'anthropic')).toBe('sonnet[1m]');
});
it('does not double-append [1m] when input already has it', () => {
expect(computeEffectiveTeamModel('opus[1m]', false, 'anthropic')).toBe('opus[1m]');
expect(computeEffectiveTeamModel('sonnet[1m]', false, 'anthropic')).toBe('sonnet[1m]');
expect(computeEffectiveTeamModel('opus[1m][1m]', false, 'anthropic')).toBe('opus[1m]');
});
it('defaults to opus[1m] when no model selected', () => {
expect(computeEffectiveTeamModel('', false, 'anthropic')).toBe('opus[1m]');
});
it('returns base model without [1m] when limitContext is true', () => {
expect(computeEffectiveTeamModel('opus', true, 'anthropic')).toBe('opus');
expect(computeEffectiveTeamModel('opus[1m]', true, 'anthropic')).toBe('opus');
expect(computeEffectiveTeamModel('opus[1m][1m]', true, 'anthropic')).toBe('opus');
});
it('returns haiku as-is', () => {
expect(computeEffectiveTeamModel('haiku', false, 'anthropic')).toBe('haiku');
});
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

@ -64,7 +64,7 @@ describe('resolveLaunchDialogPrefill', () => {
const savedRequest = { const savedRequest = {
teamName: 'vector-room-2', teamName: 'vector-room-2',
cwd: '/tmp/project', cwd: '/Users/test/project',
providerId: 'anthropic', providerId: 'anthropic',
model: 'haiku', model: 'haiku',
effort: 'low', effort: 'low',
@ -146,4 +146,31 @@ describe('resolveLaunchDialogPrefill', () => {
effort: 'medium', effort: 'medium',
}); });
}); });
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', folderName: overrides.folderName ?? 'skill-name',
scope: overrides.scope ?? 'project', scope: overrides.scope ?? 'project',
rootKind: overrides.rootKind ?? 'claude', rootKind: overrides.rootKind ?? 'claude',
projectRoot: overrides.projectRoot ?? '/tmp/project', projectRoot: overrides.projectRoot ?? '/Users/test/project',
discoveryRoot: overrides.discoveryRoot ?? '/tmp/project/.claude/skills', discoveryRoot: overrides.discoveryRoot ?? '/Users/test/project/.claude/skills',
skillDir: overrides.skillDir ?? '/tmp/project/.claude/skills/skill-name', skillDir: overrides.skillDir ?? '/Users/test/project/.claude/skills/skill-name',
skillFile: overrides.skillFile ?? '/tmp/project/.claude/skills/skill-name/SKILL.md', skillFile: overrides.skillFile ?? '/Users/test/project/.claude/skills/skill-name/SKILL.md',
metadata: overrides.metadata ?? {}, metadata: overrides.metadata ?? {},
invocationMode: overrides.invocationMode ?? 'manual-only', invocationMode: overrides.invocationMode ?? 'manual-only',
flags: overrides.flags ?? { hasScripts: false, hasReferences: false, hasAssets: false }, flags: overrides.flags ?? { hasScripts: false, hasReferences: false, hasAssets: false },
@ -29,18 +29,22 @@ function createSkill(overrides: Partial<SkillCatalogItem>): SkillCatalogItem {
describe('buildSlashCommandSuggestions', () => { describe('buildSlashCommandSuggestions', () => {
it('keeps built-ins and adds valid skills in a separate suggestion type', () => { it('keeps built-ins and adds valid skills in a separate suggestion type', () => {
const suggestions = buildSlashCommandSuggestions(KNOWN_SLASH_COMMANDS, [ const suggestions = buildSlashCommandSuggestions(
createSkill({ id: 'project-skill', folderName: 'review-skill', scope: 'project' }), KNOWN_SLASH_COMMANDS,
], []); [createSkill({ id: 'project-skill', folderName: 'review-skill', scope: 'project' })],
[]
);
expect(suggestions[0]?.type).toBe('command'); expect(suggestions[0]?.type).toBe('command');
expect(suggestions.some((suggestion) => suggestion.type === 'skill')).toBe(true); expect(suggestions.some((suggestion) => suggestion.type === 'skill')).toBe(true);
expect(suggestions.find((suggestion) => suggestion.id === 'skill:project-skill')).toMatchObject({ expect(suggestions.find((suggestion) => suggestion.id === 'skill:project-skill')).toMatchObject(
name: 'review-skill', {
command: '/review-skill', name: 'review-skill',
subtitle: 'Project skill', command: '/review-skill',
type: 'skill', subtitle: 'Project skill',
}); type: 'skill',
}
);
}); });
it('filters slash-unsafe names and built-in collisions', () => { it('filters slash-unsafe names and built-in collisions', () => {
@ -64,9 +68,27 @@ describe('buildSlashCommandSuggestions', () => {
[createSkill({ id: 'user', folderName: 'shared-skill', scope: 'user' })] [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( expect(suggestions.find((suggestion) => suggestion.command === '/shared-skill')?.id).toBe(
'skill:project' '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();
});
}); });