Sending recorded the effective variant - inherited settings/agent defaults included - as the session's explicit per-session choice, so the picker jumped from Default to the inherited effort right after the send and the session stopped following later default changes. Record only an explicit picker override when the send reflects the live selection; sends carrying a captured configuration for another session keep their captured variant.
1118 lines
42 KiB
JavaScript
1118 lines
42 KiB
JavaScript
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 { useSelectionStore } from './selection-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';
|
|
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
|
import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
|
|
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
|
|
|
/**
|
|
* 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('draft materialization transition identity', () => {
|
|
beforeEach(() => {
|
|
useSessionUIStore.setState({
|
|
currentSessionId: null,
|
|
currentSessionDirectory: null,
|
|
materializedDraftSessionId: null,
|
|
newSessionDraft: { open: true, target: 'project', directoryOverride: '/projects/alpha' },
|
|
});
|
|
});
|
|
|
|
test('marks and consumes only the submitted draft session', () => {
|
|
useSessionUIStore.getState().setCurrentSession(
|
|
'session-created',
|
|
'/projects/alpha',
|
|
'submitted-draft',
|
|
);
|
|
|
|
expect(useSessionUIStore.getState().materializedDraftSessionId).toBe('session-created');
|
|
|
|
useSessionUIStore.getState().clearMaterializedDraftSession('another-session');
|
|
expect(useSessionUIStore.getState().materializedDraftSessionId).toBe('session-created');
|
|
|
|
useSessionUIStore.getState().clearMaterializedDraftSession('session-created');
|
|
expect(useSessionUIStore.getState().materializedDraftSessionId).toBeNull();
|
|
});
|
|
|
|
test('clears the marker when navigating from a draft to an existing session', () => {
|
|
useSessionUIStore.getState().setCurrentSession(
|
|
'session-created',
|
|
'/projects/alpha',
|
|
'submitted-draft',
|
|
);
|
|
useSessionUIStore.setState({
|
|
newSessionDraft: { open: true, target: 'project', directoryOverride: '/projects/alpha' },
|
|
});
|
|
useSessionUIStore.getState().setCurrentSession('session-existing', '/projects/alpha');
|
|
|
|
expect(useSessionUIStore.getState().materializedDraftSessionId).toBeNull();
|
|
});
|
|
});
|
|
|
|
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('sendMessage effort recording', () => {
|
|
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 sendWithVariant = (variant, sessionId) => useSessionUIStore.getState().sendMessage(
|
|
'hello',
|
|
'provider-a',
|
|
'model-a',
|
|
'plan',
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
variant,
|
|
'normal',
|
|
{ target: { runtimeKey: getRuntimeKey(), sessionId, directory: '/current/project' } },
|
|
);
|
|
|
|
test('does not pin an inherited effort as the session choice', async () => {
|
|
useConfigStore.setState({
|
|
currentProviderId: 'provider-a',
|
|
currentModelId: 'model-a',
|
|
currentAgentName: 'plan',
|
|
currentVariant: 'low',
|
|
currentVariantSelection: { override: undefined, inherited: 'low' },
|
|
});
|
|
|
|
await sendWithVariant('low', 'session-current');
|
|
|
|
// The send still carries the inherited effort; it is just not recorded
|
|
// as this session's explicit choice.
|
|
expect(calls[0].variant).toBe('low');
|
|
expect(useSelectionStore.getState().getAgentModelVariantForSession('session-current', 'plan', 'provider-a', 'model-a')).toBe(undefined);
|
|
});
|
|
|
|
test('records an explicit effort choice with the send', async () => {
|
|
useConfigStore.setState({
|
|
currentProviderId: 'provider-a',
|
|
currentModelId: 'model-a',
|
|
currentAgentName: 'plan',
|
|
currentVariant: 'high',
|
|
currentVariantSelection: { override: 'high', inherited: 'low' },
|
|
});
|
|
|
|
await sendWithVariant('high', 'session-current');
|
|
|
|
expect(useSelectionStore.getState().getAgentModelVariantForSession('session-current', 'plan', 'provider-a', 'model-a')).toBe('high');
|
|
});
|
|
|
|
test('keeps the captured variant for a send targeting another session', async () => {
|
|
useConfigStore.setState({
|
|
currentProviderId: 'provider-a',
|
|
currentModelId: 'model-a',
|
|
currentAgentName: 'plan',
|
|
currentVariant: 'low',
|
|
currentVariantSelection: { override: undefined, inherited: 'low' },
|
|
});
|
|
|
|
await sendWithVariant('low', 'session-other');
|
|
|
|
expect(useSelectionStore.getState().getAgentModelVariantForSession('session-other', 'plan', 'provider-a', 'model-a')).toBe('low');
|
|
});
|
|
});
|
|
|
|
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' };
|
|
|
|
const DRAFT_TARGET_KEY = 'oc.chatInput.lastDraftTarget';
|
|
|
|
beforeEach(() => {
|
|
getDeferredSafeStorage().removeItem(DRAFT_TARGET_KEY);
|
|
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 });
|
|
});
|
|
|
|
afterEach(() => {
|
|
getDeferredSafeStorage().removeItem(DRAFT_TARGET_KEY);
|
|
});
|
|
|
|
test('defaults an implicit draft to Chat when active project differs', () => {
|
|
useSessionUIStore.getState().openNewSessionDraft();
|
|
const draft = useSessionUIStore.getState().newSessionDraft;
|
|
|
|
expect(draft.open).toBe(true);
|
|
expect(draft.target).toBe('chat');
|
|
expect(draft.selectedProjectId).toBeNull();
|
|
expect(draft.directoryOverride).toBeNull();
|
|
});
|
|
|
|
test('defaults an implicit draft to Chat 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.target).toBe('chat');
|
|
expect(draft.directoryOverride).toBeNull();
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
test('reopens an implicit draft on the project the target selector was last set to', () => {
|
|
useSessionUIStore.getState().openNewSessionDraft({ selectedProjectId: projectB.id });
|
|
useSessionUIStore.getState().closeNewSessionDraft();
|
|
// A chat session leaves its managed scratch directory current; the project
|
|
// to reopen on can only come from the recorded target.
|
|
useDirectoryStore.getState().setDirectory(
|
|
'/Users/tester/.config/openchamber/chats/ses_chat',
|
|
{ showOverlay: false },
|
|
);
|
|
|
|
useSessionUIStore.getState().openNewSessionDraft();
|
|
const draft = useSessionUIStore.getState().newSessionDraft;
|
|
|
|
expect(draft.target).toBe('project');
|
|
expect(draft.selectedProjectId).toBe(projectB.id);
|
|
expect(draft.directoryOverride).toBe(projectB.path);
|
|
});
|
|
|
|
test('setNewSessionDraftTarget records Chat, so the next implicit draft opens on Chat', () => {
|
|
useSessionUIStore.getState().openNewSessionDraft({ selectedProjectId: projectB.id });
|
|
useSessionUIStore.getState().setNewSessionDraftTarget({ projectId: CHAT_DRAFT_PROJECT_ID });
|
|
useSessionUIStore.getState().closeNewSessionDraft();
|
|
|
|
useSessionUIStore.getState().openNewSessionDraft();
|
|
|
|
expect(useSessionUIStore.getState().newSessionDraft.target).toBe('chat');
|
|
});
|
|
|
|
test('keeps the Chat default for a record written before the target was stored', () => {
|
|
getDeferredSafeStorage().setItem(
|
|
DRAFT_TARGET_KEY,
|
|
JSON.stringify({ projectId: projectB.id, directory: projectB.path }),
|
|
);
|
|
|
|
useSessionUIStore.getState().openNewSessionDraft();
|
|
|
|
expect(useSessionUIStore.getState().newSessionDraft.target).toBe('chat');
|
|
});
|
|
|
|
test('falls back to Chat when the last project target no longer exists', () => {
|
|
getDeferredSafeStorage().setItem(
|
|
DRAFT_TARGET_KEY,
|
|
JSON.stringify({ projectId: 'proj-removed', directory: '/projects/removed', target: 'project' }),
|
|
);
|
|
|
|
useSessionUIStore.getState().openNewSessionDraft();
|
|
|
|
expect(useSessionUIStore.getState().newSessionDraft.target).toBe('chat');
|
|
});
|
|
});
|
|
|
|
describe('createSession draft lifecycle', () => {
|
|
let originalCreateSession;
|
|
let originalGetDirectoryAvailability;
|
|
let originalProjects;
|
|
let originalActiveProjectId;
|
|
let originalDirectoryState;
|
|
let originalClientDirectory;
|
|
let originalLastDirectory;
|
|
|
|
beforeEach(() => {
|
|
originalCreateSession = opencodeClient.createSession;
|
|
originalGetDirectoryAvailability = opencodeClient.getDirectoryAvailability;
|
|
originalProjects = useProjectsStore.getState().projects;
|
|
originalActiveProjectId = useProjectsStore.getState().activeProjectId;
|
|
originalDirectoryState = useDirectoryStore.getState();
|
|
originalClientDirectory = opencodeClient.getDirectory();
|
|
originalLastDirectory = getDeferredSafeStorage().getItem('lastDirectory');
|
|
useSessionUIStore.setState({
|
|
currentSessionId: null,
|
|
currentSessionDirectory: null,
|
|
newSessionDraft: { open: true, directoryOverride: '/projects/alpha', parentID: null, title: 'Draft title' },
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
opencodeClient.createSession = originalCreateSession;
|
|
opencodeClient.getDirectoryAvailability = originalGetDirectoryAvailability;
|
|
useProjectsStore.setState({ projects: originalProjects, activeProjectId: originalActiveProjectId });
|
|
useDirectoryStore.setState(originalDirectoryState, true);
|
|
opencodeClient.setDirectory(originalClientDirectory ?? undefined);
|
|
if (originalLastDirectory === null) {
|
|
getDeferredSafeStorage().removeItem('lastDirectory');
|
|
} else {
|
|
getDeferredSafeStorage().setItem('lastDirectory', originalLastDirectory);
|
|
}
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
test('rewrites an implicit new-chat draft to the active project before the session is created', async () => {
|
|
useProjectsStore.setState({
|
|
projects: [{ id: 'project-main', path: '/projects/main', label: 'Main' }],
|
|
activeProjectId: 'project-main',
|
|
});
|
|
useDirectoryStore.getState().setDirectory('/private/deleted-worktree', { showOverlay: false });
|
|
opencodeClient.getDirectoryAvailability = async () => 'missing';
|
|
|
|
useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/deleted-worktree' });
|
|
await Bun.sleep(0);
|
|
|
|
expect(useSessionUIStore.getState().newSessionDraft.directoryOverride).toBe('/projects/main');
|
|
expect(useSessionUIStore.getState().newSessionDraft.selectedProjectId).toBe('project-main');
|
|
expect(getDeferredSafeStorage().getItem('lastDirectory')).toBe('/private/deleted-worktree');
|
|
});
|
|
|
|
test('falls back to the current active project when a regular new-chat directory is missing', async () => {
|
|
const createSessionCalls = [];
|
|
useProjectsStore.setState({
|
|
projects: [
|
|
{ id: 'project-draft', path: '/projects/draft', label: 'Draft' },
|
|
{ id: 'project-active', path: '/projects/active', label: 'Active' },
|
|
],
|
|
activeProjectId: 'project-active',
|
|
});
|
|
useDirectoryStore.getState().setDirectory('/private/deleted-worktree', { showOverlay: false });
|
|
useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/deleted-worktree' });
|
|
opencodeClient.getDirectoryAvailability = async () => 'missing';
|
|
opencodeClient.createSession = async (_params, directory) => {
|
|
createSessionCalls.push(directory);
|
|
return { id: 'session-fallback', directory };
|
|
};
|
|
|
|
await useSessionUIStore.getState().createSession('Draft title', '/private/deleted-worktree');
|
|
|
|
expect(createSessionCalls).toEqual(['/projects/active']);
|
|
expect(useDirectoryStore.getState().currentDirectory).toBe('/projects/active');
|
|
expect(getDeferredSafeStorage().getItem('lastDirectory')).toBe('/projects/active');
|
|
});
|
|
|
|
test('keeps an explicitly pinned worktree directory unchanged', async () => {
|
|
const createSessionCalls = [];
|
|
useProjectsStore.setState({
|
|
projects: [{ id: 'project-main', path: '/projects/main', label: 'Main' }],
|
|
activeProjectId: 'project-main',
|
|
});
|
|
useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/deleted-worktree', preserveDirectoryOverride: true });
|
|
expect(useSessionUIStore.getState().newSessionDraft.preserveDirectoryOverride).toBe(true);
|
|
opencodeClient.getDirectoryAvailability = async () => 'missing';
|
|
opencodeClient.createSession = async (_params, directory) => {
|
|
createSessionCalls.push(directory);
|
|
return { id: 'session-pinned', directory };
|
|
};
|
|
|
|
await useSessionUIStore.getState().createSession('Draft title', '/private/deleted-worktree');
|
|
|
|
expect(createSessionCalls).toEqual(['/private/deleted-worktree']);
|
|
});
|
|
|
|
test('keeps a ChatInput-style current-directory draft recoverable when that path is missing', async () => {
|
|
const createSessionCalls = [];
|
|
useProjectsStore.setState({
|
|
projects: [{ id: 'project-main', path: '/projects/main', label: 'Main' }],
|
|
activeProjectId: 'project-main',
|
|
});
|
|
useDirectoryStore.getState().setDirectory('/private/deleted-worktree', { showOverlay: false });
|
|
useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/deleted-worktree' });
|
|
expect(useSessionUIStore.getState().newSessionDraft.preserveDirectoryOverride).not.toBe(true);
|
|
opencodeClient.getDirectoryAvailability = async () => 'missing';
|
|
opencodeClient.createSession = async (_params, directory) => {
|
|
createSessionCalls.push(directory);
|
|
return { id: 'session-chat-input', directory };
|
|
};
|
|
|
|
await useSessionUIStore.getState().createSession('Draft title', '/private/deleted-worktree');
|
|
|
|
expect(createSessionCalls).toEqual(['/projects/main']);
|
|
});
|
|
|
|
test('keeps the stale directory when its availability cannot be confirmed', async () => {
|
|
const createSessionCalls = [];
|
|
useProjectsStore.setState({
|
|
projects: [{ id: 'project-main', path: '/projects/main', label: 'Main' }],
|
|
activeProjectId: 'project-main',
|
|
});
|
|
useDirectoryStore.getState().setDirectory('/private/unavailable-worktree', { showOverlay: false });
|
|
useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/unavailable-worktree' });
|
|
opencodeClient.getDirectoryAvailability = async () => 'unknown';
|
|
opencodeClient.createSession = async (_params, directory) => {
|
|
createSessionCalls.push(directory);
|
|
return { id: 'session-unavailable', directory };
|
|
};
|
|
|
|
await useSessionUIStore.getState().createSession('Draft title', '/private/unavailable-worktree');
|
|
|
|
expect(createSessionCalls).toEqual(['/private/unavailable-worktree']);
|
|
expect(useDirectoryStore.getState().currentDirectory).toBe('/private/unavailable-worktree');
|
|
});
|
|
|
|
test('still creates against the active project when the draft is rewritten during the create probe', async () => {
|
|
const createSessionCalls = [];
|
|
const availabilityResolvers = [];
|
|
useProjectsStore.setState({
|
|
projects: [{ id: 'project-main', path: '/projects/main', label: 'Main' }],
|
|
activeProjectId: 'project-main',
|
|
});
|
|
useDirectoryStore.getState().setDirectory('/private/deleted-worktree', { showOverlay: false });
|
|
opencodeClient.getDirectoryAvailability = () => new Promise((resolve) => {
|
|
availabilityResolvers.push(resolve);
|
|
});
|
|
opencodeClient.createSession = async (_params, directory) => {
|
|
createSessionCalls.push(directory);
|
|
return { id: 'session-race', directory };
|
|
};
|
|
|
|
useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/deleted-worktree' });
|
|
const createPromise = useSessionUIStore.getState().createSession('Draft title', '/private/deleted-worktree');
|
|
expect(availabilityResolvers.length).toBe(2);
|
|
|
|
availabilityResolvers[0]('missing');
|
|
await Bun.sleep(0);
|
|
expect(useSessionUIStore.getState().newSessionDraft.directoryOverride).toBe('/projects/main');
|
|
|
|
availabilityResolvers[1]('missing');
|
|
const session = await createPromise;
|
|
|
|
expect(session).not.toBeNull();
|
|
expect(createSessionCalls).toEqual(['/projects/main']);
|
|
});
|
|
|
|
test('does not persist a fallback when session creation fails', async () => {
|
|
useProjectsStore.setState({
|
|
projects: [{ id: 'project-main', path: '/projects/main', label: 'Main' }],
|
|
activeProjectId: 'project-main',
|
|
});
|
|
useDirectoryStore.getState().setDirectory('/private/deleted-worktree', { showOverlay: false });
|
|
useSessionUIStore.getState().openNewSessionDraft();
|
|
opencodeClient.getDirectoryAvailability = async () => 'missing';
|
|
opencodeClient.createSession = async () => {
|
|
throw new Error('offline');
|
|
};
|
|
|
|
const session = await useSessionUIStore.getState().createSession('Draft title', '/private/deleted-worktree');
|
|
|
|
expect(session).toBeNull();
|
|
expect(useDirectoryStore.getState().currentDirectory).toBe('/private/deleted-worktree');
|
|
expect(getDeferredSafeStorage().getItem('lastDirectory')).toBe('/private/deleted-worktree');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Issues #2222 and #2315 — send target must be snapshotted at submit time so a
|
|
// later sidebar/project selection cannot reroute a pending draft or session
|
|
// send to whichever session happens to be current when the async work resumes.
|
|
// ---------------------------------------------------------------------------
|
|
describe('sendMessage draft snapshot (issues #2222 / #2315)', () => {
|
|
const sendMessageCalls = [];
|
|
const createSessionCalls = [];
|
|
let originalSendMessage;
|
|
let originalCreateSession;
|
|
|
|
beforeEach(() => {
|
|
sendMessageCalls.length = 0;
|
|
createSessionCalls.length = 0;
|
|
|
|
const childStore = {
|
|
getState: () => ({ session: [], message: {}, part: {}, session_status: {} }),
|
|
setState: () => {},
|
|
};
|
|
const childStores = {
|
|
children: new Map(),
|
|
ensureChild: () => childStore,
|
|
getChild: () => childStore,
|
|
};
|
|
setActionRefs(opencodeClient, childStores, () => '/projects/alpha');
|
|
setOptimisticRefs(() => {}, () => {});
|
|
useConfigStore.setState({ isConnected: true });
|
|
|
|
originalSendMessage = opencodeClient.sendMessage;
|
|
originalCreateSession = opencodeClient.createSession;
|
|
opencodeClient.sendMessage = async (params) => {
|
|
sendMessageCalls.push(params);
|
|
return 'msg';
|
|
};
|
|
opencodeClient.createSession = async (_params, directory) => {
|
|
createSessionCalls.push(directory);
|
|
return { id: 'session-materialized', directory: directory ?? '/projects/alpha' };
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
opencodeClient.sendMessage = originalSendMessage;
|
|
opencodeClient.createSession = originalCreateSession;
|
|
useSessionUIStore.setState({
|
|
currentSessionId: null,
|
|
currentSessionDirectory: null,
|
|
newSessionDraft: { open: false, directoryOverride: null, parentID: null },
|
|
});
|
|
useProjectsStore.setState({ projects: [], activeProjectId: null });
|
|
useSessionDisplayStore.setState({ singleProjectId: null });
|
|
});
|
|
|
|
test('draft send snapshots the draft; switching to another project mid-flight still targets the materialized session', async () => {
|
|
useProjectsStore.setState({
|
|
projects: [
|
|
{ id: 'project-alpha', path: '/projects/alpha', label: 'Alpha' },
|
|
{ id: 'project-beta', path: '/projects/beta', label: 'Beta' },
|
|
],
|
|
activeProjectId: 'project-alpha',
|
|
});
|
|
useSessionDisplayStore.setState({ singleProjectId: 'project-alpha' });
|
|
const draftSnapshot = {
|
|
open: true,
|
|
directoryOverride: '/projects/alpha',
|
|
parentID: null,
|
|
title: 'Project A draft',
|
|
};
|
|
useSessionUIStore.setState({
|
|
currentSessionId: null,
|
|
currentSessionDirectory: null,
|
|
newSessionDraft: draftSnapshot,
|
|
});
|
|
|
|
const sendPromise = useSessionUIStore.getState().sendMessage(
|
|
'message for project A',
|
|
'provider-a',
|
|
'model-a',
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
'normal',
|
|
{ draftSnapshot },
|
|
);
|
|
|
|
// A sidebar switch while the send is still in flight must not reroute it.
|
|
useSessionUIStore.getState().setCurrentSession('session-project-b', '/projects/beta');
|
|
expect(useSessionDisplayStore.getState().singleProjectId).toBe('project-beta');
|
|
|
|
await sendPromise;
|
|
|
|
expect(createSessionCalls).toHaveLength(1);
|
|
expect(createSessionCalls[0]).toBe('/projects/alpha');
|
|
expect(sendMessageCalls).toHaveLength(1);
|
|
expect(sendMessageCalls[0].id).toBe('session-materialized');
|
|
expect(sendMessageCalls[0].directory).toBe('/projects/alpha');
|
|
expect(useSessionDisplayStore.getState().singleProjectId).toBe('project-alpha');
|
|
});
|
|
|
|
test('existing-session send keeps the submit-time target even when selection changes', async () => {
|
|
useSessionUIStore.setState({
|
|
currentSessionId: 'session-project-a',
|
|
currentSessionDirectory: '/projects/alpha',
|
|
newSessionDraft: { open: false, directoryOverride: null, parentID: null },
|
|
});
|
|
|
|
const sendPromise = useSessionUIStore.getState().sendMessage(
|
|
'message for project A',
|
|
'provider-a',
|
|
'model-a',
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
'normal',
|
|
{ target: { runtimeKey: getRuntimeKey(), sessionId: 'session-project-a', directory: '/projects/alpha' } },
|
|
);
|
|
|
|
useSessionUIStore.getState().setCurrentSession('session-project-b', '/projects/beta');
|
|
|
|
await sendPromise;
|
|
|
|
expect(sendMessageCalls).toHaveLength(1);
|
|
expect(sendMessageCalls[0].id).toBe('session-project-a');
|
|
expect(sendMessageCalls[0].directory).toBe('/projects/alpha');
|
|
});
|
|
});
|
|
|
|
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([]);
|
|
});
|
|
});
|