feat: support per-bot workspace switching

This commit is contained in:
xmanrui 2026-08-17 23:56:27 +08:00
parent adcace3512
commit 44dd50216a
64 changed files with 3878 additions and 712 deletions

View file

@ -22,6 +22,7 @@ test('client exposes the fixed DingTalk RPC channel and endpoint names', () => {
bindCredentials: 'bot.bind-credentials',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: 'bot.workspace.set',
});
});

View file

@ -0,0 +1,318 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as React from 'react';
import TestRenderer from 'react-test-renderer';
import { WorkspaceEditor } from '../plugin-src/client/workspace-editor.js';
import { DiscordSettingsTab } from '../plugin-src/client/channels/discord/index.js';
const { act, create } = TestRenderer;
function submitEvent() {
return { preventDefault() {} };
}
function deferred() {
let resolve;
const promise = new Promise((onResolve) => { resolve = onResolve; });
return { promise, resolve };
}
async function flushMicrotasks() {
for (let index = 0; index < 6; index += 1) await Promise.resolve();
}
function discordSnapshot(workspace) {
return {
revision: 1,
bots: [{
botId: 'discord_test',
connected: true,
state: 'connected',
workspace,
bot: { name: 'Harness Bot', username: 'HarnessBot', idMasked: '123•••' },
health: { summary: 'Discord Gateway 长连接运行正常', lastCheckedAt: Date.now() },
error: null,
}],
};
}
function twoBotDiscordSnapshot(firstWorkspace) {
const first = discordSnapshot(firstWorkspace).bots[0];
return {
revision: 1,
bots: [
{ ...first, botId: 'discord_first', bot: { ...first.bot, name: 'First Bot' } },
{
...first,
botId: 'discord_second',
workspace: '/workspace/second',
bot: { ...first.bot, name: 'Second Bot' },
},
],
};
}
test('WorkspaceEditor shows the current path and saves an edited absolute path', async () => {
const saved = [];
function Fixture() {
const [workspace, setWorkspace] = React.useState('/workspace/current');
return React.createElement(WorkspaceEditor, {
workspace,
async onSave(value) {
saved.push(value);
setWorkspace(value);
},
});
}
let renderer;
await act(async () => { renderer = create(React.createElement(Fixture)); });
assert.equal(renderer.root.findByType('code').props.title, '/workspace/current');
await act(async () => {
renderer.root.findByType('button').props.onClick();
});
const input = renderer.root.findByType('input');
assert.equal(input.props.value, '/workspace/current');
assert.equal(input.props.maxLength, 4_096);
await act(async () => {
input.props.onChange({ target: { value: ' /workspace/next project ' } });
});
await act(async () => {
await renderer.root.findByType('form').props.onSubmit(submitEvent());
});
assert.deepEqual(saved, ['/workspace/next project']);
assert.equal(renderer.root.findByType('code').props.title, '/workspace/next project');
});
test('WorkspaceEditor keeps editing and presents a rejected workspace error', async () => {
let renderer;
await act(async () => {
renderer = create(React.createElement(WorkspaceEditor, {
workspace: '/workspace/current',
async onSave() {
const error = new Error('工作区路径不存在。');
error.code = 'workspace-not-found';
throw error;
},
}));
});
await act(async () => { renderer.root.findByType('button').props.onClick(); });
await act(async () => {
renderer.root.findByType('input').props.onChange({ target: { value: '/workspace/missing' } });
});
await act(async () => {
await renderer.root.findByType('form').props.onSubmit(submitEvent());
});
assert.equal(renderer.root.findByProps({ role: 'alert' }).children.join(''), '工作区路径不存在。');
assert.equal(renderer.root.findByType('input').props.value, '/workspace/missing');
});
test('WorkspaceEditor rejects a relative path before calling the Host', async () => {
let saves = 0;
let renderer;
await act(async () => {
renderer = create(React.createElement(WorkspaceEditor, {
workspace: '/workspace/current',
async onSave() { saves += 1; },
}));
});
await act(async () => { renderer.root.findByType('button').props.onClick(); });
await act(async () => {
renderer.root.findByType('input').props.onChange({ target: { value: 'relative/path' } });
});
await act(async () => {
await renderer.root.findByType('form').props.onSubmit(submitEvent());
});
assert.equal(saves, 0);
assert.equal(renderer.root.findByProps({ role: 'alert' }).children.join(''), '工作区必须是绝对路径。');
});
test('WorkspaceEditor moves keyboard focus into and back out of edit mode', async () => {
let inputFocus = 0;
let editFocus = 0;
let renderer;
await act(async () => {
renderer = create(React.createElement(WorkspaceEditor, {
workspace: '/workspace/current',
async onSave() {},
}), {
createNodeMock(element) {
if (element.type === 'input') return { focus() { inputFocus += 1; } };
if (element.props?.className === 'dim-workspaceEdit') {
return { focus() { editFocus += 1; } };
}
return {};
},
});
});
await act(async () => { renderer.root.findByType('button').props.onClick(); });
assert.equal(inputFocus, 1);
const cancel = renderer.root.findAllByType('button')
.find((button) => button.children.join('') === '取消');
await act(async () => { cancel.props.onClick(); });
assert.equal(editFocus, 1);
});
test('a status response started before saving cannot restore the old workspace', async (t) => {
const previousWindow = globalThis.window;
let intervalCallback;
globalThis.window = {
setInterval(callback) { intervalCallback = callback; return 1; },
clearInterval() {},
};
t.after(() => {
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
});
const staleStatus = deferred();
let statusCalls = 0;
const rpcCall = async (endpoint) => {
if (endpoint === 'connection.status') {
statusCalls += 1;
if (statusCalls === 1) return { ok: true, value: discordSnapshot('/workspace/current') };
if (statusCalls === 2) return staleStatus.promise;
return { ok: true, value: discordSnapshot('/workspace/new') };
}
if (endpoint === 'bot.workspace.set') {
return { ok: true, value: discordSnapshot('/workspace/new') };
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
};
let renderer;
await act(async () => {
renderer = create(React.createElement(DiscordSettingsTab, { rpcCall }));
await flushMicrotasks();
});
await act(async () => {
intervalCallback();
await flushMicrotasks();
});
await act(async () => {
renderer.root.findAllByType('button')
.find((button) => button.children.join('') === '修改').props.onClick();
});
await act(async () => {
renderer.root.findByType('input').props.onChange({ target: { value: '/workspace/new' } });
await renderer.root.findByType('form').props.onSubmit(submitEvent());
await flushMicrotasks();
});
assert.equal(renderer.root.findByType('code').props.title, '/workspace/new');
await act(async () => {
staleStatus.resolve({ ok: true, value: discordSnapshot('/workspace/old') });
await flushMicrotasks();
});
assert.equal(renderer.root.findByType('code').props.title, '/workspace/new');
await act(async () => { renderer.unmount(); });
});
test('an older reconnect snapshot from another bot cannot restore a saved workspace', async (t) => {
const previousWindow = globalThis.window;
globalThis.window = { setInterval() { return 1; }, clearInterval() {} };
t.after(() => {
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
});
const staleReconnect = deferred();
let statusCalls = 0;
const rpcCall = async (endpoint) => {
if (endpoint === 'connection.status') {
statusCalls += 1;
return {
ok: true,
value: twoBotDiscordSnapshot(statusCalls === 1 ? '/workspace/current' : '/workspace/new'),
};
}
if (endpoint === 'bot.reconnect') return staleReconnect.promise;
if (endpoint === 'bot.workspace.set') {
return { ok: true, value: twoBotDiscordSnapshot('/workspace/new') };
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
};
let renderer;
await act(async () => {
renderer = create(React.createElement(DiscordSettingsTab, { rpcCall }));
await flushMicrotasks();
});
const firstCard = renderer.root.findByProps({ 'data-bot-id': 'discord_first' });
const secondCard = renderer.root.findByProps({ 'data-bot-id': 'discord_second' });
await act(async () => {
secondCard.findAllByType('button')
.find((button) => button.children.join('') === '检查连接').props.onClick();
await flushMicrotasks();
});
await act(async () => {
firstCard.findAllByType('button')
.find((button) => button.children.join('') === '修改').props.onClick();
});
await act(async () => {
firstCard.findByType('input').props.onChange({ target: { value: '/workspace/new' } });
await firstCard.findByType('form').props.onSubmit(submitEvent());
await flushMicrotasks();
});
staleReconnect.resolve({ ok: true, value: twoBotDiscordSnapshot('/workspace/old') });
await act(async () => { await flushMicrotasks(); });
assert.equal(
renderer.root.findByProps({ 'data-bot-id': 'discord_first' }).findByType('code').props.title,
'/workspace/new',
);
await act(async () => { renderer.unmount(); });
});
test('an older reconnect snapshot cannot resurrect a bot deleted by a newer mutation', async (t) => {
const previousWindow = globalThis.window;
globalThis.window = { setInterval() { return 1; }, clearInterval() {} };
t.after(() => {
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
});
const staleReconnect = deferred();
const initialSnapshot = twoBotDiscordSnapshot('/workspace/first');
const deletedSnapshot = { ...initialSnapshot, bots: initialSnapshot.bots.slice(1) };
let statusCalls = 0;
const rpcCall = async (endpoint) => {
if (endpoint === 'connection.status') {
statusCalls += 1;
return { ok: true, value: statusCalls === 1 ? initialSnapshot : deletedSnapshot };
}
if (endpoint === 'bot.reconnect') return staleReconnect.promise;
if (endpoint === 'bot.delete') return { ok: true, value: deletedSnapshot };
throw new Error(`Unexpected endpoint: ${endpoint}`);
};
let renderer;
await act(async () => {
renderer = create(React.createElement(DiscordSettingsTab, { rpcCall }));
await flushMicrotasks();
});
const firstCard = renderer.root.findByProps({ 'data-bot-id': 'discord_first' });
const secondCard = renderer.root.findByProps({ 'data-bot-id': 'discord_second' });
await act(async () => {
secondCard.findAllByType('button')
.find((button) => button.children.join('') === '检查连接').props.onClick();
await flushMicrotasks();
});
await act(async () => {
firstCard.findAllByType('button')
.find((button) => button.children.join('') === '移除接入').props.onClick();
});
await act(async () => {
await firstCard.findAllByType('button')
.find((button) => button.children.join('') === '确认移除接入').props.onClick();
await flushMicrotasks();
});
assert.equal(renderer.root.findAllByProps({ 'data-bot-id': 'discord_first' }).length, 0);
staleReconnect.resolve({ ok: true, value: initialSnapshot });
await act(async () => { await flushMicrotasks(); });
assert.equal(renderer.root.findAllByProps({ 'data-bot-id': 'discord_first' }).length, 0);
await act(async () => { renderer.unmount(); });
});

826
test/workspace.test.mjs Normal file
View file

@ -0,0 +1,826 @@
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import {
BotWorkspaceStore,
createBotScopedHarness,
createBotWorkspaceScope,
createWorkspaceAwareController,
observeBotWorkspaceRemovals,
validateWorkspacePath,
} from '../src/channels/shared/bot-workspace-store.mjs';
import { runWorkspaceCommand } from '../src/channels/shared/workspace-command.mjs';
import { askInWorkspaceSession } from '../src/channels/shared/workspace-session.mjs';
import { HarnessClient as WeixinHarnessClient } from '../src/channels/weixin/harness-client.mjs';
import { HarnessClient as FeishuHarnessClient } from '../src/channels/feishu/harness-client.mjs';
import { HarnessClient as DingtalkHarnessClient } from '../src/channels/dingtalk/harness-client.mjs';
import { ConversationStateStore } from '../src/channels/shared/conversation-state-store.mjs';
import { WeixinStateStore } from '../src/channels/weixin/state-store.mjs';
import { StateStore as FeishuStateStore } from '../src/channels/feishu/state-store.mjs';
import { DingtalkStateStore } from '../src/channels/dingtalk/state-store.mjs';
import { WecomStateStore } from '../src/channels/wecom/state-store.mjs';
import { QqStateStore } from '../src/channels/qq/state-store.mjs';
import {
TOKEN_BOT_ENDPOINTS,
createTokenBotRpcHandler,
} from '../plugin-src/host/channels/shared/rpc.mjs';
async function fixture(t) {
const root = await mkdtemp(join(tmpdir(), 'dsh-im-workspace-'));
t.after(() => rm(root, { recursive: true, force: true }));
const defaultWorkspace = join(root, 'default');
const alternateWorkspace = join(root, 'alternate workspace');
await Promise.all([
mkdir(defaultWorkspace),
mkdir(alternateWorkspace),
]);
return { root, defaultWorkspace, alternateWorkspace, path: join(root, 'workspaces.json') };
}
test('BotWorkspaceStore persists the creation default and keeps bots isolated', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
assert.equal(await store.ensure('bot_one'), defaultWorkspace);
assert.equal(await store.ensure('bot_two'), defaultWorkspace);
await store.setWorkspace('bot_one', alternateWorkspace);
assert.equal(store.workspaceFor('bot_one'), alternateWorkspace);
assert.equal(store.workspaceFor('bot_two'), defaultWorkspace);
assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), {
version: 1,
workspaces: { bot_one: alternateWorkspace, bot_two: defaultWorkspace },
});
const reloaded = await new BotWorkspaceStore(path, { defaultWorkspace: tmpdir() }).load();
assert.equal(reloaded.workspaceFor('bot_one'), alternateWorkspace);
assert.equal(reloaded.workspaceFor('bot_two'), defaultWorkspace);
});
test('BotWorkspaceStore uses process.cwd() when a bot has no configured workspace', async (t) => {
const { root } = await fixture(t);
const store = await new BotWorkspaceStore(join(root, 'cwd-workspaces.json')).load();
assert.equal(await store.ensure('bot_cwd'), process.cwd());
});
test('workspace writes roll back updates while committed removals stay retired in memory', async (t) => {
const { root, defaultWorkspace, alternateWorkspace } = await fixture(t);
const storeDirectory = join(root, 'workspace-store');
const storePath = join(storeDirectory, 'workspaces.json');
await mkdir(storeDirectory);
const store = await new BotWorkspaceStore(storePath, { defaultWorkspace }).load();
await store.ensure('bot_io');
await store.setWorkspace('bot_io', alternateWorkspace);
await rename(storeDirectory, `${storeDirectory}-saved`);
await writeFile(storeDirectory, 'blocks workspace persistence');
let clears = 0;
await assert.rejects(store.setWorkspace('bot_io', defaultWorkspace, {
clearSessions: async () => { clears += 1; },
}));
assert.equal(clears, 1);
assert.equal(store.workspaceFor('bot_io'), alternateWorkspace);
await assert.rejects(store.remove('bot_io'));
assert.equal(store.has('bot_io'), false);
assert.equal(store.workspaceFor('bot_io'), defaultWorkspace);
await rm(storeDirectory, { force: true });
await rename(`${storeDirectory}-saved`, storeDirectory);
const staleDisk = await new BotWorkspaceStore(storePath, { defaultWorkspace }).load();
assert.equal(staleDisk.workspaceFor('bot_io'), alternateWorkspace);
await staleDisk.reconcile([]);
assert.equal(staleDisk.has('bot_io'), false);
const blockedParent = join(root, 'blocked-parent');
await writeFile(blockedParent, 'not a directory');
const broken = new BotWorkspaceStore(join(blockedParent, 'workspaces.json'), { defaultWorkspace });
await assert.rejects(broken.ensure('bot_new'));
assert.equal(broken.workspaceFor('bot_new'), defaultWorkspace);
});
test('workspace validation rejects relative, missing, and file paths', async (t) => {
const { root } = await fixture(t);
const file = join(root, 'file.txt');
await writeFile(file, 'not a directory');
await assert.rejects(validateWorkspacePath('relative/path'), { code: 'workspace-not-absolute' });
await assert.rejects(validateWorkspacePath(join(root, 'missing')), { code: 'workspace-not-found' });
await assert.rejects(validateWorkspacePath(file), { code: 'workspace-not-directory' });
});
test('bot-scoped Harness creates sessions in each bot workspace and switching clears sessions', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await Promise.all([workspaces.ensure('bot_one'), workspaces.ensure('bot_two')]);
const calls = [];
const harness = {
async createSession(options) { calls.push(options); return `session-${calls.length}`; },
async ensureRunning() { return true; },
};
let cleared = 0;
const state = { async clearSessions() { cleared += 1; } };
const one = createBotScopedHarness(harness, { botId: 'bot_one', workspaces, state });
const two = createBotScopedHarness(harness, { botId: 'bot_two', workspaces, state });
await one.createSession();
await one.switchWorkspace(alternateWorkspace);
await Promise.all([one.createSession(), two.createSession()]);
assert.equal(cleared, 1);
assert.deepEqual(calls.map((call) => call.workspace), [
defaultWorkspace,
alternateWorkspace,
defaultWorkspace,
]);
});
test('an old session cannot be written back while RPC switches the bot workspace', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_race');
let finishCreation;
let existenceChecks = 0;
const harness = {
createSession() {
return new Promise((resolveCreation) => { finishCreation = resolveCreation; });
},
async sessionExists() {
existenceChecks += 1;
return true;
},
};
let persistedSession = null;
const state = {
async clearSessions() { persistedSession = null; },
async setSession(_key, sessionId) { persistedSession = sessionId; },
};
const scope = createBotWorkspaceScope(harness, { botId: 'bot_race', workspaces, state });
const controller = createWorkspaceAwareController({
status() { return { bots: [{ botId: 'bot_race' }] }; },
}, {
workspaces,
stateFor: async () => state,
});
const oldSession = scope.harness.createSession();
await controller.updateWorkspace('bot_race', alternateWorkspace);
finishCreation('session-from-old-workspace');
const sessionId = await oldSession;
assert.equal(await scope.state.setSession('conversation', sessionId), false);
assert.equal(persistedSession, null);
const oldSessionForLookup = scope.harness.createSession();
await controller.updateWorkspace('bot_race', defaultWorkspace);
finishCreation('second-session-from-old-workspace');
assert.equal(await scope.harness.sessionExists(await oldSessionForLookup), false);
assert.equal(existenceChecks, 0, 'stale sessions are rejected before asking Harness');
});
test('a prompt retries in the new workspace when switching after session creation', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_prompt');
const createdIn = [];
const asks = [];
let sessionNumber = 0;
let markFirstSet;
let releaseFirstSet;
const firstSet = new Promise((resolveSet) => { markFirstSet = resolveSet; });
const firstSetGate = new Promise((resolveSet) => { releaseFirstSet = resolveSet; });
const harness = {
async createSession({ workspace }) {
createdIn.push(workspace);
sessionNumber += 1;
return `session-${sessionNumber}`;
},
async sessionExists() { return true; },
async ask(sessionId) { asks.push(sessionId); return `answer-${sessionId}`; },
};
let persistedSession = null;
const state = {
sessionFor() { return persistedSession; },
async setSession(_key, sessionId) {
persistedSession = sessionId;
if (sessionId === 'session-1') {
markFirstSet();
await firstSetGate;
}
},
async clearSessions() { persistedSession = null; },
};
const scope = createBotWorkspaceScope(harness, { botId: 'bot_prompt', workspaces, state });
const controller = createWorkspaceAwareController({
status() { return { bots: [{ botId: 'bot_prompt' }] }; },
}, { workspaces, stateFor: async () => state });
const prompting = askInWorkspaceSession({
harness: scope.harness,
state: scope.state,
key: 'conversation',
text: 'hello',
});
await firstSet;
await controller.updateWorkspace('bot_prompt', alternateWorkspace);
releaseFirstSet();
const result = await prompting;
assert.equal(result.answer, 'answer-session-2');
assert.deepEqual(createdIn, [defaultWorkspace, alternateWorkspace]);
assert.deepEqual(asks, ['session-2']);
assert.equal(persistedSession, 'session-2');
});
test('a workspace switch lets an already-started reply finish and moves the next message', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_started_prompt');
const createdIn = [];
const asks = [];
let sessionNumber = 0;
let markFirstAskStarted;
let releaseFirstAsk;
const firstAskStarted = new Promise((resolveStarted) => { markFirstAskStarted = resolveStarted; });
const firstAskGate = new Promise((resolveAsk) => { releaseFirstAsk = resolveAsk; });
const harness = {
async createSession({ workspace }) {
createdIn.push(workspace);
sessionNumber += 1;
return `session-${sessionNumber}`;
},
async sessionExists() { return true; },
async ask(sessionId, text) {
asks.push({ sessionId, text });
if (asks.length === 1) {
markFirstAskStarted();
await firstAskGate;
}
return `answer-${sessionId}`;
},
};
let persistedSession = null;
const state = {
sessionFor() { return persistedSession; },
async setSession(_key, sessionId) { persistedSession = sessionId; },
async clearSessions() { persistedSession = null; },
};
const scope = createBotWorkspaceScope(harness, {
botId: 'bot_started_prompt', workspaces, state,
});
const controller = createWorkspaceAwareController({
status() { return { bots: [{ botId: 'bot_started_prompt' }] }; },
}, { workspaces, stateFor: async () => state });
const first = askInWorkspaceSession({
harness: scope.harness,
state: scope.state,
key: 'conversation',
text: 'first',
});
await firstAskStarted;
await controller.updateWorkspace('bot_started_prompt', alternateWorkspace);
assert.equal(workspaces.workspaceFor('bot_started_prompt'), alternateWorkspace);
releaseFirstAsk();
assert.deepEqual(await first, {
sessionId: 'session-1',
answer: 'answer-session-1',
});
assert.deepEqual(await askInWorkspaceSession({
harness: scope.harness,
state: scope.state,
key: 'conversation',
text: 'second',
}), {
sessionId: 'session-2',
answer: 'answer-session-2',
});
assert.deepEqual(createdIn, [defaultWorkspace, alternateWorkspace]);
assert.deepEqual(asks, [
{ sessionId: 'session-1', text: 'first' },
{ sessionId: 'session-2', text: 'second' },
]);
});
test('deleting and rebinding a bot cannot accept an old in-flight session', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_rebind');
let finishCreation;
const harness = {
createSession() {
return new Promise((resolveCreation) => { finishCreation = resolveCreation; });
},
};
let persistedSession = null;
const state = {
async clearSessions() { persistedSession = null; },
async setSession(_key, sessionId) { persistedSession = sessionId; },
};
const oldScope = createBotWorkspaceScope(harness, { botId: 'bot_rebind', workspaces, state });
let bots = [{ botId: 'bot_rebind' }];
const controller = createWorkspaceAwareController({
status() { return { bots }; },
async deleteBot() { bots = []; return { bots }; },
}, { workspaces, stateFor: async () => state });
const oldSession = oldScope.harness.createSession();
await controller.deleteBot('bot_rebind');
await workspaces.ensure('bot_rebind');
finishCreation('session-before-delete');
assert.equal(await oldScope.state.setSession('conversation', await oldSession), false);
assert.equal(persistedSession, null);
await assert.rejects(oldScope.harness.switchWorkspace(alternateWorkspace), {
code: 'workspace-bot-not-found',
});
const reboundScope = createBotWorkspaceScope(harness, {
botId: 'bot_rebind', workspaces, state,
});
assert.equal(await reboundScope.harness.switchWorkspace(alternateWorkspace), alternateWorkspace);
});
test('a successful public delete clears sessions before a same-id rebind', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_reused_id');
let persistedSession = 'old-session';
const state = {
sessionFor() { return persistedSession; },
async setSession(_key, sessionId) { persistedSession = sessionId; },
async clearSessions() { persistedSession = null; },
};
let bots = [{ botId: 'bot_reused_id' }];
const controller = createWorkspaceAwareController({
status() { return { bots }; },
async deleteBot() {
assert.equal(persistedSession, null, 'session cleanup precedes the config deletion');
bots = [];
return { bots };
},
}, { workspaces, stateFor: async () => state });
await controller.deleteBot('bot_reused_id');
await workspaces.ensure('bot_reused_id');
const asks = [];
const scope = createBotWorkspaceScope({
async createSession() { return 'new-session'; },
async sessionExists() { return true; },
async ask(sessionId) { asks.push(sessionId); return 'new-answer'; },
}, { botId: 'bot_reused_id', workspaces, state });
assert.deepEqual(await askInWorkspaceSession({
harness: scope.harness,
state: scope.state,
key: 'conversation',
text: 'after rebind',
}), { sessionId: 'new-session', answer: 'new-answer' });
assert.deepEqual(asks, ['new-session']);
assert.equal(persistedSession, 'new-session');
});
test('session cleanup load or clear failures do not block public deletion', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
const warnings = [];
t.mock.method(console, 'warn', (...args) => { warnings.push(args); });
for (const failure of ['load', 'clear']) {
const botId = `bot_cleanup_${failure}`;
await workspaces.ensure(botId);
let bots = [{ botId }];
let deletions = 0;
const controller = createWorkspaceAwareController({
status() { return { bots }; },
async deleteBot() {
deletions += 1;
bots = [];
return { bots };
},
}, {
workspaces,
stateFor: async () => {
if (failure === 'load') throw new Error('state load failed');
return { async clearSessions() { throw new Error('session clear failed'); } };
},
});
await controller.deleteBot(botId);
assert.equal(deletions, 1);
assert.equal(workspaces.has(botId), false);
}
assert.equal(warnings.length, 2);
assert.ok(warnings.every(([message]) => message.includes('ignored session cleanup failure')));
});
test('an old deletion transaction cannot retire a same-id rebound bot', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_lifecycle');
await workspaces.setWorkspace('bot_lifecycle', alternateWorkspace);
const firstIncarnation = workspaces.incarnationFor('bot_lifecycle');
let bots = [{ botId: 'bot_lifecycle', lifecycle: 'old' }];
const observedStore = observeBotWorkspaceRemovals({
async remove(botId) { return { botId }; },
}, { workspaces });
const controller = createWorkspaceAwareController({
status() { return { bots }; },
async deleteBot(botId) {
// The config commit retires the old lifecycle before the outer adapter
// resumes. Simulate a queued same-account provisioning completing in
// that gap and creating a new incarnation with the deterministic id.
await observedStore.remove(botId);
await workspaces.ensure(botId, { workspace: defaultWorkspace });
bots = [{ botId, lifecycle: 'rebound' }];
return { bots };
},
}, { workspaces, stateFor: async () => ({ async clearSessions() {} }) });
const result = await controller.deleteBot('bot_lifecycle');
assert.equal(workspaces.has('bot_lifecycle'), true);
assert.equal(workspaces.workspaceFor('bot_lifecycle'), defaultWorkspace);
assert.notEqual(workspaces.incarnationFor('bot_lifecycle'), firstIncarnation);
assert.equal(result.bots[0].workspace, defaultWorkspace);
const staleRemoval = await workspaces.beginRemoval('bot_lifecycle');
await workspaces.retireAfterConfigCommit('bot_lifecycle');
await workspaces.ensure('bot_lifecycle', { workspace: alternateWorkspace });
const latestIncarnation = workspaces.incarnationFor('bot_lifecycle');
assert.equal(await workspaces.abortRemoval(staleRemoval), false);
assert.equal((await workspaces.finishRemoval(staleRemoval)).stale, true);
assert.equal(workspaces.has('bot_lifecycle'), true);
assert.equal(workspaces.workspaceFor('bot_lifecycle'), alternateWorkspace);
assert.equal(workspaces.incarnationFor('bot_lifecycle'), latestIncarnation);
});
test('a workspace update for an old incarnation cannot mutate a same-id rebound bot', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_update_aba');
let markStateRequested;
let releaseState;
const stateRequested = new Promise((resolveRequested) => { markStateRequested = resolveRequested; });
const stateGate = new Promise((resolveState) => { releaseState = resolveState; });
let clears = 0;
const controller = createWorkspaceAwareController({
status() { return { bots: [{ botId: 'bot_update_aba' }] }; },
}, {
workspaces,
stateFor: async () => {
markStateRequested();
await stateGate;
return { async clearSessions() { clears += 1; } };
},
});
const updating = controller.updateWorkspace('bot_update_aba', alternateWorkspace);
await stateRequested;
await workspaces.retireAfterConfigCommit('bot_update_aba');
await workspaces.ensure('bot_update_aba', { workspace: defaultWorkspace });
releaseState();
await assert.rejects(updating, { code: 'workspace-bot-not-found' });
assert.equal(clears, 0);
assert.equal(workspaces.has('bot_update_aba'), true);
assert.equal(workspaces.workspaceFor('bot_update_aba'), defaultWorkspace);
});
test('a blocked workspace switch for one bot does not block another bot session', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await Promise.all([workspaces.ensure('bot_slow'), workspaces.ensure('bot_ready')]);
let markClearStarted;
let releaseClear;
const clearStarted = new Promise((resolveClear) => { markClearStarted = resolveClear; });
const clearGate = new Promise((resolveClear) => { releaseClear = resolveClear; });
const stateSlow = {
async clearSessions() { markClearStarted(); await clearGate; },
};
const stateReady = { async clearSessions() {} };
const created = [];
const harness = {
async createSession({ workspace }) { created.push(workspace); return 'ready-session'; },
};
const slow = createBotScopedHarness(harness, {
botId: 'bot_slow', workspaces, state: stateSlow,
});
const ready = createBotScopedHarness(harness, {
botId: 'bot_ready', workspaces, state: stateReady,
});
const switching = slow.switchWorkspace(alternateWorkspace);
await clearStarted;
assert.equal(await ready.createSession(), 'ready-session');
assert.deepEqual(created, [defaultWorkspace]);
releaseClear();
await switching;
});
test('clearing workspace sessions preserves message deduplication and channel cursors', async (t) => {
const { root } = await fixture(t);
const stores = [
['shared', await new ConversationStateStore(join(root, 'shared-state.json')).load()],
['weixin', await new WeixinStateStore(join(root, 'weixin-state.json')).load()],
['feishu', await new FeishuStateStore(join(root, 'feishu-state.json')).load()],
['dingtalk', await new DingtalkStateStore(join(root, 'dingtalk-state.json'), {
idFactory: () => 'request',
now: () => '2026-08-17T00:00:00.000Z',
}).load()],
['wecom', await new WecomStateStore(join(root, 'wecom-state.json')).load()],
['qq', await new QqStateStore(join(root, 'qq-state.json')).load()],
];
for (const [name, store] of stores) {
await store.setSession('conversation', `session-${name}`);
await store.markSeen(`message-${name}`);
}
await stores[0][1].setCursor(42);
await stores[1][1].setGetUpdatesBuf('next-weixin-cursor');
await stores[3][1].recordPendingSender('staff-one', 'User One');
for (const [name, store] of stores) {
await store.clearSessions();
assert.equal(store.sessionFor('conversation'), null, `${name} clears its Harness session`);
assert.equal(store.hasSeen(`message-${name}`), true, `${name} keeps message deduplication`);
}
assert.equal(stores[0][1].cursor(), 42);
assert.equal(stores[1][1].getUpdatesBuf(), 'next-weixin-cursor');
assert.equal(stores[3][1].pendingSenders().length, 1);
});
test('workspace-aware controller decorates status and updates one bot', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await Promise.all([workspaces.ensure('bot_one'), workspaces.ensure('bot_two')]);
const cleared = [];
const controller = createWorkspaceAwareController({
status() { return { bots: [{ botId: 'bot_one' }, { botId: 'bot_two' }] }; },
async deleteBot(botId) { return { bots: [{ botId: botId === 'bot_one' ? 'bot_two' : 'bot_one' }] }; },
}, {
workspaces,
stateFor: async (botId) => ({ async clearSessions() { cleared.push(botId); } }),
});
const updated = await controller.updateWorkspace('bot_one', alternateWorkspace);
assert.equal(updated.bots[0].workspace, alternateWorkspace);
assert.equal(updated.bots[1].workspace, defaultWorkspace);
assert.deepEqual(cleared, ['bot_one']);
await assert.rejects(controller.updateWorkspace('missing_bot', alternateWorkspace), {
code: 'workspace-bot-not-found',
});
});
test('workspace updates serialize with deletion and cannot recreate a removed bot mapping', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_delete');
await workspaces.setWorkspace('bot_delete', alternateWorkspace);
let bots = [{ botId: 'bot_delete' }];
let releaseDelete;
let markDeleteStarted;
const deleteStarted = new Promise((resolveStarted) => { markDeleteStarted = resolveStarted; });
const deleteGate = new Promise((resolveDelete) => { releaseDelete = resolveDelete; });
let clears = 0;
const controller = createWorkspaceAwareController({
status() { return { bots }; },
async deleteBot() {
markDeleteStarted();
await deleteGate;
bots = [];
return { bots };
},
}, {
workspaces,
stateFor: async () => ({ async clearSessions() { clears += 1; } }),
});
const deleting = controller.deleteBot('bot_delete');
await deleteStarted;
const lateUpdate = controller.updateWorkspace('bot_delete', defaultWorkspace);
releaseDelete();
await deleting;
await assert.rejects(lateUpdate, { code: 'workspace-bot-not-found' });
assert.equal(workspaces.workspaceFor('bot_delete'), defaultWorkspace);
assert.equal(clears, 1);
});
test('workspace deletion keeps the durable path until the bot config commits', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_durable');
await workspaces.setWorkspace('bot_durable', alternateWorkspace);
let bots = [{ botId: 'bot_durable' }];
let markDeleteStarted;
let releaseDelete;
const deleteStarted = new Promise((resolveDelete) => { markDeleteStarted = resolveDelete; });
const deleteGate = new Promise((resolveDelete) => { releaseDelete = resolveDelete; });
const controller = createWorkspaceAwareController({
status() { return { bots }; },
async deleteBot() {
markDeleteStarted();
await deleteGate;
bots = [];
return { bots };
},
}, { workspaces, stateFor: async () => ({ async clearSessions() {} }) });
const deleting = controller.deleteBot('bot_durable');
await deleteStarted;
assert.equal(JSON.parse(await readFile(path, 'utf8')).workspaces.bot_durable, alternateWorkspace);
releaseDelete();
await deleting;
assert.equal(workspaces.has('bot_durable'), false);
});
test('a failed bot deletion aborts the fence without rewriting its workspace', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_abort');
await workspaces.setWorkspace('bot_abort', alternateWorkspace);
const state = { async clearSessions() {} };
const scope = createBotWorkspaceScope({ async createSession() {} }, {
botId: 'bot_abort', workspaces, state,
});
const controller = createWorkspaceAwareController({
status() { return { bots: [{ botId: 'bot_abort' }] }; },
async deleteBot() { throw new Error('config removal failed'); },
}, { workspaces, stateFor: async () => state });
await assert.rejects(controller.deleteBot('bot_abort'), /config removal failed/);
assert.equal(workspaces.has('bot_abort'), true);
assert.equal(workspaces.workspaceFor('bot_abort'), alternateWorkspace);
assert.equal(await scope.harness.switchWorkspace(defaultWorkspace), defaultWorkspace);
});
test('a committed bot deletion stays retired when workspace cleanup persistence fails', async (t) => {
const { root, defaultWorkspace, alternateWorkspace } = await fixture(t);
const storeDirectory = join(root, 'delete-store');
const storePath = join(storeDirectory, 'workspaces.json');
await mkdir(storeDirectory);
const workspaces = await new BotWorkspaceStore(storePath, { defaultWorkspace }).load();
await workspaces.ensure('bot_commit');
await workspaces.setWorkspace('bot_commit', alternateWorkspace);
let bots = [{ botId: 'bot_commit' }];
let markDeleteStarted;
let releaseDelete;
const deleteStarted = new Promise((resolveDelete) => { markDeleteStarted = resolveDelete; });
const deleteGate = new Promise((resolveDelete) => { releaseDelete = resolveDelete; });
const controller = createWorkspaceAwareController({
status() { return { bots }; },
async deleteBot() {
markDeleteStarted();
await deleteGate;
bots = [];
return { bots };
},
}, { workspaces, stateFor: async () => ({ async clearSessions() {} }) });
const deleting = controller.deleteBot('bot_commit');
await deleteStarted;
await rename(storeDirectory, `${storeDirectory}-saved`);
await writeFile(storeDirectory, 'block cleanup persistence');
releaseDelete();
await deleting;
assert.equal(workspaces.has('bot_commit'), false);
await assert.rejects(workspaces.setWorkspace('bot_commit', defaultWorkspace), {
code: 'workspace-bot-not-found',
});
await rm(storeDirectory, { force: true });
await rename(`${storeDirectory}-saved`, storeDirectory);
assert.equal(JSON.parse(await readFile(storePath, 'utf8')).workspaces.bot_commit, alternateWorkspace);
await workspaces.reconcile([]);
await assert.rejects(readFile(storePath, 'utf8'), { code: 'ENOENT' });
});
test('config-store removal observation retires workspaces after the config commit', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await Promise.all([workspaces.ensure('bot_remove'), workspaces.ensure('bot_feishu')]);
const tokenStore = observeBotWorkspaceRemovals({
async remove(botId) { return { botId }; },
}, { workspaces });
const feishuStore = observeBotWorkspaceRemovals({
async removeBot(id) { return { id }; },
}, {
workspaces,
method: 'removeBot',
botIdFromRemoved: (removed) => removed.id,
});
await tokenStore.remove('bot_remove');
await feishuStore.removeBot('bot_feishu');
assert.equal(workspaces.has('bot_remove'), false);
assert.equal(workspaces.has('bot_feishu'), false);
});
test('/workspace command preserves spaces and returns actionable validation messages', async (t) => {
const { alternateWorkspace } = await fixture(t);
const switched = [];
const harness = { async switchWorkspace(path) { switched.push(path); return path; } };
assert.equal(await runWorkspaceCommand('hello', harness), null);
assert.match((await runWorkspaceCommand('/workspace', harness)).message, /用法/);
assert.match(
(await runWorkspaceCommand(`/workspace ${alternateWorkspace}`, harness)).message,
new RegExp(alternateWorkspace.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
);
assert.deepEqual(switched, [alternateWorkspace]);
const invalidHarness = {
async switchWorkspace() {
const error = new Error('工作区路径不存在。');
error.code = 'workspace-not-found';
throw error;
},
};
const invalid = await runWorkspaceCommand('/workspace /missing/workspace', invalidHarness);
assert.match(invalid.message, /路径不存在/);
assert.match(invalid.message, /用法:\/workspace 工作区绝对路径/);
const removedHarness = {
async switchWorkspace() {
const error = new Error('bot removed');
error.code = 'workspace-bot-not-found';
throw error;
},
};
assert.match(
(await runWorkspaceCommand(`/workspace ${alternateWorkspace}`, removedHarness)).message,
/正在移除或已重新接入/,
);
});
for (const [name, Client] of [
['Weixin', WeixinHarnessClient],
['Feishu', FeishuHarnessClient],
['DingTalk', DingtalkHarnessClient],
]) {
test(`${name} Harness creates a session with an explicit workspace override`, async () => {
const client = new Client({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/default-workspace',
agentPreset: 'standard',
autostart: false,
dshBin: 'dsh',
});
const calls = [];
client.ensureRunning = async () => true;
client.rpc = async (method, payload, _timeout, options) => {
calls.push({ method, payload, options });
if (method === 'workspace.list') return { items: [] };
if (method === 'workspace.create') return { workspace: { workspaceId: 'workspace-new' } };
if (method === 'session.create') return { sessionId: 'session-new' };
throw new Error(`Unexpected RPC: ${method}`);
};
const signal = new AbortController().signal;
const options = name === 'DingTalk'
? { workspace: '/explicit-workspace', signal }
: { workspace: '/explicit-workspace' };
assert.equal(await client.createSession(options), 'session-new');
assert.deepEqual(calls.map(({ method }) => method), [
'workspace.list', 'workspace.create', 'session.create',
]);
assert.equal(calls[1].payload.path, '/explicit-workspace');
if (name === 'DingTalk') assert.equal(calls[0].options.signal, signal);
});
}
test('workspace RPC validates payloads and returns the updated public status', async (t) => {
const { root, path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_one');
const base = {
status() { return { bots: [{ botId: 'bot_one', connected: true }] }; },
bindCredentials() { return this.status(); },
reconnectBot() { return this.status(); },
deleteBot() { return { bots: [] }; },
};
const controller = createWorkspaceAwareController(base, {
workspaces,
stateFor: async () => ({ async clearSessions() {} }),
});
const handler = createTokenBotRpcHandler(controller, { channel: 'Telegram' });
const success = await handler(TOKEN_BOT_ENDPOINTS.setWorkspace, {
botId: 'bot_one', workspace: alternateWorkspace,
});
assert.equal(success.ok, true);
assert.equal(success.value.bots[0].workspace, alternateWorkspace);
const relative = await handler(TOKEN_BOT_ENDPOINTS.setWorkspace, {
botId: 'bot_one', workspace: 'relative/path',
});
assert.equal(relative.error.code, 'bad-request');
const missing = await handler(TOKEN_BOT_ENDPOINTS.setWorkspace, {
botId: 'bot_one', workspace: join(root, 'missing'),
});
assert.equal(missing.error.code, 'workspace-not-found');
assert.match(missing.error.message, /不存在/);
});