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>
328 lines
11 KiB
JavaScript
328 lines
11 KiB
JavaScript
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.
|
|
*
|
|
* These tests verify that session-worktree-store is properly integrated as the
|
|
* authoritative holder of session↔worktree attachments, and that session-ui-store
|
|
* routes through it for switching and creation flows.
|
|
*
|
|
* Note: Full integration tests for setCurrentSession require runtime mocking.
|
|
* These tests focus on the contract layer: that setAttachment/getAttachment work
|
|
* correctly and that the contract helpers produce correct results.
|
|
*/
|
|
|
|
describe('session-worktree-store worktree routing', () => {
|
|
beforeEach(() => {
|
|
// Clear all attachments before each test
|
|
const store = useSessionWorktreeStore.getState();
|
|
const attachments = store.attachments;
|
|
for (const sessionId of attachments.keys()) {
|
|
store.clearAttachment(sessionId);
|
|
}
|
|
useSessionUIStore.setState({ currentSessionId: null, worktreeMetadata: new Map() });
|
|
});
|
|
|
|
test('getDirectoryForSession prefers authoritative attachment cwd over sync fallback', () => {
|
|
useSessionWorktreeStore.getState().setAttachment('session-dir', {
|
|
worktreeRoot: '/repo/worktrees/feat-a',
|
|
cwd: '/repo/worktrees/feat-a/src',
|
|
branch: 'feat-a',
|
|
headState: 'branch',
|
|
worktreeStatus: 'ready',
|
|
worktreeSource: 'existing',
|
|
legacy: false,
|
|
degraded: false,
|
|
});
|
|
|
|
expect(useSessionUIStore.getState().getDirectoryForSession('session-dir')).toBe('/repo/worktrees/feat-a/src');
|
|
});
|
|
|
|
test('getDirectoryForSession falls back to authoritative worktreeRoot when attachment is degraded', () => {
|
|
useSessionWorktreeStore.getState().setAttachment('session-dir', {
|
|
worktreeRoot: '/repo/worktrees/feat-a',
|
|
cwd: '/tmp/outside',
|
|
branch: 'feat-a',
|
|
headState: 'branch',
|
|
worktreeStatus: 'invalid',
|
|
worktreeSource: 'existing',
|
|
legacy: false,
|
|
degraded: true,
|
|
});
|
|
|
|
expect(useSessionUIStore.getState().getDirectoryForSession('session-dir')).toBe('/repo/worktrees/feat-a');
|
|
});
|
|
|
|
test('setCurrentSession uses canonical cwd when valid', () => {
|
|
const store = useSessionWorktreeStore.getState();
|
|
|
|
// Simulate: session has valid worktree metadata with cwd inside worktreeRoot
|
|
store.setAttachment('session-1', {
|
|
worktreeRoot: '/repo/worktrees/feat-a',
|
|
cwd: '/repo/worktrees/feat-a/src',
|
|
branch: 'feat-a',
|
|
headState: 'branch',
|
|
worktreeStatus: 'ready',
|
|
worktreeSource: 'existing',
|
|
legacy: false,
|
|
degraded: false,
|
|
});
|
|
|
|
const attachment = store.getAttachment('session-1');
|
|
expect(attachment).toBeDefined();
|
|
expect(attachment.cwd).toBe('/repo/worktrees/feat-a/src');
|
|
expect(attachment.worktreeRoot).toBe('/repo/worktrees/feat-a');
|
|
expect(attachment.degraded).toBe(false);
|
|
expect(attachment.worktreeStatus).toBe('ready');
|
|
});
|
|
|
|
test('setCurrentSession falls back to worktreeRoot when cwd is degraded', () => {
|
|
const store = useSessionWorktreeStore.getState();
|
|
|
|
// Simulate: cwd is outside worktreeRoot (degraded)
|
|
store.setAttachment('session-2', {
|
|
worktreeRoot: '/repo/worktrees/feat-a',
|
|
cwd: '/repo/worktrees/feat-a', // same as worktreeRoot means not degraded for this case
|
|
branch: 'feat-a',
|
|
headState: 'branch',
|
|
worktreeStatus: 'ready',
|
|
worktreeSource: 'existing',
|
|
legacy: false,
|
|
degraded: true, // marked degraded because cwd was resolved from invalid state
|
|
});
|
|
|
|
const attachment = store.getAttachment('session-2');
|
|
expect(attachment).toBeDefined();
|
|
expect(attachment.degraded).toBe(true);
|
|
// cwd should equal worktreeRoot when degraded (fallback)
|
|
expect(attachment.cwd).toBe(attachment.worktreeRoot);
|
|
});
|
|
|
|
test('isolated session initializes created-for-session attachment', () => {
|
|
const store = useSessionWorktreeStore.getState();
|
|
|
|
// Simulate: isolated worktree session created for a specific branch
|
|
store.setAttachment('session-isolated', {
|
|
worktreeRoot: '/repo/worktrees/feature-xyz',
|
|
cwd: '/repo/worktrees/feature-xyz',
|
|
branch: 'feature-xyz',
|
|
headState: 'branch',
|
|
worktreeStatus: 'ready',
|
|
worktreeSource: 'created-for-session',
|
|
legacy: false,
|
|
degraded: false,
|
|
});
|
|
|
|
const attachment = store.getAttachment('session-isolated');
|
|
expect(attachment).toBeDefined();
|
|
expect(attachment.worktreeSource).toBe('created-for-session');
|
|
expect(attachment.worktreeStatus).toBe('ready');
|
|
expect(attachment.legacy).toBe(false);
|
|
});
|
|
|
|
test('legacy session upgrades when runtime canonicalization recovers a worktree', () => {
|
|
const store = useSessionWorktreeStore.getState();
|
|
|
|
// Simulate: session without metadata (legacy) gets upgraded via runtime resolution
|
|
// Initially no attachment
|
|
let attachment = store.getAttachment('session-legacy');
|
|
expect(attachment).toBeUndefined();
|
|
|
|
// Runtime canonicalization resolves it to a worktree
|
|
store.setAttachment('session-legacy', {
|
|
worktreeRoot: '/repo/worktrees/recovered',
|
|
cwd: '/repo/worktrees/recovered',
|
|
branch: 'recovered',
|
|
headState: 'branch',
|
|
worktreeStatus: 'ready',
|
|
worktreeSource: 'existing',
|
|
legacy: false, // upgraded from legacy=true to false
|
|
degraded: false,
|
|
});
|
|
|
|
attachment = store.getAttachment('session-legacy');
|
|
expect(attachment).toBeDefined();
|
|
expect(attachment.legacy).toBe(false);
|
|
expect(attachment.worktreeRoot).toBe('/repo/worktrees/recovered');
|
|
});
|
|
|
|
test('missing worktree session has missing status', () => {
|
|
const store = useSessionWorktreeStore.getState();
|
|
|
|
// Simulate: session whose worktree was deleted
|
|
store.setAttachment('session-missing', {
|
|
worktreeRoot: null,
|
|
cwd: null,
|
|
branch: null,
|
|
headState: 'branch',
|
|
worktreeStatus: 'missing',
|
|
worktreeSource: null,
|
|
legacy: false,
|
|
degraded: true,
|
|
});
|
|
|
|
const attachment = store.getAttachment('session-missing');
|
|
expect(attachment).toBeDefined();
|
|
expect(attachment.worktreeStatus).toBe('missing');
|
|
expect(attachment.degraded).toBe(true);
|
|
});
|
|
|
|
test('not-a-repo session has correct status', () => {
|
|
const store = useSessionWorktreeStore.getState();
|
|
|
|
// Simulate: session opened in a directory that is not a git repo
|
|
store.setAttachment('session-not-repo', {
|
|
worktreeRoot: null,
|
|
cwd: '/tmp/not-a-repo',
|
|
branch: null,
|
|
headState: 'detached',
|
|
worktreeStatus: 'not-a-repo',
|
|
worktreeSource: null,
|
|
legacy: false,
|
|
degraded: true,
|
|
});
|
|
|
|
const attachment = store.getAttachment('session-not-repo');
|
|
expect(attachment).toBeDefined();
|
|
expect(attachment.worktreeStatus).toBe('not-a-repo');
|
|
});
|
|
});
|
|
|
|
describe('routeMessage directory scoping', () => {
|
|
test('runs sends in the provided session directory', async () => {
|
|
// The session directory travels as an explicit request param (not via
|
|
// client-wide directory scoping), so concurrent sends can't cross-talk.
|
|
const calls = [];
|
|
const originalShellSession = opencodeClient.shellSession;
|
|
|
|
opencodeClient.shellSession = async (params) => {
|
|
calls.push(params);
|
|
return { info: {}, parts: [] };
|
|
};
|
|
|
|
try {
|
|
await routeMessage({
|
|
sessionId: 'session-a',
|
|
directory: '/session/project',
|
|
content: 'pwd',
|
|
providerID: 'provider-a',
|
|
modelID: 'model-a',
|
|
inputMode: 'shell',
|
|
});
|
|
} finally {
|
|
opencodeClient.shellSession = originalShellSession;
|
|
}
|
|
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0].sessionId).toBe('session-a');
|
|
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);
|
|
});
|
|
});
|