diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c18a394..4abcc01c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Chat: selecting a user-installed skill from the slash command menu now invokes the skill and injects its content, instead of inserting the skill name as plain text. ## [1.13.2] - 2026-06-18 - Chat/Performance: long conversations and large session lists now stay smooth and responsive while a response is streaming (thanks to @bashrusakh). diff --git a/packages/ui/src/sync/session-ui-store.test.js b/packages/ui/src/sync/session-ui-store.test.js index fe43e3e6..e4488f40 100644 --- a/packages/ui/src/sync/session-ui-store.test.js +++ b/packages/ui/src/sync/session-ui-store.test.js @@ -1,7 +1,11 @@ -import { beforeEach, describe, expect, test } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { opencodeClient } from '@/lib/opencode/client'; import { useSessionWorktreeStore } from './session-worktree-store'; import { routeMessage, useSessionUIStore } from './session-ui-store'; +import { setActionRefs, setOptimisticRefs } from './session-actions'; +import { useSkillsStore } from '@/stores/useSkillsStore'; +import { useCommandsStore } from '@/stores/useCommandsStore'; +import { useConfigStore } from '@/stores/useConfigStore'; /** * Unit tests for session worktree routing through the authoritative store. @@ -221,3 +225,103 @@ describe('routeMessage directory scoping', () => { expect(calls[0].directory).toBe('/session/project'); }); }); + +describe('routeMessage skill invocation', () => { + // OpenCode registers every skill as a command (source: "skill"), so a skill + // selected from the slash menu must be dispatched via session.command so its + // content is injected — not sent as a plain "/name" text message (issue #1605). + const sendCommandCalls = []; + const sendMessageCalls = []; + let originalSendCommand; + let originalSendMessage; + + beforeEach(() => { + sendCommandCalls.length = 0; + sendMessageCalls.length = 0; + + // Minimal optimistic + connection machinery so routeMessage can dispatch. + const childStore = { + getState: () => ({ session_status: {} }), + setState: () => {}, + }; + const childStores = { + children: new Map(), + ensureChild: () => childStore, + getChild: () => childStore, + }; + setActionRefs(opencodeClient, childStores, () => '/skills/project'); + setOptimisticRefs(() => {}, () => {}); + useConfigStore.setState({ isConnected: true }); + + // The sync command list and the commands store both exclude user skills, + // so they start empty here — the skill is only known to the skills store. + useCommandsStore.setState({ commands: [] }); + useSkillsStore.setState({ skills: [] }); + + originalSendCommand = opencodeClient.sendCommand; + originalSendMessage = opencodeClient.sendMessage; + opencodeClient.sendCommand = async (params) => { + sendCommandCalls.push(params); + return 'msg'; + }; + opencodeClient.sendMessage = async (params) => { + sendMessageCalls.push(params); + return 'msg'; + }; + }); + + afterEach(() => { + opencodeClient.sendCommand = originalSendCommand; + opencodeClient.sendMessage = originalSendMessage; + useSkillsStore.setState({ skills: [] }); + }); + + test('invokes a user-installed skill as a command', async () => { + useSkillsStore.setState({ + skills: [{ name: 'grill-with-docs', path: '/skills/grill-with-docs/SKILL.md', scope: 'user', source: 'opencode' }], + }); + + await routeMessage({ + sessionId: 'session-skill', + directory: '/skills/project', + content: '/grill-with-docs', + providerID: 'provider-a', + modelID: 'model-a', + }); + + expect(sendCommandCalls).toHaveLength(1); + expect(sendCommandCalls[0].command).toBe('grill-with-docs'); + expect(sendMessageCalls).toHaveLength(0); + }); + + test('forwards trailing arguments to the skill command', async () => { + useSkillsStore.setState({ + skills: [{ name: 'grill-with-docs', path: '/skills/grill-with-docs/SKILL.md', scope: 'user', source: 'opencode' }], + }); + + await routeMessage({ + sessionId: 'session-skill', + directory: '/skills/project', + content: '/grill-with-docs focus on auth', + providerID: 'provider-a', + modelID: 'model-a', + }); + + expect(sendCommandCalls).toHaveLength(1); + expect(sendCommandCalls[0].command).toBe('grill-with-docs'); + expect(sendCommandCalls[0].arguments).toBe('focus on auth'); + }); + + test('sends an unknown slash token as a plain message', async () => { + await routeMessage({ + sessionId: 'session-skill', + directory: '/skills/project', + content: '/not-a-real-skill', + providerID: 'provider-a', + modelID: 'model-a', + }); + + expect(sendMessageCalls).toHaveLength(1); + expect(sendCommandCalls).toHaveLength(0); + }); +}); diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 374dc1a0..e2f3a248 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -24,6 +24,7 @@ import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from "@/stores/ import { useDirectoryStore } from "@/stores/useDirectoryStore" import { useSessionFoldersStore } from "@/stores/useSessionFoldersStore" import { useCommandsStore } from "@/stores/useCommandsStore" +import { useSkillsStore } from "@/stores/useSkillsStore" import { getSafeStorage } from "@/stores/utils/safeStorage" import { markPendingUserSendAnimation } from "@/lib/userSendAnimation" import { flattenAssistantTextParts } from "@/lib/messages/messageText" @@ -101,8 +102,14 @@ export function routeMessage(params: { const syncCommands = dirState?.command ?? [] const storeCommands = useCommandsStore.getState().commands + // OpenCode registers every skill as a command (source: "skill"), but the + // commands store filters skills out and the synced command list is only + // hydrated at bootstrap. Consult the live skills store so a skill selected + // from the slash menu is invoked via session.command (injecting its + // content) instead of being sent as a literal "/name" message (#1605). const isCommand = syncCommands.find((c) => c.name === cmdName) || storeCommands.find((c) => c.name === cmdName) + || useSkillsStore.getState().skills.some((s) => s.name === cmdName) if (isCommand) { return optimisticSend({