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