feat: add workspace list command

This commit is contained in:
xmanrui 2026-08-18 00:39:16 +08:00
parent c44340accb
commit 1fe6180813
17 changed files with 570 additions and 118 deletions

View file

@ -3,6 +3,53 @@ import test from 'node:test';
import { HarnessClient, HarnessReplyTracker, HarnessRpcError } from '../../../src/channels/dingtalk/harness-client.mjs';
test('HarnessClient lists only absolute workspace paths and forwards request options', async () => {
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/default-workspace',
});
const options = {
signal: new AbortController().signal,
rpcId: 'dingtalk-workspace-list',
};
let ensuredWith;
let rpcCall;
let response = {
items: [
{
workspaceId: 'workspace-one',
path: '/tmp/workspace-one',
title: 'private title',
sessionIds: ['private-session'],
},
{ workspaceId: 'relative', path: 'relative/workspace' },
null,
{ workspaceId: 'workspace-two', path: '/tmp/workspace two' },
],
archivedSessionIds: ['private-archive'],
};
client.ensureRunning = async (received) => { ensuredWith = received; };
client.rpc = async (method, payload, timeoutMs, rpcOptions) => {
rpcCall = { method, payload, timeoutMs, options: rpcOptions };
return response;
};
assert.deepEqual(await client.listWorkspaces(options), [
'/tmp/workspace-one',
'/tmp/workspace two',
]);
assert.equal(ensuredWith, options);
assert.deepEqual(rpcCall, {
method: 'workspace.list',
payload: {},
timeoutMs: 30_000,
options,
});
response = null;
assert.deepEqual(await client.listWorkspaces(), []);
});
test('reply tracker associates only the Harness turn created by the DingTalk prompt RPC', () => {
const tracker = new HarnessReplyTracker({ promptRpcId: 'dingtalk-prompt', afterSeq: 2 });
const update = tracker.consume([

View file

@ -5,6 +5,46 @@ import {
HarnessReplyTracker,
} from '../../../src/channels/feishu/harness-client.mjs';
test('HarnessClient lists only absolute workspace paths', async () => {
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/default-workspace',
});
const options = { rpcId: 'feishu-workspace-list' };
const calls = [];
let response = {
items: [
{
workspaceId: 'workspace-one',
path: '/tmp/workspace-one',
title: 'private title',
sessionIds: ['private-session'],
},
{ workspaceId: 'relative', path: 'relative/workspace' },
null,
{ workspaceId: 'workspace-two', path: '/tmp/workspace two' },
],
archivedSessionIds: ['private-archive'],
};
client.ensureRunning = async () => { calls.push({ method: 'ensureRunning' }); };
client.rpc = async (method, payload, timeoutMs, rpcOptions) => {
calls.push({ method, payload, timeoutMs, options: rpcOptions });
return response;
};
assert.deepEqual(await client.listWorkspaces(options), [
'/tmp/workspace-one',
'/tmp/workspace two',
]);
assert.deepEqual(calls, [
{ method: 'ensureRunning' },
{ method: 'workspace.list', payload: {}, timeoutMs: 30000, options },
]);
response = { items: 'invalid' };
assert.deepEqual(await client.listWorkspaces(), []);
});
test('HarnessClient reads the nested workspace.create response used by DSH rc.6', async (t) => {
const methods = [];
t.mock.method(globalThis, 'fetch', async (_url, options) => {

View file

@ -1,7 +1,47 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { HarnessReplyTracker } from '../../../src/channels/weixin/harness-client.mjs';
import { HarnessClient, HarnessReplyTracker } from '../../../src/channels/weixin/harness-client.mjs';
test('HarnessClient lists only absolute workspace paths', async () => {
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/default-workspace',
});
const options = { rpcId: 'weixin-workspace-list' };
const calls = [];
let response = {
items: [
{
workspaceId: 'workspace-one',
path: '/tmp/workspace-one',
title: 'private title',
sessionIds: ['private-session'],
},
{ workspaceId: 'relative', path: 'relative/workspace' },
null,
{ workspaceId: 'workspace-two', path: '/tmp/workspace two' },
],
archivedSessionIds: ['private-archive'],
};
client.ensureRunning = async () => { calls.push({ method: 'ensureRunning' }); };
client.rpc = async (method, payload, timeoutMs, rpcOptions) => {
calls.push({ method, payload, timeoutMs, options: rpcOptions });
return response;
};
assert.deepEqual(await client.listWorkspaces(options), [
'/tmp/workspace-one',
'/tmp/workspace two',
]);
assert.deepEqual(calls, [
{ method: 'ensureRunning' },
{ method: 'workspace.list', payload: {}, timeoutMs: 30_000, options },
]);
response = { items: 'invalid' };
assert.deepEqual(await client.listWorkspaces(), []);
});
test('reply tracker associates only the Harness turn created by the Weixin prompt RPC', () => {
const tracker = new HarnessReplyTracker({ promptRpcId: 'weixin-prompt', afterSeq: 2 });

View file

@ -12,7 +12,10 @@ import {
observeBotWorkspaceRemovals,
validateWorkspacePath,
} from '../src/channels/shared/bot-workspace-store.mjs';
import { runWorkspaceCommand } from '../src/channels/shared/workspace-command.mjs';
import {
runWorkspaceCommand,
splitWorkspaceCommandMessage,
} 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';
@ -755,6 +758,140 @@ test('/workspace command preserves spaces and returns actionable validation mess
);
});
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.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('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 /workspacelist 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, /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' });
});
for (const [name, Client] of [
['Weixin', WeixinHarnessClient],
['Feishu', FeishuHarnessClient],