* fix(chat): pin sends to captured session * fix(chat): handle runtime cancellation consistently
620 lines
22 KiB
JavaScript
620 lines
22 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 { 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([]);
|
|
});
|
|
});
|