Files
openchamber/packages/ui/src/sync/session-ui-store.test.js
T
f1c9776fde 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>
2026-06-23 11:51:05 +03:00

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);
});
});