feat: add workspace session list command

This commit is contained in:
xmanrui 2026-08-18 01:38:18 +08:00
parent c51334ebc3
commit 28381468b3
17 changed files with 989 additions and 122 deletions

View file

@ -71,13 +71,17 @@ QQ 扫码接入使用腾讯 QQBot v2 官方流程。默认腾讯授权页会把
| --- | --- |
| `/workspace <工作区绝对路径>` | 切换当前机器人的 Harness 工作区。 |
| `/workspacelist` | 列出当前 Harness Host 上仍然存在的工作区绝对路径。 |
| `/sessionlist [工作区序号或绝对路径]` | 列出指定工作区登记的所有会话 ID 和标题;省略参数时使用当前工作区。 |
示例:`/workspace /Users/alice/projects/my-app`
示例:`/workspace /Users/alice/projects/my-app`、`/sessionlist`、`/sessionlist 2` 或 `/sessionlist /Users/alice/projects/my-app`
- 只接受已经存在的绝对目录;路径无效时机器人会返回具体提示和正确用法。
- `/workspacelist` 不需要参数。它合并 Harness 全局登记项与当前机器人的路径;当前路径仍存在且可安全显示时会排在首位并标记为“当前”。结果可直接复制到 `/workspace` 命令。
- 任何已在对应平台可见范围内、能够正常向机器人发消息的用户都可以执行,不区分管理员和普通用户。
- `/sessionlist` 的数字参数按命令执行时与 `/workspacelist` 相同的最新顺序解析;也可使用绝对路径直接指定工作区。结果会回显最终选中的路径。
- `/sessionlist` 会列出该工作区登记的所有会话。已归档会话会标记为“已归档”;空白会话和子代理会话在它们归属该工作区时也会列出;没有标题的会话显示为“暂无标题”。
- 任何已在对应平台可见范围内、能够正常向机器人发消息的用户都可以执行这些命令,不区分管理员和普通用户。
- 工作区列表来自 Harness Host 的全局登记信息,可能包含其他机器人、其他渠道或非 IM 项目的本机绝对路径。请将机器人可见范围限制给可信用户。
- 会话列表同样来自该全局 Harness Host;会话 ID 和标题可能属于其他机器人、其他渠道或非 IM 项目,并可能包含敏感元数据。开放命令前请确保所有可见用户都可信。
- 切换成功后只清除当前机器人的旧 Harness 会话映射,不影响其他机器人。
- 新工作区对后续消息生效;已经开始生成的回复会继续完成。
@ -176,13 +180,17 @@ Each bot maintains an independent Harness workspace. A newly connected bot recor
| --- | --- |
| `/workspace <absolute workspace path>` | Switch the current bot's Harness workspace. |
| `/workspacelist` | List workspace absolute paths that still exist on the current Harness Host. |
| `/sessionlist [workspace number or absolute path]` | List every registered session ID and title in the selected workspace; omit the argument to use the current workspace. |
Example: `/workspace /Users/alice/projects/my-app`
Examples: `/workspace /Users/alice/projects/my-app`, `/sessionlist`, `/sessionlist 2`, or `/sessionlist /Users/alice/projects/my-app`
- The path must be an existing absolute directory. The bot returns an actionable error and the correct usage when validation fails.
- `/workspacelist` takes no arguments. It combines the Harness global registry with the current bot's path. When that current path still exists and is safe to display, it appears first and is marked as current. Any listed path can be copied directly into `/workspace`.
- Any user who is already within the platform bot's visibility scope and can normally message it can run the command; there is no additional administrator/ordinary-user distinction.
- A numeric `/sessionlist` argument uses the same freshly resolved order as `/workspacelist` at command execution time. An absolute path can also select a workspace directly, and the result echoes the resolved path.
- `/sessionlist` includes every session registered to the selected workspace. Archived sessions are marked as archived; blank and subagent sessions are included when they belong to that workspace; sessions without a title are shown as `No title yet`.
- Any user who is already within the platform bot's visibility scope and can normally message it can run these commands; there is no additional administrator/ordinary-user distinction.
- The list comes from the Harness Host's global registry and can include local absolute paths for other bots, other channels, or non-IM projects. Restrict the bot's visibility to trusted users.
- Session results also come from the global Harness Host. Session IDs and titles can belong to other bots, other channels, or non-IM projects, and may contain sensitive metadata. Enable these commands only when every user in the bot's visibility scope is trusted.
- A successful switch clears only the current bot's old Harness session mappings and does not affect other bots.
- The new workspace applies to subsequent messages; a reply that has already started generating is allowed to finish.

File diff suppressed because one or more lines are too long

View file

@ -16,6 +16,7 @@ const HELP_TEXT = [
'/new 开启一个全新会话',
'/workspace 工作区绝对路径 切换工作区',
'/workspacelist 列出工作区绝对路径',
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
'/status 检查连接状态',
'/help 显示本帮助',
].join('\n');

View file

@ -9,6 +9,47 @@ function workspacePaths(value) {
));
}
function workspaceFromList(workspacePath, workspaceList) {
if (!Array.isArray(workspaceList?.items)
|| !Array.isArray(workspaceList?.archivedSessionIds)) {
throw new Error('Harness returned an invalid response for workspace.list');
}
const workspace = workspaceList.items.find((item) => item?.path === workspacePath);
if (!workspace) return null;
if (!Array.isArray(workspace.sessionIds)
|| workspace.sessionIds.some((sessionId) => typeof sessionId !== 'string')) {
throw new Error('Harness returned invalid session IDs for workspace.list');
}
return workspace;
}
function workspaceSessions(workspace, archivedSessionIds, sessionList) {
if (!Array.isArray(sessionList?.items)) {
throw new Error('Harness returned an invalid response for session.list');
}
const archived = new Set(archivedSessionIds);
const summaries = new Map(sessionList.items.flatMap((item) => (
typeof item?.sessionId === 'string' ? [[item.sessionId, item]] : []
)));
return {
workspace: workspace.path,
sessions: workspace.sessionIds.map((sessionId) => {
const summary = summaries.get(sessionId);
const title = summary?.projections?.values?.title;
return {
sessionId,
title: typeof title === 'string' ? title : null,
archived: archived.has(sessionId),
blank: summary?.blank === true,
origin: summary?.origin === 'subagent' ? 'subagent' : null,
summaryAvailable: summary !== undefined,
};
}),
};
}
function sleep(ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
@ -229,6 +270,15 @@ export class HarnessClient {
return workspacePaths(await this.rpc('workspace.list', {}, 30_000, options));
}
async listWorkspaceSessions(workspacePath, options = {}) {
await this.ensureRunning(options);
const workspaceList = await this.rpc('workspace.list', {}, 30_000, options);
const workspace = workspaceFromList(workspacePath, workspaceList);
if (!workspace) return { workspace: workspacePath, sessions: [] };
const sessionList = await this.rpc('session.list', {}, 30_000, options);
return workspaceSessions(workspace, workspaceList.archivedSessionIds, sessionList);
}
async workspaceId(options = {}) {
const { workspace = this.#workspace, ...rpcOptions } = options;
const { items } = await this.rpc('workspace.list', {}, 30_000, rpcOptions);

View file

@ -15,6 +15,7 @@ const HELP_TEXT = [
'/new 开启一个全新会话',
'/workspace 工作区绝对路径 切换工作区',
'/workspacelist 列出工作区绝对路径',
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
'/status 检查连接状态',
'/help 显示本帮助',
].join('\n');

View file

@ -11,6 +11,47 @@ function workspacePaths(value) {
));
}
function workspaceFromList(workspacePath, workspaceList) {
if (!Array.isArray(workspaceList?.items)
|| !Array.isArray(workspaceList?.archivedSessionIds)) {
throw new Error('Harness returned an invalid response for workspace.list');
}
const workspace = workspaceList.items.find((item) => item?.path === workspacePath);
if (!workspace) return null;
if (!Array.isArray(workspace.sessionIds)
|| workspace.sessionIds.some((sessionId) => typeof sessionId !== 'string')) {
throw new Error('Harness returned invalid session IDs for workspace.list');
}
return workspace;
}
function workspaceSessions(workspace, archivedSessionIds, sessionList) {
if (!Array.isArray(sessionList?.items)) {
throw new Error('Harness returned an invalid response for session.list');
}
const archived = new Set(archivedSessionIds);
const summaries = new Map(sessionList.items.flatMap((item) => (
typeof item?.sessionId === 'string' ? [[item.sessionId, item]] : []
)));
return {
workspace: workspace.path,
sessions: workspace.sessionIds.map((sessionId) => {
const summary = summaries.get(sessionId);
const title = summary?.projections?.values?.title;
return {
sessionId,
title: typeof title === 'string' ? title : null,
archived: archived.has(sessionId),
blank: summary?.blank === true,
origin: summary?.origin === 'subagent' ? 'subagent' : null,
summaryAvailable: summary !== undefined,
};
}),
};
}
function messageText(event) {
return (event?.data?.message?.content ?? [])
.filter((part) => part.type === 'text' && typeof part.text === 'string')
@ -210,6 +251,15 @@ export class HarnessClient {
return workspacePaths(await this.rpc('workspace.list', {}, 30000, options));
}
async listWorkspaceSessions(workspacePath, options = {}) {
await this.ensureRunning();
const workspaceList = await this.rpc('workspace.list', {}, 30000, options);
const workspace = workspaceFromList(workspacePath, workspaceList);
if (!workspace) return { workspace: workspacePath, sessions: [] };
const sessionList = await this.rpc('session.list', {}, 30000, options);
return workspaceSessions(workspace, workspaceList.archivedSessionIds, sessionList);
}
async workspaceId(options = {}) {
const workspace = options.workspace ?? this.#workspace;
const { items } = await this.rpc('workspace.list', {});

View file

@ -8,6 +8,7 @@ const HELP_TEXT = [
'/new 开启一个全新会话',
'/workspace 工作区绝对路径 切换工作区',
'/workspacelist 列出工作区绝对路径',
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
'/status 检查连接状态',
'/help 显示本帮助',
].join('\n');

View file

@ -417,20 +417,21 @@ export function createBotWorkspaceScope(harness, { botId, workspaces, state }) {
}
};
}
if (property === 'listWorkspaces' && typeof target.listWorkspaces === 'function') {
if ((property === 'listWorkspaces' || property === 'listWorkspaceSessions')
&& typeof target[property] === 'function') {
return async (...args) => {
if (!isCurrentScope()) {
const error = new Error('找不到要修改的机器人。');
error.code = 'workspace-bot-not-found';
throw error;
}
const paths = await target.listWorkspaces(...args);
const result = await target[property](...args);
if (!isCurrentScope()) {
const error = new Error('找不到要修改的机器人。');
error.code = 'workspace-bot-not-found';
throw error;
}
return paths;
return result;
};
}
if (property === 'switchWorkspace') {

View file

@ -102,6 +102,7 @@ export class TextHarnessBridge {
'/new 开启一个全新会话',
'/workspace 工作区绝对路径 切换工作区',
'/workspacelist 列出工作区绝对路径',
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
'/status 检查连接状态',
'/help 显示本帮助',
].join('\n'));

View file

@ -1,10 +1,19 @@
import { stat } from 'node:fs/promises';
import { realpath, stat } from 'node:fs/promises';
import { isAbsolute, resolve } from 'node:path';
const WORKSPACE_COMMAND = /^\/workspace(?:\s+([\s\S]+))?$/i;
const WORKSPACE_LIST_COMMAND = /^\/workspacelist(?:\s+([\s\S]+))?$/i;
const SESSION_LIST_COMMAND = /^\/sessionlist(?:\s+([\s\S]+))?$/i;
const MAX_WORKSPACE_PATH_LENGTH = 4_096;
const MAX_COMMAND_MESSAGE_LENGTH = 1_800;
const UNSAFE_DISPLAY_TEXT = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
const UNSAFE_DISPLAY_TEXT_GLOBAL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu;
const SESSION_LIST_USAGE = [
'用法:',
'/sessionlist 列出当前工作区会话',
'/sessionlist 工作区序号 按 /workspacelist 序号列出会话',
'/sessionlist 工作区绝对路径 列出指定工作区会话',
].join('\n');
function commandResult(message, messages = [message]) {
return { handled: true, message, messages };
@ -12,20 +21,66 @@ function commandResult(message, messages = [message]) {
function normalizedWorkspacePath(value) {
if (typeof value !== 'string' || value.length > MAX_WORKSPACE_PATH_LENGTH
|| !isAbsolute(value) || /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(value)) return null;
|| !isAbsolute(value) || UNSAFE_DISPLAY_TEXT.test(value)) return null;
return resolve(value);
}
function safeDisplayText(value) {
if (typeof value !== 'string') return '';
return value.replace(UNSAFE_DISPLAY_TEXT_GLOBAL, ' ').replace(/\s+/gu, ' ').trim();
}
async function existingWorkspacePaths(values) {
const unique = [...new Set(values.map(normalizedWorkspacePath).filter(Boolean))];
const checked = await Promise.all(unique.map(async (workspace) => {
const checked = await Promise.all(values.map(async (value) => {
const workspace = normalizedWorkspacePath(value);
if (!workspace) return null;
try {
return (await stat(workspace)).isDirectory() ? workspace : null;
if (!(await stat(workspace)).isDirectory()) return null;
return normalizedWorkspacePath(await realpath(workspace));
} catch {
return null;
}
}));
return checked.filter(Boolean);
return [...new Set(checked.filter(Boolean))];
}
async function selectedWorkspacePath(value) {
if (typeof value !== 'string' || !isAbsolute(value.trim())) {
return { error: `工作区必须是绝对路径。\n${SESSION_LIST_USAGE}` };
}
const workspace = normalizedWorkspacePath(value.trim());
if (!workspace) {
return { error: `工作区路径包含不支持的字符或长度超过限制。\n${SESSION_LIST_USAGE}` };
}
let info;
try {
info = await stat(workspace);
} catch {
return { error: `工作区路径不存在。\n${SESSION_LIST_USAGE}` };
}
if (!info.isDirectory()) {
return { error: `工作区路径必须指向一个目录。\n${SESSION_LIST_USAGE}` };
}
try {
const canonical = normalizedWorkspacePath(await realpath(workspace));
return canonical
? { workspace: canonical }
: { error: `工作区路径包含不支持的字符或长度超过限制。\n${SESSION_LIST_USAGE}` };
} catch {
return { error: `工作区路径不存在。\n${SESSION_LIST_USAGE}` };
}
}
async function workspacePathSnapshot(harness) {
const listed = await harness.listWorkspaces();
const currentValue = typeof harness?.currentWorkspace === 'function'
? harness.currentWorkspace()
: null;
const [current] = currentValue ? await existingWorkspacePaths([currentValue]) : [];
const registered = await existingWorkspacePaths(Array.isArray(listed) ? listed : []);
const paths = [...new Set([...(current ? [current] : []), ...registered])];
harness.assertWorkspaceScope?.();
return { current: current ?? null, paths };
}
export function splitWorkspaceCommandMessage(message) {
@ -56,15 +111,7 @@ async function runWorkspaceListCommand(match, harness) {
return commandResult('当前机器人暂不支持列出工作区。');
}
try {
const listed = await harness.listWorkspaces();
const current = typeof harness.currentWorkspace === 'function'
? normalizedWorkspacePath(harness.currentWorkspace())
: null;
const paths = await existingWorkspacePaths([
...(current ? [current] : []),
...(Array.isArray(listed) ? listed : []),
]);
harness.assertWorkspaceScope?.();
const { current, paths } = await workspacePathSnapshot(harness);
if (paths.length === 0) {
return commandResult('当前 Harness Host 上没有仍然存在的已登记工作区。');
}
@ -75,6 +122,7 @@ async function runWorkspaceListCommand(match, harness) {
)),
'',
'切换用法:/workspace 工作区绝对路径',
'查看会话:/sessionlist 工作区序号或绝对路径',
];
const message = lines.join('\n');
return commandResult(message, splitWorkspaceCommandMessage(message));
@ -86,9 +134,80 @@ async function runWorkspaceListCommand(match, harness) {
}
}
async function resolveSessionListWorkspace(selector, harness) {
if (!selector) {
if (typeof harness?.currentWorkspace !== 'function') {
return { error: '当前机器人没有可用的工作区。' };
}
const selected = await selectedWorkspacePath(harness.currentWorkspace());
harness.assertWorkspaceScope?.();
return selected;
}
if (/^\d+$/u.test(selector)) {
if (typeof harness?.listWorkspaces !== 'function') {
return { error: '当前机器人暂不支持按序号选择工作区。' };
}
const { paths } = await workspacePathSnapshot(harness);
const position = Number(selector);
if (!Number.isSafeInteger(position) || position < 1 || position > paths.length) {
return { error: '工作区序号不存在,请先执行 /workspacelist。' };
}
return { workspace: paths[position - 1] };
}
const selected = await selectedWorkspacePath(selector);
harness.assertWorkspaceScope?.();
return selected;
}
function sessionListMessage(workspace, sessions) {
const rows = sessions.map((session) => {
const sessionId = safeDisplayText(session?.sessionId);
if (!sessionId) throw new TypeError('Harness returned an invalid session id');
const title = session?.summaryAvailable === false
? '标题暂不可用'
: safeDisplayText(session?.title) || '暂无标题';
return `${title}${session?.archived === true ? '(已归档)' : ''}\n ID: ${sessionId}`;
});
if (rows.length === 0) return `工作区:${workspace}\n该工作区暂无会话。`;
return [
`工作区:${workspace}`,
`会话(${rows.length}):`,
'',
...rows.map((row, index) => `${index + 1}. ${row}`),
].join('\n');
}
async function runSessionListCommand(match, harness) {
if (typeof harness?.listWorkspaceSessions !== 'function') {
return commandResult('当前机器人暂不支持列出工作区会话。');
}
const selector = match[1]?.trim() ?? '';
try {
const resolved = await resolveSessionListWorkspace(selector, harness);
if (resolved.error) return commandResult(resolved.error);
const listed = await harness.listWorkspaceSessions(resolved.workspace);
if (!listed || !Array.isArray(listed.sessions)) {
throw new TypeError('Harness returned an invalid workspace session list');
}
harness.assertWorkspaceScope?.();
const workspace = normalizedWorkspacePath(listed.workspace) ?? resolved.workspace;
const message = sessionListMessage(workspace, listed.sessions);
return commandResult(message, splitWorkspaceCommandMessage(message));
} catch (error) {
if (error?.code === 'workspace-bot-not-found') {
return commandResult('机器人正在移除或已重新接入,无法列出原会话的工作区会话。');
}
return commandResult('暂时无法获取工作区会话列表,请稍后重试。');
}
}
export async function runWorkspaceCommand(text, harness) {
if (typeof text !== 'string') return null;
const command = text.trim();
const sessionListMatch = SESSION_LIST_COMMAND.exec(command);
if (sessionListMatch) return runSessionListCommand(sessionListMatch, harness);
const listMatch = WORKSPACE_LIST_COMMAND.exec(command);
if (listMatch) return runWorkspaceListCommand(listMatch, harness);
const match = WORKSPACE_COMMAND.exec(command);

View file

@ -9,6 +9,7 @@ const HELP_TEXT = [
'/new 开启一个全新会话',
'/workspace 工作区绝对路径 切换工作区',
'/workspacelist 列出工作区绝对路径',
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
'/status 检查连接状态',
'/help 显示本帮助',
].join('\n');

View file

@ -11,6 +11,47 @@ function workspacePaths(value) {
));
}
function workspaceFromList(workspacePath, workspaceList) {
if (!Array.isArray(workspaceList?.items)
|| !Array.isArray(workspaceList?.archivedSessionIds)) {
throw new Error('Harness returned an invalid response for workspace.list');
}
const workspace = workspaceList.items.find((item) => item?.path === workspacePath);
if (!workspace) return null;
if (!Array.isArray(workspace.sessionIds)
|| workspace.sessionIds.some((sessionId) => typeof sessionId !== 'string')) {
throw new Error('Harness returned invalid session IDs for workspace.list');
}
return workspace;
}
function workspaceSessions(workspace, archivedSessionIds, sessionList) {
if (!Array.isArray(sessionList?.items)) {
throw new Error('Harness returned an invalid response for session.list');
}
const archived = new Set(archivedSessionIds);
const summaries = new Map(sessionList.items.flatMap((item) => (
typeof item?.sessionId === 'string' ? [[item.sessionId, item]] : []
)));
return {
workspace: workspace.path,
sessions: workspace.sessionIds.map((sessionId) => {
const summary = summaries.get(sessionId);
const title = summary?.projections?.values?.title;
return {
sessionId,
title: typeof title === 'string' ? title : null,
archived: archived.has(sessionId),
blank: summary?.blank === true,
origin: summary?.origin === 'subagent' ? 'subagent' : null,
summaryAvailable: summary !== undefined,
};
}),
};
}
function assistantMessageText(event) {
return (event?.data?.message?.content ?? [])
.filter((part) => part.type === 'text' && typeof part.text === 'string')
@ -200,6 +241,15 @@ export class HarnessClient {
return workspacePaths(await this.rpc('workspace.list', {}, 30_000, options));
}
async listWorkspaceSessions(workspacePath, options = {}) {
await this.ensureRunning();
const workspaceList = await this.rpc('workspace.list', {}, 30_000, options);
const workspace = workspaceFromList(workspacePath, workspaceList);
if (!workspace) return { workspace: workspacePath, sessions: [] };
const sessionList = await this.rpc('session.list', {}, 30_000, options);
return workspaceSessions(workspace, workspaceList.archivedSessionIds, sessionList);
}
async workspaceId(options = {}) {
const workspace = options.workspace ?? this.#workspace;
const { items } = await this.rpc('workspace.list', {});

View file

@ -13,6 +13,7 @@ const HELP_TEXT = [
'/new 开启一个全新会话',
'/workspace 工作区绝对路径 切换工作区',
'/workspacelist 列出工作区绝对路径',
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
'/status 检查连接状态',
'/help 显示本帮助',
].join('\n');

View file

@ -50,6 +50,122 @@ test('HarnessClient lists only absolute workspace paths and forwards request opt
assert.deepEqual(await client.listWorkspaces(), []);
});
test('HarnessClient lists sessions by workspace accounting 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-session-list',
};
const calls = [];
let invalidWorkspaceResponse = false;
let invalidSessionResponse = false;
client.ensureRunning = async (received) => {
calls.push({ method: 'ensureRunning', options: received });
};
client.rpc = async (method, payload, timeoutMs, rpcOptions) => {
calls.push({ method, payload, timeoutMs, options: rpcOptions });
if (method === 'workspace.list') {
if (invalidWorkspaceResponse) return null;
return {
items: [
{
workspaceId: 'target',
path: '/tmp/target',
sessionIds: ['session-two', 'session-missing', 'session-one'],
},
{ workspaceId: 'other', path: '/tmp/other', sessionIds: ['cwd-only'] },
],
archivedSessionIds: ['session-missing', 'session-one'],
};
}
assert.equal(method, 'session.list');
if (invalidSessionResponse) return null;
return {
items: [
{
sessionId: 'session-one',
blank: false,
cwd: '/tmp/target',
projections: { values: { title: null } },
},
{
sessionId: 'session-two',
blank: true,
origin: 'subagent',
cwd: '/tmp/different',
projections: { values: { title: 'Second session' } },
},
{
sessionId: 'cwd-only',
blank: false,
cwd: '/tmp/target',
projections: { values: { title: 'Must not leak into target' } },
},
],
};
};
assert.deepEqual(await client.listWorkspaceSessions('/tmp/target', options), {
workspace: '/tmp/target',
sessions: [
{
sessionId: 'session-two',
title: 'Second session',
archived: false,
blank: true,
origin: 'subagent',
summaryAvailable: true,
},
{
sessionId: 'session-missing',
title: null,
archived: true,
blank: false,
origin: null,
summaryAvailable: false,
},
{
sessionId: 'session-one',
title: null,
archived: true,
blank: false,
origin: null,
summaryAvailable: true,
},
],
});
assert.deepEqual(calls, [
{ method: 'ensureRunning', options },
{ method: 'workspace.list', payload: {}, timeoutMs: 30_000, options },
{ method: 'session.list', payload: {}, timeoutMs: 30_000, options },
]);
calls.length = 0;
assert.deepEqual(await client.listWorkspaceSessions('/tmp/unregistered'), {
workspace: '/tmp/unregistered',
sessions: [],
});
assert.deepEqual(calls, [
{ method: 'ensureRunning', options: {} },
{ method: 'workspace.list', payload: {}, timeoutMs: 30_000, options: {} },
]);
invalidWorkspaceResponse = true;
await assert.rejects(
client.listWorkspaceSessions('/tmp/target'),
/invalid response for workspace\.list/,
);
invalidWorkspaceResponse = false;
invalidSessionResponse = true;
await assert.rejects(
client.listWorkspaceSessions('/tmp/target'),
/invalid response for session\.list/,
);
});
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

@ -45,6 +45,117 @@ test('HarnessClient lists only absolute workspace paths', async () => {
assert.deepEqual(await client.listWorkspaces(), []);
});
test('HarnessClient lists sessions by workspace accounting in its stored order', async () => {
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/default-workspace',
});
const options = { rpcId: 'feishu-session-list' };
const calls = [];
let invalidWorkspaceResponse = false;
let invalidSessionResponse = false;
client.ensureRunning = async () => { calls.push({ method: 'ensureRunning' }); };
client.rpc = async (method, payload, timeoutMs, rpcOptions) => {
calls.push({ method, payload, timeoutMs, options: rpcOptions });
if (method === 'workspace.list') {
if (invalidWorkspaceResponse) return null;
return {
items: [
{
workspaceId: 'target',
path: '/tmp/target',
sessionIds: ['session-two', 'session-missing', 'session-one'],
},
{ workspaceId: 'other', path: '/tmp/other', sessionIds: ['cwd-only'] },
],
archivedSessionIds: ['session-missing', 'session-one'],
};
}
assert.equal(method, 'session.list');
if (invalidSessionResponse) return null;
return {
items: [
{
sessionId: 'session-one',
blank: false,
cwd: '/tmp/target',
projections: { values: { title: null } },
},
{
sessionId: 'session-two',
blank: true,
origin: 'subagent',
cwd: '/tmp/different',
projections: { values: { title: 'Second session' } },
},
{
sessionId: 'cwd-only',
blank: false,
cwd: '/tmp/target',
projections: { values: { title: 'Must not leak into target' } },
},
],
};
};
assert.deepEqual(await client.listWorkspaceSessions('/tmp/target', options), {
workspace: '/tmp/target',
sessions: [
{
sessionId: 'session-two',
title: 'Second session',
archived: false,
blank: true,
origin: 'subagent',
summaryAvailable: true,
},
{
sessionId: 'session-missing',
title: null,
archived: true,
blank: false,
origin: null,
summaryAvailable: false,
},
{
sessionId: 'session-one',
title: null,
archived: true,
blank: false,
origin: null,
summaryAvailable: true,
},
],
});
assert.deepEqual(calls, [
{ method: 'ensureRunning' },
{ method: 'workspace.list', payload: {}, timeoutMs: 30000, options },
{ method: 'session.list', payload: {}, timeoutMs: 30000, options },
]);
calls.length = 0;
assert.deepEqual(await client.listWorkspaceSessions('/tmp/unregistered'), {
workspace: '/tmp/unregistered',
sessions: [],
});
assert.deepEqual(calls, [
{ method: 'ensureRunning' },
{ method: 'workspace.list', payload: {}, timeoutMs: 30000, options: {} },
]);
invalidWorkspaceResponse = true;
await assert.rejects(
client.listWorkspaceSessions('/tmp/target'),
/invalid response for workspace\.list/,
);
invalidWorkspaceResponse = false;
invalidSessionResponse = true;
await assert.rejects(
client.listWorkspaceSessions('/tmp/target'),
/invalid response for session\.list/,
);
});
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

@ -43,6 +43,117 @@ test('HarnessClient lists only absolute workspace paths', async () => {
assert.deepEqual(await client.listWorkspaces(), []);
});
test('HarnessClient lists sessions by workspace accounting in its stored order', async () => {
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/default-workspace',
});
const options = { rpcId: 'weixin-session-list' };
const calls = [];
let invalidWorkspaceResponse = false;
let invalidSessionResponse = false;
client.ensureRunning = async () => { calls.push({ method: 'ensureRunning' }); };
client.rpc = async (method, payload, timeoutMs, rpcOptions) => {
calls.push({ method, payload, timeoutMs, options: rpcOptions });
if (method === 'workspace.list') {
if (invalidWorkspaceResponse) return null;
return {
items: [
{
workspaceId: 'target',
path: '/tmp/target',
sessionIds: ['session-two', 'session-missing', 'session-one'],
},
{ workspaceId: 'other', path: '/tmp/other', sessionIds: ['cwd-only'] },
],
archivedSessionIds: ['session-missing', 'session-one'],
};
}
assert.equal(method, 'session.list');
if (invalidSessionResponse) return null;
return {
items: [
{
sessionId: 'session-one',
blank: false,
cwd: '/tmp/target',
projections: { values: { title: null } },
},
{
sessionId: 'session-two',
blank: true,
origin: 'subagent',
cwd: '/tmp/different',
projections: { values: { title: 'Second session' } },
},
{
sessionId: 'cwd-only',
blank: false,
cwd: '/tmp/target',
projections: { values: { title: 'Must not leak into target' } },
},
],
};
};
assert.deepEqual(await client.listWorkspaceSessions('/tmp/target', options), {
workspace: '/tmp/target',
sessions: [
{
sessionId: 'session-two',
title: 'Second session',
archived: false,
blank: true,
origin: 'subagent',
summaryAvailable: true,
},
{
sessionId: 'session-missing',
title: null,
archived: true,
blank: false,
origin: null,
summaryAvailable: false,
},
{
sessionId: 'session-one',
title: null,
archived: true,
blank: false,
origin: null,
summaryAvailable: true,
},
],
});
assert.deepEqual(calls, [
{ method: 'ensureRunning' },
{ method: 'workspace.list', payload: {}, timeoutMs: 30_000, options },
{ method: 'session.list', payload: {}, timeoutMs: 30_000, options },
]);
calls.length = 0;
assert.deepEqual(await client.listWorkspaceSessions('/tmp/unregistered'), {
workspace: '/tmp/unregistered',
sessions: [],
});
assert.deepEqual(calls, [
{ method: 'ensureRunning' },
{ method: 'workspace.list', payload: {}, timeoutMs: 30_000, options: {} },
]);
invalidWorkspaceResponse = true;
await assert.rejects(
client.listWorkspaceSessions('/tmp/target'),
/invalid response for workspace\.list/,
);
invalidWorkspaceResponse = false;
invalidSessionResponse = true;
await assert.rejects(
client.listWorkspaceSessions('/tmp/target'),
/invalid response for session\.list/,
);
});
test('reply tracker associates only the Harness turn created by the Weixin prompt RPC', () => {
const tracker = new HarnessReplyTracker({ promptRpcId: 'weixin-prompt', afterSeq: 2 });
const first = tracker.consume([

View file

@ -1,5 +1,5 @@
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
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';
@ -16,6 +16,7 @@ import {
runWorkspaceCommand,
splitWorkspaceCommandMessage,
} from '../src/channels/shared/workspace-command.mjs';
import { TextHarnessBridge } from '../src/channels/shared/text-harness-bridge.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';
@ -32,7 +33,7 @@ import {
} from '../plugin-src/host/channels/shared/rpc.mjs';
async function fixture(t) {
const root = await mkdtemp(join(tmpdir(), 'dsh-im-workspace-'));
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');
@ -786,6 +787,7 @@ test('/workspacelist returns existing absolute paths with the current workspace
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);
@ -845,6 +847,216 @@ test('/workspacelist hides unsafe Unicode paths and rechecks the bot scope', asy
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.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/);
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);
@ -853,7 +1065,7 @@ test('workspace command message splitting bounds a single very long path', () =>
assert.equal(messages.join(''), message);
});
test('all nine channel bridge families advertise and fan out /workspacelist replies', async () => {
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',
@ -865,6 +1077,7 @@ test('all nine channel bridge families advertise and fan out /workspacelist repl
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\]/);
}
});
@ -892,6 +1105,28 @@ test('a stale bot scope cannot finish listing workspaces after same-id rebinding
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],