import assert from 'node:assert/strict'; import { mkdtemp, mkdir, readFile, realpath, rename, rm, symlink, writeFile, } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; import { BotWorkspaceStore, createBotWorkspaceScope, } from '../src/channels/shared/bot-workspace-store.mjs'; import { runModelCommand } from '../src/channels/shared/model-command.mjs'; import { askInWorkspaceSession } from '../src/channels/shared/workspace-session.mjs'; async function fixture(t) { const root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-im-session-bind-'))); t.after(() => rm(root, { recursive: true, force: true })); const defaultWorkspace = join(root, 'default'); const alternateWorkspace = join(root, 'alternate'); const thirdWorkspace = join(root, 'third'); await Promise.all([ mkdir(defaultWorkspace), mkdir(alternateWorkspace), mkdir(thirdWorkspace), ]); return { root, defaultWorkspace, alternateWorkspace, thirdWorkspace, path: join(root, 'workspaces.json'), }; } function deferred() { let resolve; const promise = new Promise((settle) => { resolve = settle; }); return { promise, resolve }; } async function within(promise, timeoutMs, message) { let timer; try { return await Promise.race([ promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), timeoutMs); }), ]); } finally { clearTimeout(timer); } } function memoryState(initial = {}) { let sessions = { ...initial }; let clears = 0; let sets = 0; return { sessionFor(key) { return sessions[key] ?? null; }, async setSession(key, sessionId) { sets += 1; sessions[key] = sessionId; }, async clearSessions() { clears += 1; sessions = {}; }, snapshot() { return { ...sessions }; }, get clears() { return clears; }, get sets() { return sets; }, }; } test('binding across workspaces clears old mappings, fences the generation, and binds one conversation', async (t) => { const { path, defaultWorkspace, alternateWorkspace } = await fixture(t); const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load(); await workspaces.ensure('bot_bind'); const previousGeneration = workspaces.generationFor('bot_bind'); const state = memoryState({ 'direct:one': 'old-one', 'group:two': 'old-two' }); const calls = []; const harness = { async adoptWorkspaceSession(sessionId) { calls.push(sessionId); return { sessionId, workspace: alternateWorkspace, title: 'Existing conversation', archived: true, }; }, }; const scope = createBotWorkspaceScope(harness, { botId: 'bot_bind', workspaces, state }); const result = await scope.harness.bindWorkspaceSession('direct:one', 'session-target'); assert.deepEqual(calls, ['session-target']); assert.deepEqual(result, { sessionId: 'session-target', workspace: alternateWorkspace, title: 'Existing conversation', archived: true, }); assert.equal(workspaces.workspaceFor('bot_bind'), alternateWorkspace); assert.notEqual(workspaces.generationFor('bot_bind'), previousGeneration); assert.deepEqual(state.snapshot(), { 'direct:one': 'session-target' }); assert.equal(state.clears, 1); assert.equal(state.sets, 1); assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), { version: 1, workspaces: { bot_bind: alternateWorkspace }, }); }); test('binding inside the current workspace only replaces the selected conversation', async (t) => { const { path, defaultWorkspace } = await fixture(t); const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load(); await workspaces.ensure('bot_same'); const generation = workspaces.generationFor('bot_same'); const state = memoryState({ 'direct:one': 'old-one', 'group:two': 'kept-two' }); const scope = createBotWorkspaceScope({ async adoptWorkspaceSession(sessionId) { return { sessionId, workspace: defaultWorkspace }; }, }, { botId: 'bot_same', workspaces, state }); await scope.harness.bindWorkspaceSession('direct:one', 'session-current-workspace'); assert.equal(workspaces.workspaceFor('bot_same'), defaultWorkspace); assert.equal(workspaces.generationFor('bot_same'), generation); assert.equal(state.clears, 0); assert.deepEqual(state.snapshot(), { 'direct:one': 'session-current-workspace', 'group:two': 'kept-two', }); }); test('binding through an equivalent real path does not clear a symlink workspace', async (t) => { const { root, path, defaultWorkspace } = await fixture(t); const linkedWorkspace = join(root, 'default-link'); await symlink(defaultWorkspace, linkedWorkspace); const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace: linkedWorkspace }).load(); await workspaces.ensure('bot_symlink', { workspace: linkedWorkspace }); const generation = workspaces.generationFor('bot_symlink'); const state = memoryState({ selected: 'session-old', other: 'session-kept' }); const scope = createBotWorkspaceScope({ async adoptWorkspaceSession(sessionId) { return { sessionId, workspace: defaultWorkspace }; }, }, { botId: 'bot_symlink', workspaces, state }); await scope.harness.bindWorkspaceSession('selected', 'session-target'); assert.equal(workspaces.workspaceFor('bot_symlink'), linkedWorkspace); assert.equal(workspaces.generationFor('bot_symlink'), generation); assert.equal(state.clears, 0); assert.deepEqual(state.snapshot(), { selected: 'session-target', other: 'session-kept', }); }); test('the next message continues the bound Session without creating a new one', async (t) => { const { path, defaultWorkspace, alternateWorkspace } = await fixture(t); const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load(); await workspaces.ensure('bot_continue'); const state = memoryState({ conversation: 'session-old' }); const asked = []; let createCalls = 0; let renameCalls = 0; const scope = createBotWorkspaceScope({ async adoptWorkspaceSession(sessionId) { return { sessionId, workspace: alternateWorkspace }; }, async sessionExists(sessionId) { return sessionId === 'session-target'; }, async createSession() { createCalls += 1; return 'session-created-unexpectedly'; }, async renameSession() { renameCalls += 1; }, async ask(sessionId, text) { asked.push({ sessionId, text }); return 'continued answer'; }, }, { botId: 'bot_continue', workspaces, state }); await scope.harness.bindWorkspaceSession('conversation', 'session-target'); const reply = await askInWorkspaceSession({ harness: scope.harness, state: scope.state, key: 'conversation', text: 'continue here', }); assert.deepEqual(reply, { sessionId: 'session-target', answer: 'continued answer' }); assert.deepEqual(asked, [{ sessionId: 'session-target', text: 'continue here' }]); assert.equal(createCalls, 0); assert.equal(renameCalls, 0); }); test('a newly created enhanced Session is renamed from the first prompt before ask', async (t) => { const { path, defaultWorkspace } = await fixture(t); const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load(); await workspaces.ensure('bot_title'); const state = memoryState(); const calls = []; const scope = createBotWorkspaceScope({ async createSession() { calls.push(['create']); return 'session-titled'; }, async renameSession(sessionId, title) { calls.push(['rename', sessionId, title]); return { title, seq: 0 }; }, async ask(sessionId, prompt) { calls.push(['ask', sessionId, prompt]); return 'answer'; }, }, { botId: 'bot_title', workspaces, state }); const result = await askInWorkspaceSession({ harness: scope.harness, state: scope.state, key: 'conversation', text: '请检查今天的订单', content: '{"channel":"qq"}\n\n请检查今天的订单', contextEnhanced: true, }); assert.deepEqual(result, { sessionId: 'session-titled', answer: 'answer' }); assert.deepEqual(calls, [ ['create'], ['rename', 'session-titled', '请检查今天的订单'], ['ask', 'session-titled', '{"channel":"qq"}\n\n请检查今天的订单'], ]); }); test('an ordinary first prompt keeps DSH title handling while model switch shares its binding', async () => { const state = memoryState(); const askStarted = deferred(); const releaseAsk = deferred(); const calls = []; let creations = 0; const catalog = { groups: [{ id: 'provider', name: 'Provider', models: [{ id: 'model', name: 'Model' }], }], failures: [], }; const harness = { async createSession() { creations += 1; calls.push(['createSession']); return `session-${creations}`; }, workspaceSession(sessionId) { return { async sessionExists() { return true; }, async renameTitle(title) { calls.push(['renameTitle', sessionId, title]); }, async ask(text) { calls.push(['ask', sessionId, text]); askStarted.resolve(); await releaseAsk.promise; return 'answer'; }, async isRunning() { return false; }, async hasActiveTurn() { return false; }, async models() { return { ...catalog, current: { provider: 'provider', model: 'model' }, routable: true, }; }, async selectModel(selection) { calls.push(['selectModel', sessionId, selection]); return { selected: selection }; }, }; }, async listModels() { return catalog; }, }; const prompting = askInWorkspaceSession({ harness, state, key: 'conversation', text: 'first prompt', }); const switching = runModelCommand( '/model provider/model', harness, state, 'conversation', ); await askStarted.promise; let switchResult; try { switchResult = await within( switching, 500, 'model switching waited for the running ask instead of only the binding transaction', ); } finally { releaseAsk.resolve(); } const promptResult = await prompting; assert.match(switchResult.message, /模型已切换为/); assert.deepEqual(promptResult, { sessionId: 'session-1', answer: 'answer' }); assert.equal(creations, 1); assert.deepEqual(state.snapshot(), { conversation: 'session-1' }); assert.deepEqual(calls, [ ['createSession'], ['ask', 'session-1', 'first prompt'], ['selectModel', 'session-1', { provider: 'provider', model: 'model' }], ]); }); test('workspace sessions forward structured multimodal prompt content unchanged', async (t) => { const { path, defaultWorkspace } = await fixture(t); const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load(); await workspaces.ensure('bot_multimodal'); const state = memoryState({ conversation: 'session-image' }); const prompts = []; const content = [ { type: 'text', text: '这是什么?' }, { type: 'image', mediaType: 'image/png', data: 'AAAA' }, ]; const scope = createBotWorkspaceScope({ async sessionExists() { return true; }, async ask(sessionId, prompt) { prompts.push({ sessionId, prompt }); return 'image answer'; }, }, { botId: 'bot_multimodal', workspaces, state }); assert.deepEqual(await askInWorkspaceSession({ harness: scope.harness, state: scope.state, key: 'conversation', content, }), { sessionId: 'session-image', answer: 'image answer' }); assert.deepEqual(prompts, [{ sessionId: 'session-image', prompt: content }]); }); test('adoption errors and invalid adoption responses leave local state unchanged', async (t) => { const { path, defaultWorkspace, alternateWorkspace } = await fixture(t); const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load(); await workspaces.ensure('bot_reject'); const state = memoryState({ conversation: 'session-old' }); const rejection = Object.assign(new Error('subagent sessions cannot be adopted'), { code: 'agent-busy', }); const rejectedScope = createBotWorkspaceScope({ async adoptWorkspaceSession() { throw rejection; }, }, { botId: 'bot_reject', workspaces, state }); await assert.rejects( rejectedScope.harness.bindWorkspaceSession('conversation', 'session-child'), { code: 'agent-busy' }, ); assert.equal(workspaces.workspaceFor('bot_reject'), defaultWorkspace); assert.deepEqual(state.snapshot(), { conversation: 'session-old' }); const invalidScope = createBotWorkspaceScope({ async adoptWorkspaceSession() { return { sessionId: 'different-session', workspace: alternateWorkspace }; }, }, { botId: 'bot_reject', workspaces, state }); await assert.rejects( invalidScope.harness.bindWorkspaceSession('conversation', 'session-requested'), /invalid adopted workspace session/, ); assert.equal(workspaces.workspaceFor('bot_reject'), defaultWorkspace); assert.deepEqual(state.snapshot(), { conversation: 'session-old' }); }); test('a workspace switch during adoption wins instead of being overwritten by the stale bind', async (t) => { const { path, defaultWorkspace, alternateWorkspace } = await fixture(t); const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load(); await workspaces.ensure('bot_adoption_race'); const state = memoryState({ conversation: 'session-old', other: 'session-other' }); const adoptionStarted = deferred(); const releaseAdoption = deferred(); const scope = createBotWorkspaceScope({ async adoptWorkspaceSession(sessionId) { adoptionStarted.resolve(); await releaseAdoption.promise; return { sessionId, workspace: defaultWorkspace }; }, }, { botId: 'bot_adoption_race', workspaces, state }); const binding = scope.harness.bindWorkspaceSession('conversation', 'session-target'); await adoptionStarted.promise; await scope.harness.switchWorkspace(alternateWorkspace); releaseAdoption.resolve(); await assert.rejects(binding, { code: 'workspace-session-stale' }); assert.equal(workspaces.workspaceFor('bot_adoption_race'), alternateWorkspace); assert.deepEqual(state.snapshot(), {}); assert.equal(state.sets, 0); }); test('the store queue rejects a generation change after adoption validation', async (t) => { const { path, defaultWorkspace, alternateWorkspace } = await fixture(t); const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load(); await workspaces.ensure('bot_queue_fence'); const state = memoryState({ conversation: 'session-old', other: 'session-other' }); const bindInQueue = workspaces.bindWorkspaceSession.bind(workspaces); workspaces.bindWorkspaceSession = async (...args) => { await workspaces.setWorkspace('bot_queue_fence', alternateWorkspace, { clearSessions: () => state.clearSessions(), }); return bindInQueue(...args); }; const scope = createBotWorkspaceScope({ async adoptWorkspaceSession(sessionId) { return { sessionId, workspace: defaultWorkspace }; }, }, { botId: 'bot_queue_fence', workspaces, state }); await assert.rejects( scope.harness.bindWorkspaceSession('conversation', 'session-target'), { code: 'workspace-session-stale' }, ); assert.equal(workspaces.workspaceFor('bot_queue_fence'), alternateWorkspace); assert.deepEqual(state.snapshot(), {}); assert.equal(state.sets, 0); }); test('a workspace switch cannot interleave between bind persistence and its session write', async (t) => { const { path, defaultWorkspace, alternateWorkspace, thirdWorkspace, } = await fixture(t); const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load(); await workspaces.ensure('bot_serial'); let sessions = { first: 'session-old', second: 'session-other' }; const setStarted = deferred(); const releaseSet = deferred(); const state = { sessionFor(key) { return sessions[key] ?? null; }, async clearSessions() { sessions = {}; }, async setSession(key, sessionId) { setStarted.resolve(); await releaseSet.promise; sessions[key] = sessionId; }, snapshot() { return { ...sessions }; }, }; const scope = createBotWorkspaceScope({ async adoptWorkspaceSession(sessionId) { return { sessionId, workspace: alternateWorkspace }; }, }, { botId: 'bot_serial', workspaces, state }); const binding = scope.harness.bindWorkspaceSession('first', 'session-target'); await setStarted.promise; const switching = scope.harness.switchWorkspace(thirdWorkspace); await new Promise((resolve) => setImmediate(resolve)); assert.equal(workspaces.workspaceFor('bot_serial'), alternateWorkspace); assert.deepEqual(state.snapshot(), {}); releaseSet.resolve(); await binding; await switching; assert.equal(workspaces.workspaceFor('bot_serial'), thirdWorkspace); assert.deepEqual(state.snapshot(), {}); }); test('a later workspace generation cannot be reported as the completed binding', async (t) => { const { path, defaultWorkspace, alternateWorkspace, thirdWorkspace, } = await fixture(t); const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load(); await workspaces.ensure('bot_late_switch'); const state = memoryState({ conversation: 'session-old' }); const bindInQueue = workspaces.bindWorkspaceSession.bind(workspaces); workspaces.bindWorkspaceSession = async (...args) => { const bound = await bindInQueue(...args); await workspaces.setWorkspace('bot_late_switch', thirdWorkspace, { clearSessions: () => state.clearSessions(), }); return bound; }; const scope = createBotWorkspaceScope({ async adoptWorkspaceSession(sessionId) { return { sessionId, workspace: alternateWorkspace }; }, }, { botId: 'bot_late_switch', workspaces, state }); await assert.rejects( scope.harness.bindWorkspaceSession('conversation', 'session-target'), { code: 'workspace-session-stale' }, ); assert.equal(workspaces.workspaceFor('bot_late_switch'), thirdWorkspace); assert.deepEqual(state.snapshot(), {}); }); test('a bind started by an old bot incarnation cannot mutate a same-id replacement', async (t) => { const { path, defaultWorkspace, alternateWorkspace } = await fixture(t); const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load(); await workspaces.ensure('bot_rebound'); const oldState = memoryState({ conversation: 'session-old' }); const adoptionStarted = deferred(); const releaseAdoption = deferred(); const oldScope = createBotWorkspaceScope({ async adoptWorkspaceSession(sessionId) { adoptionStarted.resolve(); await releaseAdoption.promise; return { sessionId, workspace: alternateWorkspace }; }, }, { botId: 'bot_rebound', workspaces, state: oldState }); const staleBinding = oldScope.harness.bindWorkspaceSession('conversation', 'session-target'); await adoptionStarted.promise; const removal = await workspaces.beginRemoval('bot_rebound', { clearSessions: () => oldState.clearSessions(), }); await workspaces.finishRemoval(removal); await workspaces.ensure('bot_rebound', { workspace: defaultWorkspace }); const replacementIncarnation = workspaces.incarnationFor('bot_rebound'); releaseAdoption.resolve(); await assert.rejects(staleBinding, { code: 'workspace-bot-not-found' }); assert.equal(workspaces.incarnationFor('bot_rebound'), replacementIncarnation); assert.equal(workspaces.workspaceFor('bot_rebound'), defaultWorkspace); assert.deepEqual(oldState.snapshot(), {}); assert.equal(oldState.sets, 0); }); test('binding fences a session creation that started in the previous workspace generation', async (t) => { const { path, defaultWorkspace, alternateWorkspace } = await fixture(t); const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load(); await workspaces.ensure('bot_creation'); const creationStarted = deferred(); const releaseCreation = deferred(); const state = memoryState(); const scope = createBotWorkspaceScope({ async createSession() { creationStarted.resolve(); await releaseCreation.promise; return 'session-from-old-workspace'; }, async adoptWorkspaceSession(sessionId) { return { sessionId, workspace: alternateWorkspace }; }, }, { botId: 'bot_creation', workspaces, state }); const oldCreation = scope.harness.createSession(); await creationStarted.promise; await scope.harness.bindWorkspaceSession('bound', 'session-target'); releaseCreation.resolve(); const staleSessionId = await oldCreation; assert.equal(await scope.state.setSession('other', staleSessionId), false); assert.deepEqual(state.snapshot(), { bound: 'session-target' }); }); test('two conversations sharing one Session keep independent generation handles', async (t) => { const { path, defaultWorkspace, alternateWorkspace } = await fixture(t); const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load(); await workspaces.ensure('bot_shared_session'); const state = memoryState({ first: 'session-shared', second: 'session-shared' }); const bothExistenceChecksStarted = deferred(); const releaseExistenceChecks = deferred(); let existenceChecks = 0; let creations = 0; const asked = []; const scope = createBotWorkspaceScope({ async sessionExists(sessionId) { assert.equal(sessionId, 'session-shared'); existenceChecks += 1; if (existenceChecks === 2) bothExistenceChecksStarted.resolve(); await releaseExistenceChecks.promise; return true; }, async createSession({ workspace }) { assert.equal(workspace, alternateWorkspace); creations += 1; return `session-current-${creations}`; }, async ask(sessionId, text) { asked.push({ sessionId, text }); return `answer:${text}`; }, }, { botId: 'bot_shared_session', workspaces, state }); const first = askInWorkspaceSession({ harness: scope.harness, state: scope.state, key: 'first', text: 'first prompt', }); const second = askInWorkspaceSession({ harness: scope.harness, state: scope.state, key: 'second', text: 'second prompt', }); await bothExistenceChecksStarted.promise; await scope.harness.switchWorkspace(alternateWorkspace); releaseExistenceChecks.resolve(); const replies = await Promise.all([first, second]); assert.equal(replies.length, 2); assert.equal(asked.some(({ sessionId }) => sessionId === 'session-shared'), false); assert.deepEqual(new Set(asked.map(({ sessionId }) => sessionId)), new Set([ 'session-current-1', 'session-current-2', ])); assert.equal(workspaces.workspaceFor('bot_shared_session'), alternateWorkspace); }); test('an old handle stays stale after switching away and rebinding the same Session id', async (t) => { const { path, defaultWorkspace, alternateWorkspace } = await fixture(t); const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load(); await workspaces.ensure('bot_rebind_same_id'); const state = memoryState({ first: 'session-shared' }); const existenceStarted = deferred(); const releaseExistence = deferred(); const asked = []; const scope = createBotWorkspaceScope({ async adoptWorkspaceSession(sessionId) { return { sessionId, workspace: defaultWorkspace }; }, async sessionExists(sessionId) { assert.equal(sessionId, 'session-shared'); existenceStarted.resolve(); await releaseExistence.promise; return true; }, async createSession({ workspace }) { assert.equal(workspace, defaultWorkspace); return 'session-after-rebind'; }, async ask(sessionId, text) { asked.push({ sessionId, text }); return 'answer'; }, }, { botId: 'bot_rebind_same_id', workspaces, state }); const oldPrompt = askInWorkspaceSession({ harness: scope.harness, state: scope.state, key: 'first', text: 'old prompt', }); await existenceStarted.promise; await scope.harness.switchWorkspace(alternateWorkspace); await scope.harness.bindWorkspaceSession('second', 'session-shared'); releaseExistence.resolve(); assert.deepEqual(await oldPrompt, { sessionId: 'session-after-rebind', answer: 'answer' }); assert.deepEqual(asked, [{ sessionId: 'session-after-rebind', text: 'old prompt' }]); assert.equal(workspaces.workspaceFor('bot_rebind_same_id'), defaultWorkspace); assert.deepEqual(state.snapshot(), { second: 'session-shared', first: 'session-after-rebind', }); }); test('a Session created before a switch keeps its original generation provenance', async (t) => { const { path, defaultWorkspace, alternateWorkspace } = await fixture(t); const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load(); await workspaces.ensure('bot_create_provenance'); const state = memoryState(); const creationStarted = deferred(); const releaseCreation = deferred(); const createdIn = []; const asked = []; const scope = createBotWorkspaceScope({ async createSession({ workspace }) { createdIn.push(workspace); if (createdIn.length === 1) { creationStarted.resolve(); await releaseCreation.promise; return 'session-created-before-switch'; } return 'session-created-after-switch'; }, async ask(sessionId, text) { asked.push({ sessionId, text }); return 'answer'; }, }, { botId: 'bot_create_provenance', workspaces, state }); const prompting = askInWorkspaceSession({ harness: scope.harness, state: scope.state, key: 'conversation', text: 'prompt', }); await creationStarted.promise; await scope.harness.switchWorkspace(alternateWorkspace); releaseCreation.resolve(); assert.deepEqual(await prompting, { sessionId: 'session-created-after-switch', answer: 'answer', }); assert.deepEqual(createdIn, [defaultWorkspace, alternateWorkspace]); assert.deepEqual(asked, [{ sessionId: 'session-created-after-switch', text: 'prompt' }]); assert.deepEqual(state.snapshot(), { conversation: 'session-created-after-switch' }); }); test('workspace persistence failure leaves old mappings cleared and never writes the target session', async (t) => { const { root, defaultWorkspace, alternateWorkspace } = await fixture(t); const storeDirectory = join(root, 'store'); const storePath = join(storeDirectory, 'workspaces.json'); await mkdir(storeDirectory); const workspaces = await new BotWorkspaceStore(storePath, { defaultWorkspace }).load(); await workspaces.ensure('bot_persist'); const generation = workspaces.generationFor('bot_persist'); const state = memoryState({ first: 'session-old', second: 'session-other' }); const scope = createBotWorkspaceScope({ async adoptWorkspaceSession(sessionId) { return { sessionId, workspace: alternateWorkspace }; }, }, { botId: 'bot_persist', workspaces, state }); const savedDirectory = `${storeDirectory}-saved`; await rename(storeDirectory, savedDirectory); await writeFile(storeDirectory, 'blocks persistence'); await assert.rejects(scope.harness.bindWorkspaceSession('first', 'session-target')); assert.equal(workspaces.workspaceFor('bot_persist'), defaultWorkspace); assert.notEqual(workspaces.generationFor('bot_persist'), generation); assert.deepEqual(state.snapshot(), {}); assert.equal(state.sets, 0); await rm(storeDirectory, { force: true }); await rename(savedDirectory, storeDirectory); }); test('session persistence failure keeps the new workspace and does not restore old mappings', async (t) => { const { path, defaultWorkspace, alternateWorkspace } = await fixture(t); const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load(); await workspaces.ensure('bot_state_failure'); let sessions = { first: 'session-old', second: 'session-other' }; const state = { sessionFor(key) { return sessions[key] ?? null; }, async clearSessions() { sessions = {}; }, async setSession() { throw new Error('state persistence failed'); }, snapshot() { return { ...sessions }; }, }; const scope = createBotWorkspaceScope({ async adoptWorkspaceSession(sessionId) { return { sessionId, workspace: alternateWorkspace }; }, }, { botId: 'bot_state_failure', workspaces, state }); await assert.rejects( scope.harness.bindWorkspaceSession('first', 'session-target'), /state persistence failed/, ); assert.equal(workspaces.workspaceFor('bot_state_failure'), alternateWorkspace); assert.deepEqual(state.snapshot(), {}); assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), { version: 1, workspaces: { bot_state_failure: alternateWorkspace }, }); });