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 <artmore@protonmail.com> Co-authored-by: Ibrahim Khan <ibrakhxn@amazon.com> Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Ibrahim Khan
Bohdan Triapitsyn
parent
ec69dcc28b
commit
f1c9776fde
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user