Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/disabled-skills-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Add `disabled_skills` config to fully hide selected skill names from Kimi (model listing, Skill tool, slash menu, and activation). Set `disabled_skills = ["name"]` in `config.toml`, then run `/reload`.
5 changes: 5 additions & 0 deletions .changeset/refresh-web-skill-menus.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Refresh loaded skill menus after configuration changes.
1 change: 1 addition & 0 deletions apps/kimi-web/src/api/daemon/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1275,6 +1275,7 @@ export class DaemonKimiWebApi implements KimiWebApi {
services: 'services',
mergeAllAvailableSkills: 'merge_all_available_skills',
extraSkillDirs: 'extra_skill_dirs',
disabledSkills: 'disabled_skills',
loopControl: 'loop_control',
background: 'background',
experimental: 'experimental',
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-web/src/api/daemon/mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,7 @@ export function toAppConfig(wire: WireConfig): AppConfig {
services: wire.services,
mergeAllAvailableSkills: wire.merge_all_available_skills,
extraSkillDirs: wire.extra_skill_dirs,
disabledSkills: wire.disabled_skills,
loopControl: wire.loop_control,
background: wire.background,
experimental: wire.experimental,
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-web/src/api/daemon/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,7 @@ export interface WireConfig {
services?: unknown;
merge_all_available_skills?: boolean;
extra_skill_dirs?: string[];
disabled_skills?: string[];
loop_control?: unknown;
background?: unknown;
experimental?: Record<string, boolean>;
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,7 @@ export interface AppConfig {
services?: unknown;
mergeAllAvailableSkills?: boolean;
extraSkillDirs?: string[];
disabledSkills?: string[];
loopControl?: unknown;
background?: unknown;
experimental?: Record<string, boolean>;
Expand Down
17 changes: 17 additions & 0 deletions apps/kimi-web/src/composables/client/useModelProviderState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,10 +258,16 @@ export function useModelProviderState(
.catch((error: unknown) => pushOperationFailure('setConfig', error));
}

const sessionSkillRequestSeq = new Map<string, number>();
const workspaceSkillRequestSeq = new Map<string, number>();

async function loadSkillsForSession(sessionId: string): Promise<void> {
const requestSeq = (sessionSkillRequestSeq.get(sessionId) ?? 0) + 1;
sessionSkillRequestSeq.set(sessionId, requestSeq);
try {
const api = getKimiWebApi();
const list = await api.listSkills(sessionId);
if (sessionSkillRequestSeq.get(sessionId) !== requestSeq) return;
skillsBySession.value = { ...skillsBySession.value, [sessionId]: list };
} catch {
// Skills are side data; an older daemon without /skills just yields no
Expand All @@ -270,16 +276,26 @@ export function useModelProviderState(
}

async function loadSkillsForWorkspace(workspaceId: string): Promise<void> {
const requestSeq = (workspaceSkillRequestSeq.get(workspaceId) ?? 0) + 1;
workspaceSkillRequestSeq.set(workspaceId, requestSeq);
try {
const api = getKimiWebApi();
const list = await api.listSkillsForWorkspace(workspaceId);
if (workspaceSkillRequestSeq.get(workspaceId) !== requestSeq) return;
skillsByWorkspace.value = { ...skillsByWorkspace.value, [workspaceId]: list };
} catch {
// Side data; an older daemon without /workspaces/{id}/skills just yields
// no slash-skills for the onboarding composer.
}
}

async function refreshLoadedSkills(): Promise<void> {
await Promise.all([
...Object.keys(skillsBySession.value).map(loadSkillsForSession),
...Object.keys(skillsByWorkspace.value).map(loadSkillsForWorkspace),
]);
}

/** Load models (cached — call again to force refresh) */
async function loadModels(): Promise<void> {
try {
Expand Down Expand Up @@ -583,6 +599,7 @@ export function useModelProviderState(
// actions
loadSkillsForSession,
loadSkillsForWorkspace,
refreshLoadedSkills,
loadModels,
loadProviders,
setModel,
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-web/src/composables/useKimiWebClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,7 @@ function applyEvent(event: ReturnType<typeof toAppEvent>, sessionId: string, seq

if (event.type === 'configChanged') {
rawState.defaultModel = event.config.defaultModel ?? null;
void modelProvider.refreshLoadedSkills();
}

if (event.type === 'modelCatalogChanged') {
Expand Down
143 changes: 142 additions & 1 deletion apps/kimi-web/test/event-batcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -576,7 +576,7 @@ describe('coalesceAppRenderEvents (lossless stream grouping)', () => {
});
});

describe('useKimiWebClient (resync integration)', () => {
describe('useKimiWebClient integration', () => {
it('flushes queued deltas around an authoritative snapshot before live streaming resumes', async () => {
vi.stubGlobal('WebSocket', class {});

Expand Down Expand Up @@ -775,6 +775,147 @@ describe('useKimiWebClient (resync integration)', () => {
vi.unstubAllGlobals();
}
});

it('refreshes loaded skills after a configChanged event', async () => {
vi.resetModules();
vi.stubGlobal('WebSocket', vi.fn());

const sessionId = 'session-config';
const session: AppSession = {
id: sessionId,
title: 'Session',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
status: 'idle',
archived: false,
currentPromptId: null,
cwd: '/workspace',
model: 'model-1',
usage: {
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
totalCostUsd: 0,
contextTokens: 0,
contextLimit: 0,
turnCount: 0,
},
messageCount: 0,
lastSeq: 0,
workspaceId: 'workspace-1',
};
let handlers: KimiEventHandlers | undefined;
let sessionSkills = [{ name: 'before-config', description: '', source: 'project' as const }];
const listSkills = vi.fn(async () => sessionSkills);
const connection: KimiEventConnection = {
subscribe: vi.fn(),
unsubscribe: vi.fn(),
bindNextPromptId: vi.fn(),
seedSnapshot: vi.fn(),
abort: vi.fn(),
terminalAttach: vi.fn(),
terminalInput: vi.fn(),
terminalResize: vi.fn(),
terminalDetach: vi.fn(),
terminalClose: vi.fn(),
markSideChannelAgent: vi.fn(),
health: () => ({ connected: true, open: true, stale: false }),
reconnect: vi.fn(),
close: vi.fn(),
};
const api: Partial<KimiWebApi> = {
getAuth: vi.fn(async () => ({
ready: true,
defaultModel: 'model-1',
managedProvider: null,
})),
getHealth: vi.fn(async () => ({ status: 'ok', uptimeSec: 1 })),
getMeta: vi.fn(async () => ({
serverVersion: '0.0.0',
serverId: 'server-1',
startedAt: '2026-01-01T00:00:00.000Z',
capabilities: {},
openInApps: [],
dangerousBypassAuth: false,
backend: 'v2',
})),
getConfig: vi.fn(async () => ({ providers: {}, defaultModel: 'model-1' })),
listModels: vi.fn(async () => []),
listProviders: vi.fn(async () => []),
listWorkspaces: vi.fn(async () => [
{ id: 'workspace-1', root: '/workspace', name: 'Workspace', sessionCount: 1 },
]),
getFsHome: vi.fn(async () => ({ home: '/home/test', recentRoots: [] })),
listSessions: vi.fn(async () => ({ items: [session], hasMore: false })),
getSessionSnapshot: vi.fn(async () => ({
asOfSeq: 0,
epoch: 'epoch-1',
session,
messages: [],
hasMoreMessages: false,
inFlightTurn: null,
subagents: [],
pendingApprovals: [],
pendingQuestions: [],
})),
getSessionStatus: vi.fn(async () => ({
model: 'model-1',
thinkingEffort: 'high',
permission: 'manual',
planMode: false,
swarmMode: false,
contextTokens: 0,
maxContextTokens: 0,
contextUsage: 0,
})),
getSessionGoal: vi.fn(async () => null),
getSessionWarnings: vi.fn(async () => []),
getGitStatus: vi.fn(async () => ({
branch: '',
ahead: 0,
behind: 0,
entries: {},
additions: 0,
deletions: 0,
pullRequest: null,
})),
listTasks: vi.fn(async () => []),
listSkills,
listSkillsForWorkspace: vi.fn(async () => []),
getFileUrl: (fileId) => `file:${fileId}`,
connectEvents: vi.fn((nextHandlers) => {
handlers = nextHandlers;
return connection;
}),
};
for (const key of Object.keys(clientApiMock)) delete clientApiMock[key];
Object.assign(clientApiMock, api);

try {
const { useKimiWebClient } = await import('../src/composables/useKimiWebClient');
const client = useKimiWebClient();
await client.load();
await vi.waitFor(() => expect(client.skills.value[0]?.name).toBe('before-config'));
listSkills.mockClear();
sessionSkills = [{ name: 'after-config', description: '', source: 'project' as const }];

handlers!.onEvent(
{
type: 'configChanged',
changedFields: ['disabledSkills'],
config: { providers: {}, defaultModel: 'model-1', disabledSkills: ['review-helper'] },
},
{ sessionId, seq: 1 },
);

await vi.waitFor(() => expect(listSkills).toHaveBeenCalledWith(sessionId));
await vi.waitFor(() => expect(client.skills.value[0]?.name).toBe('after-config'));
} finally {
connection.close();
vi.unstubAllGlobals();
}
});
});

describe('isRenderEvent (queue classification)', () => {
Expand Down
82 changes: 82 additions & 0 deletions apps/kimi-web/test/workspace-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { DaemonApiError } from '../src/api/errors';
import { createInitialState } from '../src/api/daemon/eventReducer';
import { mergeWorkspaces } from '../src/lib/mergeWorkspaces';
import { loadWorkspaceNameOverrides, saveWorkspaceNameOverrides } from '../src/lib/storage';
import { useModelProviderState } from '../src/composables/client/useModelProviderState';
import { useWorkspaceState, forgetLocalTurnState, type UseWorkspaceStateDeps } from '../src/composables/client/useWorkspaceState';
import type { ExtendedState } from '../src/composables/useKimiWebClient';
import { clearTrace, traceKeyEvent } from '../src/debug/trace';
Expand All @@ -33,6 +34,8 @@ const apiMock = vi.hoisted(() => ({
getHealth: vi.fn(),
getMeta: vi.fn(),
listSessions: vi.fn(),
listSkills: vi.fn(),
listSkillsForWorkspace: vi.fn(),
listWorkspaces: vi.fn(),
}));

Expand Down Expand Up @@ -233,6 +236,85 @@ function task(id: string, status: AppTask['status'] = 'running'): AppTask {
};
}

describe('useModelProviderState — skill cache refresh', () => {
it('reloads every session and workspace skill list already in use', async () => {
apiMock.listSkills.mockReset();
apiMock.listSkillsForWorkspace.mockReset();
apiMock.listSkills.mockResolvedValue([
{ name: 'fresh-session-skill', description: '', source: 'project' },
]);
apiMock.listSkillsForWorkspace.mockResolvedValue([
{ name: 'fresh-workspace-skill', description: '', source: 'project' },
]);
const provider = useModelProviderState(createState(), {
pushOperationFailure: vi.fn(),
refreshSessionStatus: vi.fn(async () => {}),
persistSessionProfile: vi.fn(async () => true),
activity: computed(() => 'running'),
updateSession: vi.fn(),
updateSessionMessages: vi.fn(),
});
provider.skillsBySession.value = { sess_1: [] };
provider.skillsByWorkspace.value = { workspace_1: [] };

await provider.refreshLoadedSkills();

expect(apiMock.listSkills).toHaveBeenCalledWith('sess_1');
expect(apiMock.listSkillsForWorkspace).toHaveBeenCalledWith('workspace_1');
expect(provider.skillsBySession.value['sess_1']?.[0]?.name).toBe('fresh-session-skill');
expect(provider.skillsByWorkspace.value['workspace_1']?.[0]?.name).toBe(
'fresh-workspace-skill',
);
});

it('keeps newer skill caches when overlapping refreshes resolve out of order', async () => {
const skill = (name: string) => ({ name, description: '', source: 'project' as const });
const deferred = <T>() => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
};
const olderSession = deferred<ReturnType<typeof skill>[]>();
const newerSession = deferred<ReturnType<typeof skill>[]>();
const olderWorkspace = deferred<ReturnType<typeof skill>[]>();
const newerWorkspace = deferred<ReturnType<typeof skill>[]>();
apiMock.listSkills.mockReset();
apiMock.listSkillsForWorkspace.mockReset();
apiMock.listSkills
.mockImplementationOnce(() => olderSession.promise)
.mockImplementationOnce(() => newerSession.promise);
apiMock.listSkillsForWorkspace
.mockImplementationOnce(() => olderWorkspace.promise)
.mockImplementationOnce(() => newerWorkspace.promise);
const provider = useModelProviderState(createState(), {
pushOperationFailure: vi.fn(),
refreshSessionStatus: vi.fn(async () => {}),
persistSessionProfile: vi.fn(async () => true),
activity: computed(() => 'running'),
updateSession: vi.fn(),
updateSessionMessages: vi.fn(),
});
provider.skillsBySession.value = { sess_1: [] };
provider.skillsByWorkspace.value = { workspace_1: [] };

const olderRefresh = provider.refreshLoadedSkills();
const newerRefresh = provider.refreshLoadedSkills();
newerSession.resolve([skill('newer-session-skill')]);
newerWorkspace.resolve([skill('newer-workspace-skill')]);
await newerRefresh;
olderSession.resolve([skill('older-session-skill')]);
olderWorkspace.resolve([skill('older-workspace-skill')]);
await olderRefresh;

expect(provider.skillsBySession.value['sess_1']?.[0]?.name).toBe('newer-session-skill');
expect(provider.skillsByWorkspace.value['workspace_1']?.[0]?.name).toBe(
'newer-workspace-skill',
);
});
});

describe('useWorkspaceState — abortCurrentPrompt', () => {
beforeEach(() => {
apiMock.abortPrompt.mockReset();
Expand Down
Loading