import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { opencodeClient } from '@/lib/opencode/client'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useSessionWorktreeStore } from './session-worktree-store'; import { expandSlashCommandGoalObjective, 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'; import { getRuntimeKey } from '@/lib/runtime-switch'; /** * 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('sendMessage captured target', () => { let originalSendMessage; const calls = []; beforeEach(() => { calls.length = 0; const childStore = { getState: () => ({ session: [], message: {}, part: {}, session_status: {} }), setState: () => {}, }; const childStores = { children: new Map(), ensureChild: () => childStore, getChild: () => childStore, }; setActionRefs(opencodeClient, childStores, () => '/current/project'); setOptimisticRefs(() => {}, () => {}); useConfigStore.setState({ isConnected: true }); useSessionUIStore.setState({ currentSessionId: 'session-current', currentSessionDirectory: '/current/project', newSessionDraft: { open: false, directoryOverride: null, parentID: null }, }); originalSendMessage = opencodeClient.sendMessage; opencodeClient.sendMessage = async (params) => { calls.push(params); return 'msg'; }; }); afterEach(() => { opencodeClient.sendMessage = originalSendMessage; }); const sendToTarget = (target) => useSessionUIStore.getState().sendMessage( 'queued message', 'provider-a', 'model-a', undefined, undefined, undefined, undefined, undefined, 'normal', { target }, ); test('uses the target captured before the active session changes', async () => { await sendToTarget({ runtimeKey: getRuntimeKey(), sessionId: 'session-captured', directory: '/captured/project', }); expect(calls).toHaveLength(1); expect(calls[0].runtimeKey).toBe(getRuntimeKey()); expect(calls[0].id).toBe('session-captured'); expect(calls[0].directory).toBe('/captured/project'); }); test('does not send a captured target through a different runtime', async () => { let error = null; try { await sendToTarget({ runtimeKey: `${getRuntimeKey()}-stale`, sessionId: 'session-captured', directory: '/captured/project', }); } catch (caught) { error = caught; } expect(error).toBeInstanceOf(Error); expect(error.message).toContain('runtime changed'); expect(calls).toHaveLength(0); }); }); describe('slash-command goal objectives', () => { test('expands every $ARGUMENTS reference from the authoritative command template', () => { expect(expandSlashCommandGoalObjective('/issue--to-pr LIN-123 --draft', [{ name: 'issue--to-pr', template: 'Run the issue pipeline for $ARGUMENTS. Verify $ARGUMENTS is represented by the PR.', }])).toBe('Run the issue pipeline for LIN-123 --draft. Verify LIN-123 --draft is represented by the PR.'); }); test('keeps the invocation when the command template is unavailable', () => { expect(expandSlashCommandGoalObjective('/issue--to-pr LIN-123', [{ name: 'issue--to-pr' }])) .toBe('/issue--to-pr LIN-123'); }); test('matches OpenCode positional and implicit argument expansion', () => { expect(expandSlashCommandGoalObjective('/move "src old" dist extra', [{ name: 'move', template: 'Move $1 to $2', }])).toBe('Move src old to dist extra'); expect(expandSlashCommandGoalObjective('/review auth module', [{ name: 'review', template: 'Review the requested scope.', }])).toBe('Review the requested scope.\n\nauth module'); }); }); describe('runtime worktree topology', () => { test('restores independent in-memory maps across A -> B -> A', () => { const topologyA = new Map([['/repo', [{ path: '/repo/a', branch: 'a' }]]]); const topologyB = new Map([['/repo', [{ path: '/repo/b', branch: 'b' }]]]); useSessionUIStore.setState({ availableWorktreesByProject: topologyA, availableWorktrees: topologyA.get('/repo') }); useSessionUIStore.getState().prepareForRuntimeSwitch('runtime-a'); useSessionUIStore.setState({ availableWorktreesByProject: topologyB, availableWorktrees: topologyB.get('/repo') }); useSessionUIStore.getState().prepareForRuntimeSwitch('runtime-b'); useSessionUIStore.getState().restoreForRuntimeSwitch('runtime-a'); expect(useSessionUIStore.getState().availableWorktreesByProject.get('/repo')?.[0]?.path).toBe('/repo/a'); useSessionUIStore.getState().restoreForRuntimeSwitch('runtime-b'); expect(useSessionUIStore.getState().availableWorktreesByProject.get('/repo')?.[0]?.path).toBe('/repo/b'); }); }); describe('openNewSessionDraft project binding', () => { const projectA = { id: 'proj-a', path: '/projects/alpha', label: 'Alpha' }; const projectB = { id: 'proj-b', path: '/projects/beta', label: 'Beta' }; beforeEach(() => { useSessionUIStore.setState({ currentSessionId: null, currentSessionDirectory: null, newSessionDraft: { open: false, directoryOverride: null, parentID: null }, availableWorktreesByProject: new Map(), }); useProjectsStore.setState({ projects: [projectA, projectB], activeProjectId: projectA.id, }); useDirectoryStore.getState().setDirectory(projectB.path, { showOverlay: false }); }); test('keeps implicit draft on current directory when active project differs', () => { useSessionUIStore.getState().openNewSessionDraft(); const draft = useSessionUIStore.getState().newSessionDraft; expect(draft.open).toBe(true); expect(draft.selectedProjectId).toBe(projectB.id); expect(draft.directoryOverride).toBe(projectB.path); }); test('does not attach active project when current directory is unmatched', () => { useDirectoryStore.getState().setDirectory('/external/worktree', { showOverlay: false }); useSessionUIStore.getState().openNewSessionDraft(); const draft = useSessionUIStore.getState().newSessionDraft; expect(draft.open).toBe(true); expect(draft.selectedProjectId).toBeNull(); expect(draft.directoryOverride).toBe('/external/worktree'); }); test('respects explicit directoryOverride over active project', () => { useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/projects/beta/src' }); const draft = useSessionUIStore.getState().newSessionDraft; expect(draft.open).toBe(true); expect(draft.directoryOverride).toBe('/projects/beta/src'); }); test('respects explicit selectedProjectId over active project', () => { useSessionUIStore.getState().openNewSessionDraft({ selectedProjectId: projectB.id }); const draft = useSessionUIStore.getState().newSessionDraft; expect(draft.open).toBe(true); expect(draft.selectedProjectId).toBe(projectB.id); }); }); describe('createSession draft lifecycle', () => { let originalCreateSession; beforeEach(() => { originalCreateSession = opencodeClient.createSession; useSessionUIStore.setState({ currentSessionId: null, currentSessionDirectory: null, newSessionDraft: { open: true, directoryOverride: '/projects/alpha', parentID: null, title: 'Draft title' }, }); }); afterEach(() => { opencodeClient.createSession = originalCreateSession; }); test('keeps the draft open when session creation fails', async () => { opencodeClient.createSession = async () => { throw new Error('offline'); }; const session = await useSessionUIStore.getState().createSession('Draft title', '/projects/alpha'); expect(session).toBeNull(); expect(useSessionUIStore.getState().newSessionDraft.open).toBe(true); expect(useSessionUIStore.getState().newSessionDraft.title).toBe('Draft title'); }); }); 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: [], message: {}, part: {}, 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); }); }); describe('archiveSessions option forwarding', () => { let originalUpdateSession; let updateSessionCalls; beforeEach(() => { updateSessionCalls = []; originalUpdateSession = opencodeClient.updateSession; opencodeClient.updateSession = (sessionId) => { updateSessionCalls.push(sessionId); return Promise.resolve(null); }; }); afterEach(() => { opencodeClient.updateSession = originalUpdateSession; }); // The store used to accept an options object and silently drop it, so a // caller-supplied runtime key had no effect. Passing a key that cannot match // the active runtime must abort the batch before any SDK call. test('honors expectedRuntimeKey instead of discarding the options object', async () => { const result = await useSessionUIStore.getState().archiveSessions(['session-x', 'session-y'], { expectedRuntimeKey: 'runtime-that-is-not-active', }); expect(result).toEqual({ archivedIds: [], failedIds: ['session-x', 'session-y'] }); expect(updateSessionCalls).toEqual([]); }); test('unarchiveSessions honors expectedRuntimeKey instead of discarding the options object', async () => { const result = await useSessionUIStore.getState().unarchiveSessions(['session-x', 'session-y'], { expectedRuntimeKey: 'runtime-that-is-not-active', }); expect(result).toEqual({ restoredIds: [], failedIds: ['session-x', 'session-y'] }); expect(updateSessionCalls).toEqual([]); }); }); describe('deleteSessions option forwarding', () => { let originalDeleteSession; let deleteSessionCalls; beforeEach(() => { deleteSessionCalls = []; originalDeleteSession = opencodeClient.deleteSession; opencodeClient.deleteSession = (sessionId) => { deleteSessionCalls.push(sessionId); return Promise.resolve(true); }; }); afterEach(() => { opencodeClient.deleteSession = originalDeleteSession; }); // The store accepted an options object and dropped it on both the single and // batch delete paths. A key that cannot match the active runtime must abort // before any SDK call rather than deleting and erasing persisted state. test('honors expectedRuntimeKey on the batch delete instead of discarding options', async () => { const result = await useSessionUIStore.getState().deleteSessions(['session-x', 'session-y'], { expectedRuntimeKey: 'runtime-that-is-not-active', }); expect(result).toEqual({ deletedIds: [], failedIds: ['session-x', 'session-y'] }); expect(deleteSessionCalls).toEqual([]); }); test('honors expectedRuntimeKey on the single delete instead of discarding options', async () => { const deleted = await useSessionUIStore.getState().deleteSession('session-x', { expectedRuntimeKey: 'runtime-that-is-not-active', }); expect(deleted).toBe(false); expect(deleteSessionCalls).toEqual([]); }); });