From f1c9776fde89240070dbd357e703411b9982d615 Mon Sep 17 00:00:00 2001 From: Ibrahim Khan Date: Tue, 23 Jun 2026 01:51:05 -0700 Subject: [PATCH] fix: invoke skills selected from the slash command menu (#1607) Selecting a user-installed skill from the slash menu inserted "/name" as a plain text message instead of running the skill (#1605). routeMessage only dispatched a "/name" via session.command when the name was found in the synced command list (hydrated once at bootstrap) or the commands store (which filters skills out), so skills installed after startup fell through to a plain prompt. Consult the live skills store when classifying a slash token. OpenCode registers every skill as a command (source: "skill"), so a known skill is dispatched via session.command and its content is injected, matching the existing behavior of skills that happened to be in the bootstrap snapshot. Signed-off-by: Bohdan Triapitsyn Co-authored-by: Ibrahim Khan Co-authored-by: Bohdan Triapitsyn --- CHANGELOG.md | 1 + packages/ui/src/sync/session-ui-store.test.js | 106 +++++++++++++++++- packages/ui/src/sync/session-ui-store.ts | 7 ++ 3 files changed, 113 insertions(+), 1 deletion(-) 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({