dsh-im-ops/test/workspace.test.mjs

1558 lines
64 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
createBotScopedHarness,
createBotWorkspaceScope,
createWorkspaceAwareController,
observeBotWorkspaceRemovals,
validateWorkspacePath,
} from '../src/channels/shared/bot-workspace-store.mjs';
import { listAgentPresetCatalog } from '../src/channels/shared/agent-preset.mjs';
import {
connectionTestTarget,
rememberConnectionTestTarget,
} from '../src/channels/shared/connection-test.mjs';
import {
runWorkspaceCommand,
splitWorkspaceCommandMessage,
} from '../src/channels/shared/workspace-command.mjs';
import { TextHarnessBridge } from '../src/channels/shared/text-harness-bridge.mjs';
import {
askInWorkspaceSession,
WORKSPACE_SESSION_STALE,
} 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 realpath(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('connection test targets survive a new workspace scope for the same bot', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_reconnect');
const state = {};
const harness = {};
const beforeReconnect = createBotWorkspaceScope(harness, {
botId: 'bot_reconnect', workspaces, state,
});
const afterReconnect = createBotWorkspaceScope(harness, {
botId: 'bot_reconnect', workspaces, state,
});
assert.equal(rememberConnectionTestTarget(beforeReconnect.state, { channelId: 'D123' }), true);
assert.deepEqual(connectionTestTarget(afterReconnect.state), { channelId: 'D123' });
});
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', { defaultAgentPreset: 'router-standard' }));
assert.equal(broken.workspaceFor('bot_new'), defaultWorkspace);
assert.equal(broken.agentPresetFor('bot_new'), null);
});
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('an old workspace session handle cannot list, select, stop, or steer after a switch', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_session_controls');
const targetCalls = [];
const harness = {
async getSessionModels(...args) { targetCalls.push(['models', ...args]); },
async selectSessionModel(...args) { targetCalls.push(['select', ...args]); },
async stopActiveTurn(...args) { targetCalls.push(['stop', ...args]); },
async steerActiveTurn(...args) { targetCalls.push(['steer', ...args]); },
};
const state = { async clearSessions() {} };
const scope = createBotWorkspaceScope(harness, {
botId: 'bot_session_controls', workspaces, state,
});
const oldSession = scope.harness.workspaceSession('session-old');
const controller = createWorkspaceAwareController({
status() { return { bots: [{ botId: 'bot_session_controls' }] }; },
}, { workspaces, stateFor: async () => state });
await controller.updateWorkspace('bot_session_controls', alternateWorkspace);
const control = { owner: {}, key: 'direct:one' };
for (const operation of [
() => oldSession.models(),
() => oldSession.selectModel({ provider: 'provider', model: 'model' }),
() => oldSession.stopActiveTurn(control),
() => oldSession.steerActiveTurn('continue', control),
]) {
await assert.rejects(operation(), (error) => error?.code === WORKSPACE_SESSION_STALE);
}
assert.deepEqual(targetCalls, []);
});
test('a control mutation that already started keeps its result across a workspace switch', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_started_controls');
const started = [];
let release;
const gate = new Promise((resolve) => { release = resolve; });
const harness = {
async stopActiveTurn() { started.push('stop'); await gate; return true; },
async steerActiveTurn() { started.push('steer'); await gate; return true; },
};
const state = { async clearSessions() {} };
const scope = createBotWorkspaceScope(harness, {
botId: 'bot_started_controls', workspaces, state,
});
const session = scope.harness.workspaceSession('session-old');
const control = { owner: {}, key: 'direct:one' };
const stop = session.stopActiveTurn(control);
const steer = session.steerActiveTurn('continue', control);
assert.deepEqual(started, ['stop', 'steer']);
const controller = createWorkspaceAwareController({
status() { return { bots: [{ botId: 'bot_started_controls' }] }; },
}, { workspaces, stateFor: async () => state });
await controller.updateWorkspace('bot_started_controls', alternateWorkspace);
release();
assert.deepEqual(await Promise.all([stop, steer]), [true, true]);
});
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,
/正在移除或已重新接入/,
);
});
test('/workspacelist returns existing absolute paths with the current workspace first', async (t) => {
const { root, defaultWorkspace, alternateWorkspace } = await fixture(t);
const thirdWorkspace = join(root, 'third');
await mkdir(thirdWorkspace);
let listCalls = 0;
const harness = {
currentWorkspace() { return defaultWorkspace; },
async listWorkspaces() {
listCalls += 1;
return [
alternateWorkspace,
defaultWorkspace,
alternateWorkspace,
thirdWorkspace,
join(root, 'missing'),
'relative/path',
];
},
};
const result = await runWorkspaceCommand('/WORKSPACELIST', harness);
assert.equal(result.handled, true);
assert.match(result.message, /工作区(3)/);
assert.match(result.message, new RegExp(`1\\. ${defaultWorkspace.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(当前)`));
assert.ok(result.message.indexOf(defaultWorkspace) < result.message.indexOf(alternateWorkspace));
assert.ok(result.message.indexOf(alternateWorkspace) < result.message.indexOf(thirdWorkspace));
assert.doesNotMatch(result.message, /missing|relative\/path/);
assert.match(result.message, /切换用法:\/workspace 工作区绝对路径/);
assert.match(result.message, /查看会话:\/sessionlist 工作区序号或绝对路径/);
assert.equal(result.messages.join(''), result.message);
assert.equal(listCalls, 1);
assert.match((await runWorkspaceCommand('/workspacelist extra', harness)).message, /用法/);
assert.equal(listCalls, 1);
assert.match((await runWorkspaceCommand('/workspacelist', {})).message, /暂不支持/);
assert.match((await runWorkspaceCommand('/workspacelist', {
async listWorkspaces() { throw new Error('private host detail'); },
})).message, /暂时无法获取/);
assert.match((await runWorkspaceCommand('/workspacelist', {
async listWorkspaces() { return []; },
})).message, /没有仍然存在/);
});
test('/workspacelist splits a long registry without dropping paths', async (t) => {
const { root, defaultWorkspace } = await fixture(t);
const paths = Array.from({ length: 48 }, (_, index) => (
join(root, `workspace-${String(index).padStart(2, '0')}`)
));
await Promise.all(paths.map((workspace) => mkdir(workspace)));
const result = await runWorkspaceCommand('/workspacelist', {
currentWorkspace() { return defaultWorkspace; },
async listWorkspaces() { return paths; },
});
assert.ok(result.messages.length > 1);
assert.equal(result.messages.join(''), result.message);
assert.ok(result.messages.every((message) => message.length <= 1_800));
for (const workspace of paths) assert.ok(result.message.includes(workspace));
});
test('/workspacelist hides unsafe Unicode paths and rechecks the bot scope', async (t) => {
const { root, defaultWorkspace } = await fixture(t);
const unsafePaths = [
join(root, 'line\u2028separator'),
join(root, 'bidi\u202ereversal'),
join(root, 'control\u0085next-line'),
];
await Promise.all(unsafePaths.map((workspace) => mkdir(workspace)));
const filtered = await runWorkspaceCommand('/workspacelist', {
currentWorkspace() { return defaultWorkspace; },
async listWorkspaces() { return unsafePaths; },
});
assert.match(filtered.message, /工作区(1)/);
for (const workspace of unsafePaths) assert.ok(!filtered.message.includes(workspace));
const stale = await runWorkspaceCommand('/workspacelist', {
currentWorkspace() { return defaultWorkspace; },
async listWorkspaces() { return [defaultWorkspace]; },
assertWorkspaceScope() {
const error = new Error('old bot lifecycle');
error.code = 'workspace-bot-not-found';
throw error;
},
});
assert.match(stale.message, /正在移除或已重新接入/);
});
test('/sessionlist supports the current workspace, list numbers, and absolute paths', async (t) => {
const { root, defaultWorkspace, alternateWorkspace } = await fixture(t);
const thirdWorkspace = join(root, 'third workspace');
await mkdir(thirdWorkspace);
const listedWorkspaces = [];
let workspaceListCalls = 0;
const sessionsByWorkspace = new Map([
[defaultWorkspace, [
{
sessionId: 'session-current',
title: '安全标题\u202e伪造\n4. injected',
archived: false,
blank: true,
origin: 'subagent',
summaryAvailable: true,
},
{
sessionId: 'session-archived',
title: null,
archived: true,
blank: false,
origin: null,
summaryAvailable: true,
},
{
sessionId: 'session-missing-summary',
title: null,
archived: false,
blank: false,
origin: null,
summaryAvailable: false,
},
]],
[alternateWorkspace, [{
sessionId: 'session-alternate',
title: 'Alternate session',
archived: false,
summaryAvailable: true,
}]],
[thirdWorkspace, []],
]);
const harness = {
currentWorkspace() { return defaultWorkspace; },
async listWorkspaces() {
workspaceListCalls += 1;
return [alternateWorkspace, defaultWorkspace, thirdWorkspace];
},
async listWorkspaceSessions(workspace) {
listedWorkspaces.push(workspace);
return { workspace, sessions: sessionsByWorkspace.get(workspace) ?? [] };
},
};
const current = await runWorkspaceCommand('/SESSIONLIST', harness);
assert.match(current.message, new RegExp(`工作区:${defaultWorkspace.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
assert.match(current.message, /会话(3)/);
assert.match(current.message, /1\. 安全标题 伪造 4\. injected\n ID: session-current/);
assert.doesNotMatch(current.message, /\u202e|\n4\. injected/);
assert.match(current.message, /2\. 暂无标题(已归档)\n ID: session-archived/);
assert.match(current.message, /3\. 标题暂不可用\n ID: session-missing-summary/);
assert.match(current.message, /绑定用法:\/session Session ID 或当前工作区序号(\/session N)/);
assert.equal(current.messages.join(''), current.message);
const numbered = await runWorkspaceCommand('/sessionlist 2', harness);
assert.match(numbered.message, new RegExp(`工作区:${alternateWorkspace.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
assert.match(numbered.message, /Alternate session/);
assert.match(numbered.message, /绑定用法:\/session Session ID\n提示:\/session N 只按机器人当前工作区的序号绑定/);
assert.doesNotMatch(numbered.message, /Session ID 或当前工作区序号/);
const absolute = await runWorkspaceCommand(`/sessionlist ${thirdWorkspace}`, harness);
assert.match(absolute.message, /该工作区暂无会话/);
assert.deepEqual(listedWorkspaces, [defaultWorkspace, alternateWorkspace, thirdWorkspace]);
assert.equal(workspaceListCalls, 1, 'only numeric selection needs the workspace registry order');
});
test('/sessionlist returns actionable and safe errors', async (t) => {
const { root, defaultWorkspace } = await fixture(t);
const file = join(root, 'not-a-workspace.txt');
await writeFile(file, 'not a directory');
const supported = {
currentWorkspace() { return defaultWorkspace; },
async listWorkspaces() { return [defaultWorkspace]; },
async listWorkspaceSessions(workspace) { return { workspace, sessions: [] }; },
};
const invalidUsage = (await runWorkspaceCommand('/sessionlist relative/path', supported)).message;
assert.match(invalidUsage, /工作区必须是绝对路径/);
assert.match(invalidUsage, /\/sessionlist 列出当前工作区会话/);
assert.match(invalidUsage, /\/sessionlist 工作区序号/);
assert.match(invalidUsage, /\/sessionlist 工作区绝对路径/);
assert.match((await runWorkspaceCommand('/sessionlist 0', supported)).message, /序号不存在/);
assert.match((await runWorkspaceCommand('/sessionlist 99', supported)).message, /\/workspacelist/);
assert.match(
(await runWorkspaceCommand(`/sessionlist ${join(root, 'missing')}`, supported)).message,
/工作区路径不存在/,
);
assert.match(
(await runWorkspaceCommand(`/sessionlist ${file}`, supported)).message,
/工作区路径必须指向一个目录/,
);
assert.match((await runWorkspaceCommand('/sessionlist', {})).message, /暂不支持/);
assert.match((await runWorkspaceCommand('/sessionlist', {
currentWorkspace() { return defaultWorkspace; },
async listWorkspaceSessions() { throw new Error('private Harness detail'); },
})).message, /暂时无法获取/);
const stale = new Error('old bot lifecycle');
stale.code = 'workspace-bot-not-found';
const staleResult = await runWorkspaceCommand('/sessionlist', {
currentWorkspace() { return defaultWorkspace; },
async listWorkspaceSessions() { throw stale; },
});
assert.match(staleResult.message, /正在移除或已重新接入/);
assert.doesNotMatch(staleResult.message, /old bot lifecycle|private Harness detail/);
});
test('/workspacelist and /sessionlist canonicalize a symbolic-link workspace', async (t) => {
const { root } = await fixture(t);
const canonicalWorkspace = join(root, 'canonical-workspace');
const linkedWorkspace = join(root, 'linked-workspace');
await mkdir(canonicalWorkspace);
await symlink(canonicalWorkspace, linkedWorkspace, 'dir');
const requested = [];
const harness = {
currentWorkspace() { return linkedWorkspace; },
async listWorkspaces() { return [canonicalWorkspace, linkedWorkspace]; },
async listWorkspaceSessions(workspace) {
requested.push(workspace);
return {
workspace,
sessions: [{
sessionId: 'session-through-link',
title: 'Canonical workspace session',
archived: false,
summaryAvailable: true,
}],
};
},
};
const workspaces = await runWorkspaceCommand('/workspacelist', harness);
assert.match(workspaces.message, /工作区(1)/);
assert.match(workspaces.message, new RegExp(`1\\. ${canonicalWorkspace.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(当前)`));
const current = await runWorkspaceCommand('/sessionlist', harness);
const absolute = await runWorkspaceCommand(`/sessionlist ${linkedWorkspace}`, harness);
assert.match(current.message, new RegExp(`工作区:${canonicalWorkspace.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
assert.match(current.message, /session-through-link/);
assert.match(absolute.message, /session-through-link/);
assert.deepEqual(requested, [canonicalWorkspace, canonicalWorkspace]);
});
test('/sessionlist splits a long complete session list without losing IDs', async (t) => {
const { defaultWorkspace } = await fixture(t);
const sessions = Array.from({ length: 120 }, (_, index) => ({
sessionId: `session-${String(index).padStart(3, '0')}`,
title: `会话 ${index} ${'标题'.repeat(12)}`,
archived: index % 9 === 0,
summaryAvailable: true,
}));
const result = await runWorkspaceCommand('/sessionlist', {
currentWorkspace() { return defaultWorkspace; },
async listWorkspaceSessions(workspace) { return { workspace, sessions }; },
});
assert.ok(result.messages.length > 1);
assert.ok(result.messages.every((message) => message.length <= 1_800));
assert.equal(result.messages.join(''), result.message);
for (const session of sessions) assert.ok(result.message.includes(session.sessionId));
});
test('the shared bridge sends every /sessionlist chunk without creating or prompting a session', async (t) => {
const { defaultWorkspace } = await fixture(t);
const sent = [];
const seen = new Set();
let sessionCalls = 0;
const sessions = Array.from({ length: 120 }, (_, index) => ({
sessionId: `bridge-session-${String(index).padStart(3, '0')}`,
title: `Bridge title ${index} ${'detail '.repeat(8)}`,
archived: false,
summaryAvailable: true,
}));
const bridge = new TextHarnessBridge({
descriptor: { key: 'test', label: 'Test' },
bot: { async sendText(_target, text) { sent.push(text); } },
harness: {
currentWorkspace() { return defaultWorkspace; },
async listWorkspaceSessions(workspace) { return { workspace, sessions }; },
async createSession() { sessionCalls += 1; },
async ask() { sessionCalls += 1; },
},
state: {
hasSeen(messageId) { return seen.has(messageId); },
async markSeen(messageId) { seen.add(messageId); },
},
});
await bridge.accept({
messageId: 'message-sessionlist',
senderId: 'sender',
conversationId: 'conversation',
kind: 'direct',
content: '/sessionlist',
replyTarget: 'target',
});
assert.ok(sent.length > 1);
assert.ok(sent.every((message) => message.length <= 1_800));
for (const session of sessions) assert.ok(sent.join('').includes(session.sessionId));
assert.equal(sessionCalls, 0);
assert.equal(seen.has('message-sessionlist'), true);
});
test('workspace command message splitting bounds a single very long path', () => {
const message = `/workspace/${'nested/'.repeat(600)}project-😀`;
const messages = splitWorkspaceCommandMessage(message);
assert.ok(messages.length > 1);
assert.ok(messages.every((part) => part.length <= 1_800));
assert.equal(messages.join(''), message);
});
test('all nine channel bridge families advertise and fan out workspace command replies', async () => {
const bridgeFiles = [
'../src/channels/shared/text-harness-bridge.mjs',
'../src/channels/weixin/weixin-bridge.mjs',
'../src/channels/feishu/bridge.mjs',
'../src/channels/dingtalk/dingtalk-bridge.mjs',
'../src/channels/wecom/wecom-bridge.mjs',
'../src/channels/qq/qq-bridge.mjs',
];
for (const file of bridgeFiles) {
const source = await readFile(new URL(file, import.meta.url), 'utf8');
assert.match(source, /\/workspacelist 列出工作区绝对路径/);
assert.match(source, /\/sessionlist \[工作区序号或绝对路径\] 列出会话 ID 和标题/);
assert.match(source, /workspaceCommand\.messages \?\? \[workspaceCommand\.message\]/);
}
});
test('a stale bot scope cannot finish listing workspaces after same-id rebinding', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_list');
let finishList;
const harness = {
listWorkspaces() {
return new Promise((resolve) => { finishList = resolve; });
},
};
const oldScope = createBotScopedHarness(harness, {
botId: 'bot_list',
workspaces,
state: { async clearSessions() {} },
});
const pending = oldScope.listWorkspaces();
await workspaces.retireAfterConfigCommit('bot_list');
await workspaces.ensure('bot_list', { workspace: alternateWorkspace });
finishList([defaultWorkspace]);
await assert.rejects(pending, { code: 'workspace-bot-not-found' });
});
test('a stale bot scope cannot finish listing workspace sessions after same-id rebinding', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_session_list');
let finishList;
const oldScope = createBotScopedHarness({
listWorkspaceSessions() {
return new Promise((resolveList) => { finishList = resolveList; });
},
}, {
botId: 'bot_session_list',
workspaces,
state: { async clearSessions() {} },
});
const pending = oldScope.listWorkspaceSessions(defaultWorkspace);
await workspaces.retireAfterConfigCommit('bot_session_list');
await workspaces.ensure('bot_session_list', { workspace: alternateWorkspace });
finishList({ workspace: defaultWorkspace, sessions: [] });
await assert.rejects(pending, { code: 'workspace-bot-not-found' });
});
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, /不存在/);
});
test('BotWorkspaceStore persists per-bot agent presets without changing workspaces', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await Promise.all([store.ensure('bot_one'), store.ensure('bot_two')]);
assert.equal(store.agentPresetFor('bot_one'), null);
await store.setAgentPreset('bot_one', 'marketing-jeep');
assert.equal(store.agentPresetFor('bot_one'), 'marketing-jeep');
assert.equal(store.agentPresetFor('bot_two'), null);
assert.equal(store.workspaceFor('bot_one'), defaultWorkspace);
await store.setWorkspace('bot_one', alternateWorkspace);
assert.equal(store.agentPresetFor('bot_one'), 'marketing-jeep');
assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), {
version: 1,
workspaces: { bot_one: alternateWorkspace, bot_two: defaultWorkspace },
agentPresets: { bot_one: 'marketing-jeep' },
});
await store.setAgentPreset('bot_one', null);
assert.equal(store.agentPresetFor('bot_one'), null);
assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), {
version: 1,
workspaces: { bot_one: alternateWorkspace, bot_two: defaultWorkspace },
});
await writeFile(path, `${JSON.stringify({
version: 1,
workspaces: { bot_one: defaultWorkspace },
agentPresets: { bot_one: 'standard-claude' },
}, null, 2)}\n`);
const reloaded = await new BotWorkspaceStore(path, { defaultWorkspace: tmpdir() }).load();
assert.equal(reloaded.agentPresetFor('bot_one'), 'standard-claude');
});
test('BotWorkspaceStore applies a channel preset only when a bot is first created', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await store.ensure('bot_seeded', { defaultAgentPreset: 'marketing-jeep' });
await store.ensure('bot_existing');
await store.ensure('bot_existing', { defaultAgentPreset: 'router-standard' });
assert.equal(store.agentPresetFor('bot_seeded'), 'marketing-jeep');
assert.equal(store.agentPresetFor('bot_existing'), null);
await store.setAgentPreset('bot_seeded', null);
await store.ensure('bot_seeded', { defaultAgentPreset: 'router-standard' });
assert.equal(store.agentPresetFor('bot_seeded'), null);
await store.ensure('bot_existing', { defaultAgentPreset: 'Not Valid' });
await assert.rejects(
store.ensure('bot_invalid', { defaultAgentPreset: 'Not Valid' }),
{ code: 'agent-preset-invalid' },
);
assert.equal(store.has('bot_invalid'), false);
await store.remove('bot_seeded');
await store.ensure('bot_seeded', { defaultAgentPreset: 'router-standard' });
assert.equal(store.agentPresetFor('bot_seeded'), 'router-standard');
});
test('Agent Preset catalog filters broken entries and exposes public fields only', async () => {
const catalog = await listAgentPresetCatalog({
get(name) {
assert.equal(name, 'agentPresets');
return {
defaultId: 'standard',
async list() {
return [
{ id: 'standard', name: 'Standard', path: '/secret/standard', trust: 'trusted' },
{ id: 'broken-one', name: 'Broken', broken: 'missing file', path: '/secret/broken' },
{ id: 'marketing-jeep', label: 'Marketing' },
{ id: 'marketing-jeep', label: 'Duplicate' },
{ id: 'Not Valid', label: 'Invalid' },
];
},
};
},
});
assert.deepEqual(catalog, {
defaultId: 'standard',
items: [
{ id: 'standard', label: 'Standard' },
{ id: 'marketing-jeep', label: 'Marketing' },
],
});
});
test('Agent Preset catalog lookup failures fail soft', async () => {
const empty = { defaultId: '', items: [] };
assert.deepEqual(await listAgentPresetCatalog({
get() {
throw new Error('service was unloaded');
},
}), empty);
const service = {
async list() {
return [{ id: 'standard', name: 'Standard' }];
},
};
Object.defineProperty(service, 'defaultId', {
get() {
throw new Error('service was replaced');
},
});
assert.deepEqual(await listAgentPresetCatalog({ agentPresets: service }), empty);
});
test('BotWorkspaceStore rejects invalid agent preset ids and missing bots', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await store.ensure('bot_one');
await assert.rejects(store.setAgentPreset('bot_one', 'Standard'), { code: 'agent-preset-invalid' });
await assert.rejects(store.setAgentPreset('bot_missing', 'standard'), { code: 'workspace-bot-not-found' });
assert.equal(store.agentPresetFor('bot_one'), null);
});
test('changing a bot agent preset does not clear sessions', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await store.ensure('bot_one');
let clears = 0;
const generation = store.generationFor('bot_one');
await store.setAgentPreset('bot_one', 'marketing-jeep', {
clearSessions: async () => { clears += 1; },
});
assert.equal(clears, 0);
assert.equal(store.generationFor('bot_one'), generation);
});
test('bot-scoped Harness creates sessions with the selected agent preset', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await Promise.all([workspaces.ensure('bot_one'), workspaces.ensure('bot_two')]);
await workspaces.setAgentPreset('bot_one', 'marketing-jeep');
const calls = [];
const harness = {
async createSession(options) { calls.push(options); return `session-${calls.length}`; },
async ensureRunning() { return true; },
};
const state = { async clearSessions() {} };
const one = createBotScopedHarness(harness, { botId: 'bot_one', workspaces, state });
const two = createBotScopedHarness(harness, { botId: 'bot_two', workspaces, state });
await one.createSession();
await two.createSession();
assert.equal(calls[0].workspace, defaultWorkspace);
assert.equal(calls[0].agentPreset, 'marketing-jeep');
assert.equal(calls[1].workspace, defaultWorkspace);
assert.equal(Object.hasOwn(calls[1], 'agentPreset'), false);
});
test('workspace RPC can set a bot agent preset without switching workspace', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_one');
await workspaces.setWorkspace('bot_one', alternateWorkspace);
const catalog = {
defaultId: 'standard',
items: [
{ id: 'standard', label: 'Standard' },
{ id: 'marketing-jeep', label: '营销吉普' },
],
};
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() {} }),
agentPresetCatalog: catalog,
});
const handler = createTokenBotRpcHandler(controller, { channel: 'Telegram' });
const generation = workspaces.generationFor('bot_one');
const listed = await handler(TOKEN_BOT_ENDPOINTS.status, {});
assert.equal(listed.ok, true);
assert.deepEqual(listed.value.agentPresetCatalog, catalog);
assert.equal(listed.value.bots[0].agentPreset, null);
assert.equal(listed.value.bots[0].workspace, alternateWorkspace);
const success = await handler(TOKEN_BOT_ENDPOINTS.setAgentPreset, {
botId: 'bot_one', agentPreset: 'marketing-jeep',
});
assert.equal(success.ok, true);
assert.equal(success.value.bots[0].agentPreset, 'marketing-jeep');
assert.equal(success.value.bots[0].workspace, alternateWorkspace);
assert.equal(workspaces.generationFor('bot_one'), generation);
const cleared = await handler(TOKEN_BOT_ENDPOINTS.setAgentPreset, {
botId: 'bot_one', agentPreset: null,
});
assert.equal(cleared.ok, true);
assert.equal(cleared.value.bots[0].agentPreset, null);
const invalid = await handler(TOKEN_BOT_ENDPOINTS.setAgentPreset, {
botId: 'bot_one', agentPreset: 'Not Valid',
});
assert.equal(invalid.ok, false);
assert.equal(invalid.error.code, 'bad-request');
});
test('workspace RPC refreshes the Agent Preset catalog and rejects unavailable choices', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_one');
let catalog = {
defaultId: 'standard',
items: [{ id: 'standard', label: 'Standard' }],
};
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() {} }),
agentPresetCatalog: async () => catalog,
});
const handler = createTokenBotRpcHandler(controller, { channel: 'Telegram' });
const initial = await handler(TOKEN_BOT_ENDPOINTS.status, {});
assert.deepEqual(initial.value.agentPresetCatalog, catalog);
catalog = {
defaultId: 'marketing-jeep',
items: [
{ id: 'standard', label: 'Broken Standard', broken: 'missing entrypoint' },
{ id: 'marketing-jeep', label: 'Marketing' },
],
};
const refreshed = await handler(TOKEN_BOT_ENDPOINTS.status, {});
assert.deepEqual(refreshed.value.agentPresetCatalog, {
defaultId: 'marketing-jeep',
items: [{ id: 'marketing-jeep', label: 'Marketing' }],
});
const unavailable = await handler(TOKEN_BOT_ENDPOINTS.setAgentPreset, {
botId: 'bot_one', agentPreset: 'standard',
});
assert.equal(unavailable.ok, false);
assert.equal(unavailable.error.code, 'agent-preset-unavailable');
assert.equal(workspaces.agentPresetFor('bot_one'), null);
const selected = await handler(TOKEN_BOT_ENDPOINTS.setAgentPreset, {
botId: 'bot_one', agentPreset: 'marketing-jeep',
});
assert.equal(selected.ok, true);
assert.equal(selected.value.bots[0].agentPreset, 'marketing-jeep');
catalog = { defaultId: '', items: [] };
const cleared = await handler(TOKEN_BOT_ENDPOINTS.setAgentPreset, {
botId: 'bot_one', agentPreset: null,
});
assert.equal(cleared.ok, true);
assert.equal(cleared.value.bots[0].agentPreset, null);
assert.deepEqual(cleared.value.agentPresetCatalog, catalog);
});