mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-08 23:13:17 +08:00
feat: bind bot chats to existing harness sessions
This commit is contained in:
parent
76d5430f1f
commit
1af8610cd5
21 changed files with 1807 additions and 130 deletions
18
README.md
18
README.md
|
|
@ -72,16 +72,21 @@ QQ 扫码接入使用腾讯 QQBot v2 官方流程。默认腾讯授权页会把
|
|||
| `/workspace <工作区绝对路径>` | 切换当前机器人的 Harness 工作区。 |
|
||||
| `/workspacelist` | 列出当前 Harness Host 上仍然存在的工作区绝对路径。 |
|
||||
| `/sessionlist [工作区序号或绝对路径]` | 列出指定工作区登记的所有会话 ID 和标题;省略参数时使用当前工作区。 |
|
||||
| `/session <Session ID>` | 将当前聊天绑定到指定的已有 Harness 会话。 |
|
||||
|
||||
示例:`/workspace /Users/alice/projects/my-app`、`/sessionlist`、`/sessionlist 2` 或 `/sessionlist /Users/alice/projects/my-app`
|
||||
示例:`/workspace /Users/alice/projects/my-app`、`/sessionlist 2`、`/sessionlist /Users/alice/projects/my-app` 或 `/session session-id`
|
||||
|
||||
- 只接受已经存在的绝对目录;路径无效时机器人会返回具体提示和正确用法。
|
||||
- `/workspacelist` 不需要参数。它合并 Harness 全局登记项与当前机器人的路径;当前路径仍存在且可安全显示时会排在首位并标记为“当前”。结果可直接复制到 `/workspace` 命令。
|
||||
- `/sessionlist` 的数字参数按命令执行时与 `/workspacelist` 相同的最新顺序解析;也可使用绝对路径直接指定工作区。结果会回显最终选中的路径。
|
||||
- `/sessionlist` 会列出该工作区登记的所有会话。已归档会话会标记为“已归档”;空白会话和子代理会话在它们归属该工作区时也会列出;没有标题的会话显示为“暂无标题”。
|
||||
- `/sessionlist` 会列出该工作区登记的所有会话。已归档会话会标记为“已归档”;空白会话和子代理会话在它们归属该工作区时也会列出;没有标题的会话显示为“暂无标题”。结果中的 ID 可直接用于 `/session Session ID`。
|
||||
- `/session` 只接受一个由 `/sessionlist` 获得的 Session ID。它不会新建会话或立即向模型发送消息;绑定成功后,当前聊天的后续消息会继续该会话。普通归档会话可以绑定但不会自动取消归档,子代理会话不能绑定。
|
||||
- `/session` 会自动定位会话唯一所属的工作区。同工作区绑定只替换当前聊天的映射;跨工作区绑定会切换该机器人的工作区、清除该机器人所有聊天的旧会话映射,再绑定当前聊天,因此会影响该机器人的其他聊天。已经开始生成的回复仍可完成。
|
||||
- 工作区切换和会话绑定只会清除或替换 dsh-im 的聊天映射,不会删除、清空或归档任何旧 Session 内容;旧 Session 仍可再次列出和绑定。
|
||||
- 任何已在对应平台可见范围内、能够正常向机器人发消息的用户都可以执行这些命令,不区分管理员和普通用户。
|
||||
- 工作区列表来自 Harness Host 的全局登记信息,可能包含其他机器人、其他渠道或非 IM 项目的本机绝对路径。请将机器人可见范围限制给可信用户。
|
||||
- 会话列表同样来自该全局 Harness Host;会话 ID 和标题可能属于其他机器人、其他渠道或非 IM 项目,并可能包含敏感元数据。开放命令前请确保所有可见用户都可信。
|
||||
- 任何能执行 `/session` 的用户都能接续所选会话,并通过后续消息写入会话或触发其可用工具。请只向可信用户开放机器人及其会话列表。
|
||||
- 切换成功后只清除当前机器人的旧 Harness 会话映射,不影响其他机器人。
|
||||
- 新工作区对后续消息生效;已经开始生成的回复会继续完成。
|
||||
|
||||
|
|
@ -181,16 +186,21 @@ 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. |
|
||||
| `/session <Session ID>` | Bind the current chat to an existing Harness session. |
|
||||
|
||||
Examples: `/workspace /Users/alice/projects/my-app`, `/sessionlist`, `/sessionlist 2`, or `/sessionlist /Users/alice/projects/my-app`
|
||||
Examples: `/workspace /Users/alice/projects/my-app`, `/sessionlist 2`, `/sessionlist /Users/alice/projects/my-app`, or `/session session-id`
|
||||
|
||||
- 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`.
|
||||
- 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`.
|
||||
- `/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 listed ID can be passed directly to `/session Session ID`.
|
||||
- `/session` accepts exactly one Session ID obtained from `/sessionlist`. It neither creates a session nor immediately prompts the model; later messages in the current chat continue the bound session. Regular archived sessions can be bound without being unarchived, while subagent sessions cannot be bound.
|
||||
- `/session` locates the session's unique workspace automatically. Binding inside the current workspace replaces only this chat's mapping. A cross-workspace binding switches the bot workspace, clears the old session mappings for all of that bot's chats, and then binds this chat, so it affects the bot's other chats. A reply already being generated may still finish.
|
||||
- Workspace switches and session bindings only clear or replace dsh-im chat mappings. They never delete, empty, or archive old Session contents; an old Session can still be listed and bound again.
|
||||
- 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.
|
||||
- Any user who can run `/session` can continue the selected session and use later messages to write to it or invoke its available tools. Expose the bot and session list only to trusted users.
|
||||
- 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.
|
||||
|
||||
|
|
|
|||
214
lib/index.js
214
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -72,6 +72,11 @@ for (const marker of ['/feishu', '/weixin', '/dingtalk', '/wecom', '/qq', '/slac
|
|||
throw new Error(`host bundle does not contain the internal ${marker} RPC provider`);
|
||||
}
|
||||
}
|
||||
for (const marker of ['/session Session ID', 'bindWorkspaceSession', 'session-subagent-unsupported']) {
|
||||
if (!host.includes(marker)) {
|
||||
throw new Error(`host bundle does not contain the Session binding marker: ${marker}`);
|
||||
}
|
||||
}
|
||||
if (/@xmanrui\/dsh-(?:feishu|weixin|dingtalk)/.test(host)) {
|
||||
throw new Error('host bundle still imports an external channel plugin');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const HELP_TEXT = [
|
|||
'/workspace 工作区绝对路径 切换工作区',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
||||
'/session Session ID 将当前聊天绑定到指定会话',
|
||||
'/status 检查连接状态',
|
||||
'/help 显示本帮助',
|
||||
].join('\n');
|
||||
|
|
@ -218,7 +219,7 @@ export class DingtalkHarnessBridge {
|
|||
await this.#send(sessionWebhook, '已开启新会话。请发送你的问题。');
|
||||
return;
|
||||
}
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
|
||||
if (workspaceCommand) {
|
||||
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
||||
await this.#send(sessionWebhook, reply);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { spawn } from 'node:child_process';
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { isAbsolute } from 'node:path';
|
||||
|
||||
import { adoptRegisteredWorkspaceSession } from '../shared/harness-session-binding.mjs';
|
||||
|
||||
function workspacePaths(value) {
|
||||
if (!Array.isArray(value?.items)) return [];
|
||||
return value.items.flatMap((item) => (
|
||||
|
|
@ -279,6 +281,10 @@ export class HarnessClient {
|
|||
return workspaceSessions(workspace, workspaceList.archivedSessionIds, sessionList);
|
||||
}
|
||||
|
||||
async adoptWorkspaceSession(value, options = {}) {
|
||||
return adoptRegisteredWorkspaceSession(this, value, options);
|
||||
}
|
||||
|
||||
async workspaceId(options = {}) {
|
||||
const { workspace = this.#workspace, ...rpcOptions } = options;
|
||||
const { items } = await this.rpc('workspace.list', {}, 30_000, rpcOptions);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ const HELP_TEXT = [
|
|||
'/workspace 工作区绝对路径 切换工作区',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
||||
'/session Session ID 将当前聊天绑定到指定会话',
|
||||
'/status 检查连接状态',
|
||||
'/help 显示本帮助',
|
||||
].join('\n');
|
||||
|
|
@ -111,7 +112,7 @@ export class FeishuHarnessBridge {
|
|||
await this.#send(event.message.chat_id, '飞书机器人与 DeepSeek Harness 连接正常。');
|
||||
return;
|
||||
}
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
|
||||
if (workspaceCommand) {
|
||||
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
||||
await this.#send(event.message.chat_id, reply);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { spawn } from 'node:child_process';
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { isAbsolute } from 'node:path';
|
||||
|
||||
import { adoptRegisteredWorkspaceSession } from '../shared/harness-session-binding.mjs';
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function workspacePaths(value) {
|
||||
|
|
@ -260,6 +262,10 @@ export class HarnessClient {
|
|||
return workspaceSessions(workspace, workspaceList.archivedSessionIds, sessionList);
|
||||
}
|
||||
|
||||
async adoptWorkspaceSession(value, options = {}) {
|
||||
return adoptRegisteredWorkspaceSession(this, value, options, 30000);
|
||||
}
|
||||
|
||||
async workspaceId(options = {}) {
|
||||
const workspace = options.workspace ?? this.#workspace;
|
||||
const { items } = await this.rpc('workspace.list', {});
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ const HELP_TEXT = [
|
|||
'/workspace 工作区绝对路径 切换工作区',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
||||
'/session Session ID 将当前聊天绑定到指定会话',
|
||||
'/status 检查连接状态',
|
||||
'/help 显示本帮助',
|
||||
].join('\n');
|
||||
|
|
@ -127,7 +128,7 @@ export class QqHarnessBridge {
|
|||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
|
||||
if (workspaceCommand) {
|
||||
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
||||
await this.#bot.sendText(target, reply);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,37 @@
|
|||
import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
||||
import {
|
||||
mkdir,
|
||||
readFile,
|
||||
realpath,
|
||||
rename,
|
||||
stat,
|
||||
unlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { dirname, isAbsolute, resolve } from 'node:path';
|
||||
|
||||
import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
|
||||
|
||||
const EMPTY_DOCUMENT = Object.freeze({ version: 1, workspaces: Object.freeze({}) });
|
||||
|
||||
function workspaceSessionStale(message) {
|
||||
const error = new Error(message);
|
||||
error.code = WORKSPACE_SESSION_STALE;
|
||||
return error;
|
||||
}
|
||||
|
||||
async function canonicalWorkspacePath(value) {
|
||||
return resolve(await realpath(value));
|
||||
}
|
||||
|
||||
async function sameWorkspacePath(left, right) {
|
||||
if (left === right) return true;
|
||||
try {
|
||||
return await canonicalWorkspacePath(left) === await canonicalWorkspacePath(right);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function botIdOf(value) {
|
||||
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(value)) {
|
||||
throw new TypeError('Invalid bot id');
|
||||
|
|
@ -179,6 +206,72 @@ export class BotWorkspaceStore {
|
|||
});
|
||||
}
|
||||
|
||||
async bindWorkspaceSession(botId, value, {
|
||||
conversationKey,
|
||||
sessionId,
|
||||
clearSessions,
|
||||
setSession,
|
||||
incarnation,
|
||||
expectedGeneration,
|
||||
} = {}) {
|
||||
const id = botIdOf(botId);
|
||||
if (typeof conversationKey !== 'string' || !conversationKey
|
||||
|| typeof sessionId !== 'string' || !sessionId) {
|
||||
throw new TypeError('conversationKey and sessionId are required');
|
||||
}
|
||||
if (typeof clearSessions !== 'function' || typeof setSession !== 'function') {
|
||||
throw new TypeError('session state callbacks are required');
|
||||
}
|
||||
if (!this.has(id)
|
||||
|| (incarnation !== undefined && incarnation !== this.incarnationFor(id))) {
|
||||
const error = new Error('找不到要修改的机器人。');
|
||||
error.code = 'workspace-bot-not-found';
|
||||
throw error;
|
||||
}
|
||||
const workspace = await canonicalWorkspacePath(await validateWorkspacePath(value));
|
||||
return this.#enqueue(id, async () => {
|
||||
if (!this.has(id)
|
||||
|| (incarnation !== undefined && incarnation !== this.incarnationFor(id))) {
|
||||
const error = new Error('找不到要修改的机器人。');
|
||||
error.code = 'workspace-bot-not-found';
|
||||
throw error;
|
||||
}
|
||||
if (expectedGeneration !== undefined
|
||||
&& expectedGeneration !== this.generationFor(id)) {
|
||||
throw workspaceSessionStale(
|
||||
'The bot workspace changed before the session binding could be committed.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await sameWorkspacePath(workspace, this.workspaceFor(id)))) {
|
||||
const previous = this.#workspaces[id];
|
||||
// Fence every session resolved before this transition, then remove
|
||||
// the old workspace mappings before publishing the new workspace.
|
||||
this.#generations.set(id, this.#freshGeneration());
|
||||
await clearSessions();
|
||||
this.#workspaces[id] = workspace;
|
||||
try {
|
||||
await this.#persist();
|
||||
} catch (error) {
|
||||
// Session mappings stay cleared and the advanced generation stays
|
||||
// fenced. Restoring either could pair an old session with a state
|
||||
// transition whose durable outcome is unknown.
|
||||
this.#workspaces[id] = previous;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// This write remains inside the same bot transition as the workspace
|
||||
// mutation, so another switch or bind cannot interleave between them.
|
||||
await setSession(conversationKey, sessionId);
|
||||
return {
|
||||
workspace,
|
||||
sessionId,
|
||||
generation: this.#generations.get(id),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async invalidateSessions(botId, { clearSessions } = {}) {
|
||||
const id = botIdOf(botId);
|
||||
return this.#enqueue(id, async () => {
|
||||
|
|
@ -447,6 +540,62 @@ export function createBotWorkspaceScope(harness, { botId, workspaces, state }) {
|
|||
});
|
||||
};
|
||||
}
|
||||
if (property === 'bindWorkspaceSession') {
|
||||
return async (conversationKey, sessionId) => {
|
||||
if (typeof conversationKey !== 'string' || !conversationKey
|
||||
|| typeof sessionId !== 'string' || !sessionId) {
|
||||
throw new TypeError('conversationKey and sessionId are required');
|
||||
}
|
||||
if (!isCurrentScope()) {
|
||||
const error = new Error('找不到要修改的机器人。');
|
||||
error.code = 'workspace-bot-not-found';
|
||||
throw error;
|
||||
}
|
||||
if (typeof target.adoptWorkspaceSession !== 'function') {
|
||||
throw new TypeError('Harness does not support adopting workspace sessions');
|
||||
}
|
||||
const expectedGeneration = workspaces.generationFor(botId);
|
||||
const adopted = await target.adoptWorkspaceSession(sessionId);
|
||||
if (!isCurrentScope()) {
|
||||
const error = new Error('找不到要修改的机器人。');
|
||||
error.code = 'workspace-bot-not-found';
|
||||
throw error;
|
||||
}
|
||||
if (expectedGeneration !== workspaces.generationFor(botId)) {
|
||||
throw workspaceSessionStale(
|
||||
'The bot workspace changed while the session was being adopted.',
|
||||
);
|
||||
}
|
||||
if (!adopted || typeof adopted !== 'object'
|
||||
|| adopted.sessionId !== sessionId || typeof adopted.workspace !== 'string') {
|
||||
throw new TypeError('Harness returned an invalid adopted workspace session');
|
||||
}
|
||||
const bound = await workspaces.bindWorkspaceSession(botId, adopted.workspace, {
|
||||
conversationKey,
|
||||
sessionId,
|
||||
clearSessions: () => state.clearSessions(),
|
||||
setSession: (key, selectedSessionId) => state.setSession(key, selectedSessionId),
|
||||
incarnation,
|
||||
expectedGeneration,
|
||||
});
|
||||
if (!isCurrentScope()) {
|
||||
const error = new Error('找不到要修改的机器人。');
|
||||
error.code = 'workspace-bot-not-found';
|
||||
throw error;
|
||||
}
|
||||
if (bound.generation !== workspaces.generationFor(botId)) {
|
||||
throw workspaceSessionStale(
|
||||
'The bot workspace changed before the session binding completed.',
|
||||
);
|
||||
}
|
||||
sessionGenerations.set(sessionId, bound.generation);
|
||||
return {
|
||||
...adopted,
|
||||
workspace: bound.workspace,
|
||||
sessionId: bound.sessionId,
|
||||
};
|
||||
};
|
||||
}
|
||||
if (property === 'createSession') {
|
||||
return async (options = {}) => {
|
||||
await workspaces.whenBotIdle(botId);
|
||||
|
|
@ -464,6 +613,37 @@ export function createBotWorkspaceScope(harness, { botId, workspaces, state }) {
|
|||
return sessionId;
|
||||
};
|
||||
}
|
||||
if (property === 'workspaceSession') {
|
||||
return (sessionId) => {
|
||||
if (typeof sessionId !== 'string' || !sessionId) {
|
||||
throw new TypeError('sessionId is required');
|
||||
}
|
||||
const generation = sessionGenerations.get(sessionId)
|
||||
?? workspaces.generationFor(botId);
|
||||
// Transfer the mutable provenance entry into this immutable handle.
|
||||
// A later handle for the same id captures its own generation instead
|
||||
// of sharing deletion or rebinding state with this call.
|
||||
sessionGenerations.delete(sessionId);
|
||||
const isCurrentSession = () => isCurrentScope()
|
||||
&& generation === workspaces.generationFor(botId);
|
||||
return Object.freeze({
|
||||
sessionId,
|
||||
async sessionExists(...args) {
|
||||
if (!isCurrentSession()) return false;
|
||||
const exists = await target.sessionExists(sessionId, ...args);
|
||||
return isCurrentSession() && exists;
|
||||
},
|
||||
ask(...args) {
|
||||
if (!isCurrentSession()) {
|
||||
throw workspaceSessionStale(
|
||||
'The bot workspace changed before this prompt started.',
|
||||
);
|
||||
}
|
||||
return target.ask(sessionId, ...args);
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
if (property === 'sessionExists') {
|
||||
return (sessionId, ...args) => {
|
||||
if (!isCurrentScope()) return false;
|
||||
|
|
|
|||
110
src/channels/shared/harness-session-binding.mjs
Normal file
110
src/channels/shared/harness-session-binding.mjs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { isAbsolute } from 'node:path';
|
||||
|
||||
const MAX_SESSION_ID_LENGTH = 256;
|
||||
const UNSAFE_SESSION_ID = /[\p{White_Space}\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
|
||||
const UNSAFE_WORKSPACE_PATH = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
|
||||
|
||||
function bindingError(code, message) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function validatedSessionId(value) {
|
||||
if (typeof value !== 'string' || !value || value.length > MAX_SESSION_ID_LENGTH
|
||||
|| UNSAFE_SESSION_ID.test(value)) {
|
||||
throw bindingError('session-id-invalid', 'A non-empty, safe session id is required');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sessionWorkspace(sessionId, value) {
|
||||
if (!Array.isArray(value?.items) || !Array.isArray(value?.archivedSessionIds)
|
||||
|| value.archivedSessionIds.some((id) => typeof id !== 'string' || !id)) {
|
||||
throw new Error('Harness returned an invalid response for workspace.list');
|
||||
}
|
||||
|
||||
const owners = [];
|
||||
for (const workspace of value.items) {
|
||||
if (typeof workspace?.workspaceId !== 'string' || !workspace.workspaceId
|
||||
|| typeof workspace.path !== 'string' || !isAbsolute(workspace.path)
|
||||
|| !Array.isArray(workspace.sessionIds)
|
||||
|| workspace.sessionIds.some((id) => typeof id !== 'string' || !id)) {
|
||||
throw new Error('Harness returned an invalid response for workspace.list');
|
||||
}
|
||||
for (const accountedId of workspace.sessionIds) {
|
||||
if (accountedId === sessionId) owners.push(workspace);
|
||||
}
|
||||
}
|
||||
|
||||
if (owners.length === 0) {
|
||||
throw bindingError('session-not-registered', 'The session is not registered to a Harness workspace');
|
||||
}
|
||||
if (owners.length !== 1) {
|
||||
throw bindingError(
|
||||
'session-workspace-ambiguous',
|
||||
'The session is registered to more than one Harness workspace',
|
||||
);
|
||||
}
|
||||
if (UNSAFE_WORKSPACE_PATH.test(owners[0].path)) {
|
||||
throw new Error('Harness returned an unsafe workspace path for the session');
|
||||
}
|
||||
return {
|
||||
workspace: owners[0],
|
||||
archived: value.archivedSessionIds.includes(sessionId),
|
||||
};
|
||||
}
|
||||
|
||||
function sessionSummary(sessionId, value) {
|
||||
if (!Array.isArray(value?.items)
|
||||
|| value.items.some((item) => typeof item?.sessionId !== 'string' || !item.sessionId)) {
|
||||
throw new Error('Harness returned an invalid response for session.list');
|
||||
}
|
||||
const matches = value.items.filter((item) => item.sessionId === sessionId);
|
||||
if (matches.length === 0) {
|
||||
throw bindingError('session-summary-unavailable', 'The session is no longer available from Harness');
|
||||
}
|
||||
if (matches.length !== 1) {
|
||||
throw new Error('Harness returned duplicate session summaries for session.list');
|
||||
}
|
||||
|
||||
const [summary] = matches;
|
||||
if (summary.origin === 'subagent') {
|
||||
throw bindingError(
|
||||
'session-subagent-unsupported',
|
||||
'Subagent sessions cannot be adopted as a bot conversation',
|
||||
);
|
||||
}
|
||||
if (summary.origin !== undefined) {
|
||||
throw new Error('Harness returned an invalid session origin for session.list');
|
||||
}
|
||||
const title = summary.projections?.values?.title;
|
||||
if (title !== undefined && title !== null && typeof title !== 'string') {
|
||||
throw new Error('Harness returned an invalid session title for session.list');
|
||||
}
|
||||
return { title: typeof title === 'string' ? title : null };
|
||||
}
|
||||
|
||||
export async function adoptRegisteredWorkspaceSession(client, value, options = {}, timeoutMs = 30_000) {
|
||||
const sessionId = validatedSessionId(value);
|
||||
await client.ensureRunning(options);
|
||||
const workspaceList = await client.rpc('workspace.list', {}, timeoutMs, options);
|
||||
const { workspace, archived } = sessionWorkspace(sessionId, workspaceList);
|
||||
const summary = sessionSummary(
|
||||
sessionId,
|
||||
await client.rpc('session.list', {}, timeoutMs, options),
|
||||
);
|
||||
const adopted = await client.rpc('session.create', {
|
||||
workspaceId: workspace.workspaceId,
|
||||
sessionId,
|
||||
}, timeoutMs, options);
|
||||
if (!adopted || adopted.sessionId !== sessionId) {
|
||||
throw new Error('Harness returned an invalid response for session.create');
|
||||
}
|
||||
return {
|
||||
sessionId,
|
||||
workspace: workspace.path,
|
||||
title: summary.title,
|
||||
archived,
|
||||
};
|
||||
}
|
||||
|
|
@ -103,6 +103,7 @@ export class TextHarnessBridge {
|
|||
'/workspace 工作区绝对路径 切换工作区',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
||||
'/session Session ID 将当前聊天绑定到指定会话',
|
||||
'/status 检查连接状态',
|
||||
'/help 显示本帮助',
|
||||
].join('\n'));
|
||||
|
|
@ -115,7 +116,8 @@ export class TextHarnessBridge {
|
|||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
|
||||
const conversationKey = `${message.kind}:${message.conversationId}`;
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness, conversationKey);
|
||||
if (workspaceCommand) {
|
||||
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
||||
await this.#bot.sendText(target, reply);
|
||||
|
|
@ -123,7 +125,6 @@ export class TextHarnessBridge {
|
|||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
const conversationKey = `${message.kind}:${message.conversationId}`;
|
||||
if (command === '/new') {
|
||||
await this.#state.clearSession(conversationKey);
|
||||
await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
import { realpath, stat } from 'node:fs/promises';
|
||||
import { isAbsolute, resolve } from 'node:path';
|
||||
|
||||
import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
|
||||
|
||||
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 SESSION_BIND_PREFIX = /^\/session(?=$|\s)/i;
|
||||
const SESSION_BIND_COMMAND = /^\/session[ \t]+([^\s]+)$/i;
|
||||
const MAX_WORKSPACE_PATH_LENGTH = 4_096;
|
||||
const MAX_COMMAND_MESSAGE_LENGTH = 1_800;
|
||||
const MAX_SESSION_ID_LENGTH = 256;
|
||||
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_BIND_USAGE = '用法:/session Session ID';
|
||||
const SESSION_LIST_USAGE = [
|
||||
'用法:',
|
||||
'/sessionlist 列出当前工作区会话',
|
||||
|
|
@ -30,6 +36,14 @@ function safeDisplayText(value) {
|
|||
return value.replace(UNSAFE_DISPLAY_TEXT_GLOBAL, ' ').replace(/\s+/gu, ' ').trim();
|
||||
}
|
||||
|
||||
function validSessionId(value) {
|
||||
return typeof value === 'string'
|
||||
&& value.length > 0
|
||||
&& value.length <= MAX_SESSION_ID_LENGTH
|
||||
&& !/\p{White_Space}/u.test(value)
|
||||
&& !UNSAFE_DISPLAY_TEXT.test(value);
|
||||
}
|
||||
|
||||
async function existingWorkspacePaths(values) {
|
||||
const checked = await Promise.all(values.map(async (value) => {
|
||||
const workspace = normalizedWorkspacePath(value);
|
||||
|
|
@ -176,6 +190,8 @@ function sessionListMessage(workspace, sessions) {
|
|||
`会话(${rows.length}):`,
|
||||
'',
|
||||
...rows.map((row, index) => `${index + 1}. ${row}`),
|
||||
'',
|
||||
'绑定用法:/session Session ID',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
|
|
@ -203,9 +219,70 @@ async function runSessionListCommand(match, harness) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function runWorkspaceCommand(text, harness) {
|
||||
function sessionBindErrorMessage(error) {
|
||||
if (error?.code === 'session-id-invalid') {
|
||||
return `Session ID 格式无效。\n${SESSION_BIND_USAGE}`;
|
||||
}
|
||||
if (['session-not-registered', 'session-not-found'].includes(error?.code)) {
|
||||
return '未找到该会话,请先执行 /sessionlist 确认 Session ID。';
|
||||
}
|
||||
if (error?.code === 'session-subagent-unsupported') {
|
||||
return '子代理会话不能绑定到机器人对话,请选择普通会话。';
|
||||
}
|
||||
if (error?.code === 'session-workspace-ambiguous') {
|
||||
return '该会话的工作区归属不明确,暂时无法绑定。';
|
||||
}
|
||||
if (error?.code === 'session-summary-unavailable') {
|
||||
return '暂时无法读取该会话的信息,请稍后重试。';
|
||||
}
|
||||
if (error?.code === 'workspace-bot-not-found') {
|
||||
return '机器人正在移除或已重新接入,无法绑定原对话的会话。';
|
||||
}
|
||||
if ([WORKSPACE_SESSION_STALE, 'agent-busy', 'session-conflict', 'workspace-conflict']
|
||||
.includes(error?.code)) {
|
||||
return '工作区或会话状态已发生变化,请重试。';
|
||||
}
|
||||
return '暂时无法绑定会话,请稍后重试。';
|
||||
}
|
||||
|
||||
async function runSessionBindCommand(command, harness, conversationKey) {
|
||||
const match = SESSION_BIND_COMMAND.exec(command);
|
||||
const sessionId = match?.[1];
|
||||
if (!validSessionId(sessionId)) return commandResult(SESSION_BIND_USAGE);
|
||||
if (typeof harness?.bindWorkspaceSession !== 'function') {
|
||||
return commandResult('当前机器人暂不支持绑定已有会话。');
|
||||
}
|
||||
if (typeof conversationKey !== 'string' || !conversationKey) {
|
||||
return commandResult('当前消息缺少可绑定的会话上下文。');
|
||||
}
|
||||
try {
|
||||
const bound = await harness.bindWorkspaceSession(conversationKey, sessionId);
|
||||
harness.assertWorkspaceScope?.();
|
||||
const workspace = normalizedWorkspacePath(bound?.workspace);
|
||||
const boundSessionId = safeDisplayText(bound?.sessionId);
|
||||
if (!workspace || !boundSessionId) {
|
||||
throw new TypeError('Harness returned an invalid bound session');
|
||||
}
|
||||
const title = safeDisplayText(bound?.title) || '暂无标题';
|
||||
const message = [
|
||||
'当前聊天已绑定会话:',
|
||||
`工作区:${workspace}`,
|
||||
`标题:${title}`,
|
||||
`ID:${boundSessionId}`,
|
||||
`归档:${bound?.archived === true ? '是' : '否'}`,
|
||||
].join('\n');
|
||||
return commandResult(message, splitWorkspaceCommandMessage(message));
|
||||
} catch (error) {
|
||||
return commandResult(sessionBindErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
export async function runWorkspaceCommand(text, harness, conversationKey) {
|
||||
if (typeof text !== 'string') return null;
|
||||
const command = text.trim();
|
||||
if (SESSION_BIND_PREFIX.test(command)) {
|
||||
return runSessionBindCommand(command, harness, conversationKey);
|
||||
}
|
||||
const sessionListMatch = SESSION_LIST_COMMAND.exec(command);
|
||||
if (sessionListMatch) return runSessionListCommand(sessionListMatch, harness);
|
||||
const listMatch = WORKSPACE_LIST_COMMAND.exec(command);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,20 @@
|
|||
export const WORKSPACE_SESSION_STALE = 'workspace-session-stale';
|
||||
|
||||
async function sessionExists(harness, sessionId, options) {
|
||||
function workspaceSession(harness, sessionId) {
|
||||
if (typeof harness.workspaceSession === 'function') {
|
||||
return harness.workspaceSession(sessionId);
|
||||
}
|
||||
return Object.freeze({
|
||||
sessionId,
|
||||
sessionExists: (...args) => harness.sessionExists(sessionId, ...args),
|
||||
ask: (...args) => harness.ask(sessionId, ...args),
|
||||
});
|
||||
}
|
||||
|
||||
async function sessionExists(session, options) {
|
||||
return options === undefined
|
||||
? harness.sessionExists(sessionId)
|
||||
: harness.sessionExists(sessionId, options);
|
||||
? session.sessionExists()
|
||||
: session.sessionExists(options);
|
||||
}
|
||||
|
||||
async function createSession(harness, options) {
|
||||
|
|
@ -27,15 +38,17 @@ export async function askInWorkspaceSession({
|
|||
askOptions,
|
||||
}) {
|
||||
while (true) {
|
||||
let sessionId = state.sessionFor(key);
|
||||
if (!sessionId || !(await sessionExists(harness, sessionId, existsOptions))) {
|
||||
sessionId = await createSession(harness, createOptions);
|
||||
if (await state.setSession(key, sessionId) === false) continue;
|
||||
}
|
||||
try {
|
||||
let sessionId = state.sessionFor(key);
|
||||
let session = sessionId ? workspaceSession(harness, sessionId) : null;
|
||||
if (!session || !(await sessionExists(session, existsOptions))) {
|
||||
sessionId = await createSession(harness, createOptions);
|
||||
if (await state.setSession(key, sessionId) === false) continue;
|
||||
session = workspaceSession(harness, sessionId);
|
||||
}
|
||||
return {
|
||||
sessionId,
|
||||
answer: await harness.ask(sessionId, text, askOptions),
|
||||
answer: await session.ask(text, askOptions),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error?.code !== WORKSPACE_SESSION_STALE) throw error;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const HELP_TEXT = [
|
|||
'/workspace 工作区绝对路径 切换工作区',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
||||
'/session Session ID 将当前聊天绑定到指定会话',
|
||||
'/status 检查连接状态',
|
||||
'/help 显示本帮助',
|
||||
].join('\n');
|
||||
|
|
@ -187,7 +188,7 @@ export class WecomHarnessBridge {
|
|||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
|
||||
if (workspaceCommand) {
|
||||
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
||||
await this.#sendImmediate(frame, chatId, reply);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { spawn } from 'node:child_process';
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { isAbsolute } from 'node:path';
|
||||
|
||||
import { adoptRegisteredWorkspaceSession } from '../shared/harness-session-binding.mjs';
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function workspacePaths(value) {
|
||||
|
|
@ -250,6 +252,10 @@ export class HarnessClient {
|
|||
return workspaceSessions(workspace, workspaceList.archivedSessionIds, sessionList);
|
||||
}
|
||||
|
||||
async adoptWorkspaceSession(value, options = {}) {
|
||||
return adoptRegisteredWorkspaceSession(this, value, options);
|
||||
}
|
||||
|
||||
async workspaceId(options = {}) {
|
||||
const workspace = options.workspace ?? this.#workspace;
|
||||
const { items } = await this.rpc('workspace.list', {});
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const HELP_TEXT = [
|
|||
'/workspace 工作区绝对路径 切换工作区',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
||||
'/session Session ID 将当前聊天绑定到指定会话',
|
||||
'/status 检查连接状态',
|
||||
'/help 显示本帮助',
|
||||
].join('\n');
|
||||
|
|
@ -121,6 +122,7 @@ export class WeixinHarnessBridge {
|
|||
}
|
||||
|
||||
const command = text.trim().toLowerCase();
|
||||
const key = conversationKey(sender);
|
||||
if (command === '/help') {
|
||||
await this.#send(sender, HELP_TEXT, contextToken, runId);
|
||||
await this.#state.markSeen(messageId);
|
||||
|
|
@ -133,12 +135,12 @@ export class WeixinHarnessBridge {
|
|||
return;
|
||||
}
|
||||
if (command === '/new') {
|
||||
await this.#state.clearSession(conversationKey(sender));
|
||||
await this.#state.clearSession(key);
|
||||
await this.#send(sender, '已开启新会话。请发送你的问题。', contextToken, runId);
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
|
||||
if (workspaceCommand) {
|
||||
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
||||
await this.#send(sender, reply, contextToken, runId);
|
||||
|
|
@ -147,7 +149,6 @@ export class WeixinHarnessBridge {
|
|||
return;
|
||||
}
|
||||
|
||||
const key = conversationKey(sender);
|
||||
const { answer } = await askInWorkspaceSession({
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
|
|
|
|||
|
|
@ -166,6 +166,162 @@ test('HarnessClient lists sessions by workspace accounting and forwards request
|
|||
);
|
||||
});
|
||||
|
||||
test('HarnessClient adopts one registered ordinary session and forwards request options', async () => {
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:3080',
|
||||
workspace: '/tmp/default-workspace',
|
||||
agentPreset: 'custom-preset',
|
||||
});
|
||||
const options = {
|
||||
signal: new AbortController().signal,
|
||||
rpcId: 'dingtalk-session-adopt',
|
||||
};
|
||||
const calls = [];
|
||||
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') {
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
workspaceId: 'workspace-target',
|
||||
path: '/tmp/target',
|
||||
sessionIds: ['session-other', 'session-target'],
|
||||
},
|
||||
{ workspaceId: 'workspace-unsafe', path: '/tmp/unsafe\u202e', sessionIds: [] },
|
||||
{ workspaceId: 'workspace-other', path: '/tmp/other', sessionIds: [] },
|
||||
],
|
||||
archivedSessionIds: ['session-target'],
|
||||
};
|
||||
}
|
||||
if (method === 'session.list') {
|
||||
return {
|
||||
items: [{
|
||||
sessionId: 'session-target',
|
||||
projections: { values: { title: 'Existing conversation' } },
|
||||
}],
|
||||
};
|
||||
}
|
||||
assert.equal(method, 'session.create');
|
||||
return { sessionId: 'session-target', agentPreset: 'persisted-preset' };
|
||||
};
|
||||
|
||||
assert.deepEqual(await client.adoptWorkspaceSession('session-target', options), {
|
||||
sessionId: 'session-target',
|
||||
workspace: '/tmp/target',
|
||||
title: 'Existing conversation',
|
||||
archived: true,
|
||||
});
|
||||
assert.deepEqual(calls, [
|
||||
{ method: 'ensureRunning', options },
|
||||
{ method: 'workspace.list', payload: {}, timeoutMs: 30_000, options },
|
||||
{ method: 'session.list', payload: {}, timeoutMs: 30_000, options },
|
||||
{
|
||||
method: 'session.create',
|
||||
payload: { workspaceId: 'workspace-target', sessionId: 'session-target' },
|
||||
timeoutMs: 30_000,
|
||||
options,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('HarnessClient safely rejects invalid, unregistered, ambiguous, and subagent adoption', async () => {
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:3080',
|
||||
workspace: '/tmp/default-workspace',
|
||||
});
|
||||
let mode = 'unregistered';
|
||||
let createCalls = 0;
|
||||
client.ensureRunning = async () => undefined;
|
||||
client.rpc = async (method) => {
|
||||
if (method === 'workspace.list') {
|
||||
if (mode === 'invalid-workspaces') return { items: null, archivedSessionIds: [] };
|
||||
if (mode === 'unregistered') {
|
||||
return {
|
||||
items: [{ workspaceId: 'workspace', path: '/tmp/workspace', sessionIds: [] }],
|
||||
archivedSessionIds: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
workspaceId: 'workspace-one',
|
||||
path: '/tmp/one',
|
||||
sessionIds: ['session-target'],
|
||||
},
|
||||
...(mode === 'ambiguous' ? [{
|
||||
workspaceId: 'workspace-two',
|
||||
path: '/tmp/two',
|
||||
sessionIds: ['session-target'],
|
||||
}] : []),
|
||||
],
|
||||
archivedSessionIds: [],
|
||||
};
|
||||
}
|
||||
if (method === 'session.list') {
|
||||
if (mode === 'summary-missing') return { items: [] };
|
||||
return {
|
||||
items: [{
|
||||
sessionId: 'session-target',
|
||||
...(mode === 'subagent' ? { origin: 'subagent' } : {}),
|
||||
}],
|
||||
};
|
||||
}
|
||||
createCalls += 1;
|
||||
return mode === 'bad-create'
|
||||
? { sessionId: 'different-session' }
|
||||
: { sessionId: 'session-target' };
|
||||
};
|
||||
|
||||
for (const invalid of [
|
||||
undefined,
|
||||
'',
|
||||
' ',
|
||||
'session target',
|
||||
'session\u0000target',
|
||||
's'.repeat(257),
|
||||
]) {
|
||||
await assert.rejects(
|
||||
client.adoptWorkspaceSession(invalid),
|
||||
(error) => error?.code === 'session-id-invalid',
|
||||
);
|
||||
}
|
||||
await assert.rejects(
|
||||
client.adoptWorkspaceSession('session-target'),
|
||||
(error) => error?.code === 'session-not-registered',
|
||||
);
|
||||
mode = 'ambiguous';
|
||||
await assert.rejects(
|
||||
client.adoptWorkspaceSession('session-target'),
|
||||
(error) => error?.code === 'session-workspace-ambiguous',
|
||||
);
|
||||
mode = 'summary-missing';
|
||||
await assert.rejects(
|
||||
client.adoptWorkspaceSession('session-target'),
|
||||
(error) => error?.code === 'session-summary-unavailable',
|
||||
);
|
||||
mode = 'subagent';
|
||||
await assert.rejects(
|
||||
client.adoptWorkspaceSession('session-target'),
|
||||
(error) => error?.code === 'session-subagent-unsupported',
|
||||
);
|
||||
assert.equal(createCalls, 0);
|
||||
|
||||
mode = 'invalid-workspaces';
|
||||
await assert.rejects(
|
||||
client.adoptWorkspaceSession('session-target'),
|
||||
/invalid response for workspace\.list/,
|
||||
);
|
||||
mode = 'bad-create';
|
||||
await assert.rejects(
|
||||
client.adoptWorkspaceSession('session-target'),
|
||||
/invalid response for session\.create/,
|
||||
);
|
||||
assert.equal(createCalls, 1);
|
||||
});
|
||||
|
||||
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([
|
||||
|
|
|
|||
|
|
@ -156,6 +156,162 @@ test('HarnessClient lists sessions by workspace accounting in its stored order',
|
|||
);
|
||||
});
|
||||
|
||||
test('HarnessClient adopts one registered ordinary session without changing its preset', async () => {
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:3080',
|
||||
workspace: '/tmp/default-workspace',
|
||||
agentPreset: 'custom-preset',
|
||||
});
|
||||
const options = {
|
||||
signal: new AbortController().signal,
|
||||
rpcId: 'feishu-session-adopt',
|
||||
};
|
||||
const calls = [];
|
||||
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') {
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
workspaceId: 'workspace-target',
|
||||
path: '/tmp/target',
|
||||
sessionIds: ['session-other', 'session-target'],
|
||||
},
|
||||
{ workspaceId: 'workspace-unsafe', path: '/tmp/unsafe\u202e', sessionIds: [] },
|
||||
{ workspaceId: 'workspace-other', path: '/tmp/other', sessionIds: [] },
|
||||
],
|
||||
archivedSessionIds: ['session-target'],
|
||||
};
|
||||
}
|
||||
if (method === 'session.list') {
|
||||
return {
|
||||
items: [{
|
||||
sessionId: 'session-target',
|
||||
projections: { values: { title: 'Existing conversation' } },
|
||||
}],
|
||||
};
|
||||
}
|
||||
assert.equal(method, 'session.create');
|
||||
return { sessionId: 'session-target', agentPreset: 'persisted-preset' };
|
||||
};
|
||||
|
||||
assert.deepEqual(await client.adoptWorkspaceSession('session-target', options), {
|
||||
sessionId: 'session-target',
|
||||
workspace: '/tmp/target',
|
||||
title: 'Existing conversation',
|
||||
archived: true,
|
||||
});
|
||||
assert.deepEqual(calls, [
|
||||
{ method: 'ensureRunning', options },
|
||||
{ method: 'workspace.list', payload: {}, timeoutMs: 30000, options },
|
||||
{ method: 'session.list', payload: {}, timeoutMs: 30000, options },
|
||||
{
|
||||
method: 'session.create',
|
||||
payload: { workspaceId: 'workspace-target', sessionId: 'session-target' },
|
||||
timeoutMs: 30000,
|
||||
options,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('HarnessClient safely rejects invalid, unregistered, ambiguous, and subagent adoption', async () => {
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:3080',
|
||||
workspace: '/tmp/default-workspace',
|
||||
});
|
||||
let mode = 'unregistered';
|
||||
let createCalls = 0;
|
||||
client.ensureRunning = async () => undefined;
|
||||
client.rpc = async (method) => {
|
||||
if (method === 'workspace.list') {
|
||||
if (mode === 'invalid-workspaces') return { items: null, archivedSessionIds: [] };
|
||||
if (mode === 'unregistered') {
|
||||
return {
|
||||
items: [{ workspaceId: 'workspace', path: '/tmp/workspace', sessionIds: [] }],
|
||||
archivedSessionIds: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
workspaceId: 'workspace-one',
|
||||
path: '/tmp/one',
|
||||
sessionIds: ['session-target'],
|
||||
},
|
||||
...(mode === 'ambiguous' ? [{
|
||||
workspaceId: 'workspace-two',
|
||||
path: '/tmp/two',
|
||||
sessionIds: ['session-target'],
|
||||
}] : []),
|
||||
],
|
||||
archivedSessionIds: [],
|
||||
};
|
||||
}
|
||||
if (method === 'session.list') {
|
||||
if (mode === 'summary-missing') return { items: [] };
|
||||
return {
|
||||
items: [{
|
||||
sessionId: 'session-target',
|
||||
...(mode === 'subagent' ? { origin: 'subagent' } : {}),
|
||||
}],
|
||||
};
|
||||
}
|
||||
createCalls += 1;
|
||||
return mode === 'bad-create'
|
||||
? { sessionId: 'different-session' }
|
||||
: { sessionId: 'session-target' };
|
||||
};
|
||||
|
||||
for (const invalid of [
|
||||
undefined,
|
||||
'',
|
||||
' ',
|
||||
'session target',
|
||||
'session\u0000target',
|
||||
's'.repeat(257),
|
||||
]) {
|
||||
await assert.rejects(
|
||||
client.adoptWorkspaceSession(invalid),
|
||||
(error) => error?.code === 'session-id-invalid',
|
||||
);
|
||||
}
|
||||
await assert.rejects(
|
||||
client.adoptWorkspaceSession('session-target'),
|
||||
(error) => error?.code === 'session-not-registered',
|
||||
);
|
||||
mode = 'ambiguous';
|
||||
await assert.rejects(
|
||||
client.adoptWorkspaceSession('session-target'),
|
||||
(error) => error?.code === 'session-workspace-ambiguous',
|
||||
);
|
||||
mode = 'summary-missing';
|
||||
await assert.rejects(
|
||||
client.adoptWorkspaceSession('session-target'),
|
||||
(error) => error?.code === 'session-summary-unavailable',
|
||||
);
|
||||
mode = 'subagent';
|
||||
await assert.rejects(
|
||||
client.adoptWorkspaceSession('session-target'),
|
||||
(error) => error?.code === 'session-subagent-unsupported',
|
||||
);
|
||||
assert.equal(createCalls, 0);
|
||||
|
||||
mode = 'invalid-workspaces';
|
||||
await assert.rejects(
|
||||
client.adoptWorkspaceSession('session-target'),
|
||||
/invalid response for workspace\.list/,
|
||||
);
|
||||
mode = 'bad-create';
|
||||
await assert.rejects(
|
||||
client.adoptWorkspaceSession('session-target'),
|
||||
/invalid response for session\.create/,
|
||||
);
|
||||
assert.equal(createCalls, 1);
|
||||
});
|
||||
|
||||
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) => {
|
||||
|
|
|
|||
|
|
@ -154,6 +154,162 @@ test('HarnessClient lists sessions by workspace accounting in its stored order',
|
|||
);
|
||||
});
|
||||
|
||||
test('HarnessClient adopts one registered ordinary session without changing its preset', async () => {
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:3080',
|
||||
workspace: '/tmp/default-workspace',
|
||||
agentPreset: 'custom-preset',
|
||||
});
|
||||
const options = {
|
||||
signal: new AbortController().signal,
|
||||
rpcId: 'weixin-session-adopt',
|
||||
};
|
||||
const calls = [];
|
||||
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') {
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
workspaceId: 'workspace-target',
|
||||
path: '/tmp/target',
|
||||
sessionIds: ['session-other', 'session-target'],
|
||||
},
|
||||
{ workspaceId: 'workspace-unsafe', path: '/tmp/unsafe\u202e', sessionIds: [] },
|
||||
{ workspaceId: 'workspace-other', path: '/tmp/other', sessionIds: [] },
|
||||
],
|
||||
archivedSessionIds: ['session-target'],
|
||||
};
|
||||
}
|
||||
if (method === 'session.list') {
|
||||
return {
|
||||
items: [{
|
||||
sessionId: 'session-target',
|
||||
projections: { values: { title: 'Existing conversation' } },
|
||||
}],
|
||||
};
|
||||
}
|
||||
assert.equal(method, 'session.create');
|
||||
return { sessionId: 'session-target', agentPreset: 'persisted-preset' };
|
||||
};
|
||||
|
||||
assert.deepEqual(await client.adoptWorkspaceSession('session-target', options), {
|
||||
sessionId: 'session-target',
|
||||
workspace: '/tmp/target',
|
||||
title: 'Existing conversation',
|
||||
archived: true,
|
||||
});
|
||||
assert.deepEqual(calls, [
|
||||
{ method: 'ensureRunning', options },
|
||||
{ method: 'workspace.list', payload: {}, timeoutMs: 30_000, options },
|
||||
{ method: 'session.list', payload: {}, timeoutMs: 30_000, options },
|
||||
{
|
||||
method: 'session.create',
|
||||
payload: { workspaceId: 'workspace-target', sessionId: 'session-target' },
|
||||
timeoutMs: 30_000,
|
||||
options,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('HarnessClient safely rejects invalid, unregistered, ambiguous, and subagent adoption', async () => {
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:3080',
|
||||
workspace: '/tmp/default-workspace',
|
||||
});
|
||||
let mode = 'unregistered';
|
||||
let createCalls = 0;
|
||||
client.ensureRunning = async () => undefined;
|
||||
client.rpc = async (method) => {
|
||||
if (method === 'workspace.list') {
|
||||
if (mode === 'invalid-workspaces') return { items: null, archivedSessionIds: [] };
|
||||
if (mode === 'unregistered') {
|
||||
return {
|
||||
items: [{ workspaceId: 'workspace', path: '/tmp/workspace', sessionIds: [] }],
|
||||
archivedSessionIds: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
workspaceId: 'workspace-one',
|
||||
path: '/tmp/one',
|
||||
sessionIds: ['session-target'],
|
||||
},
|
||||
...(mode === 'ambiguous' ? [{
|
||||
workspaceId: 'workspace-two',
|
||||
path: '/tmp/two',
|
||||
sessionIds: ['session-target'],
|
||||
}] : []),
|
||||
],
|
||||
archivedSessionIds: [],
|
||||
};
|
||||
}
|
||||
if (method === 'session.list') {
|
||||
if (mode === 'summary-missing') return { items: [] };
|
||||
return {
|
||||
items: [{
|
||||
sessionId: 'session-target',
|
||||
...(mode === 'subagent' ? { origin: 'subagent' } : {}),
|
||||
}],
|
||||
};
|
||||
}
|
||||
createCalls += 1;
|
||||
return mode === 'bad-create'
|
||||
? { sessionId: 'different-session' }
|
||||
: { sessionId: 'session-target' };
|
||||
};
|
||||
|
||||
for (const invalid of [
|
||||
undefined,
|
||||
'',
|
||||
' ',
|
||||
'session target',
|
||||
'session\u0000target',
|
||||
's'.repeat(257),
|
||||
]) {
|
||||
await assert.rejects(
|
||||
client.adoptWorkspaceSession(invalid),
|
||||
(error) => error?.code === 'session-id-invalid',
|
||||
);
|
||||
}
|
||||
await assert.rejects(
|
||||
client.adoptWorkspaceSession('session-target'),
|
||||
(error) => error?.code === 'session-not-registered',
|
||||
);
|
||||
mode = 'ambiguous';
|
||||
await assert.rejects(
|
||||
client.adoptWorkspaceSession('session-target'),
|
||||
(error) => error?.code === 'session-workspace-ambiguous',
|
||||
);
|
||||
mode = 'summary-missing';
|
||||
await assert.rejects(
|
||||
client.adoptWorkspaceSession('session-target'),
|
||||
(error) => error?.code === 'session-summary-unavailable',
|
||||
);
|
||||
mode = 'subagent';
|
||||
await assert.rejects(
|
||||
client.adoptWorkspaceSession('session-target'),
|
||||
(error) => error?.code === 'session-subagent-unsupported',
|
||||
);
|
||||
assert.equal(createCalls, 0);
|
||||
|
||||
mode = 'invalid-workspaces';
|
||||
await assert.rejects(
|
||||
client.adoptWorkspaceSession('session-target'),
|
||||
/invalid response for workspace\.list/,
|
||||
);
|
||||
mode = 'bad-create';
|
||||
await assert.rejects(
|
||||
client.adoptWorkspaceSession('session-target'),
|
||||
/invalid response for session\.create/,
|
||||
);
|
||||
assert.equal(createCalls, 1);
|
||||
});
|
||||
|
||||
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([
|
||||
|
|
|
|||
175
test/session-bind-command.test.mjs
Normal file
175
test/session-bind-command.test.mjs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
import { TextHarnessBridge } from '../src/channels/shared/text-harness-bridge.mjs';
|
||||
import { runWorkspaceCommand } from '../src/channels/shared/workspace-command.mjs';
|
||||
|
||||
test('/session binds exactly one safe Session ID to the current conversation', async () => {
|
||||
const calls = [];
|
||||
const result = await runWorkspaceCommand('/SESSION session-123', {
|
||||
async bindWorkspaceSession(key, sessionId) {
|
||||
calls.push({ key, sessionId });
|
||||
return {
|
||||
sessionId,
|
||||
workspace: '/workspace/project',
|
||||
title: '安全标题\u202e伪造\n下一行',
|
||||
archived: true,
|
||||
};
|
||||
},
|
||||
}, 'direct:conversation-1');
|
||||
|
||||
assert.deepEqual(calls, [{ key: 'direct:conversation-1', sessionId: 'session-123' }]);
|
||||
assert.match(result.message, /^当前聊天已绑定会话:/);
|
||||
assert.match(result.message, /工作区:\/workspace\/project/);
|
||||
assert.match(result.message, /标题:安全标题 伪造 下一行/);
|
||||
assert.doesNotMatch(result.message, /\u202e|\n下一行/);
|
||||
assert.match(result.message, /ID:session-123/);
|
||||
assert.match(result.message, /归档:是/);
|
||||
assert.equal(result.messages.join(''), result.message);
|
||||
});
|
||||
test('/session strictly rejects missing, multiple, oversized, and unsafe IDs', async () => {
|
||||
let bindCalls = 0;
|
||||
const harness = {
|
||||
async bindWorkspaceSession() {
|
||||
bindCalls += 1;
|
||||
throw new Error('must not be called');
|
||||
},
|
||||
};
|
||||
const invalid = [
|
||||
'/session',
|
||||
'/session first second',
|
||||
'/session\nsecond-line',
|
||||
`/session ${'x'.repeat(257)}`,
|
||||
'/session unsafe\u202eid',
|
||||
'/session unsafe\u0000id',
|
||||
];
|
||||
|
||||
for (const command of invalid) {
|
||||
const result = await runWorkspaceCommand(command, harness, 'direct:conversation-1');
|
||||
assert.match(result.message, /用法:\/session Session ID/);
|
||||
}
|
||||
assert.equal(bindCalls, 0);
|
||||
});
|
||||
|
||||
test('/session requires binding support and a conversation key', async () => {
|
||||
assert.match(
|
||||
(await runWorkspaceCommand('/session session-1', {}, 'direct:conversation-1')).message,
|
||||
/暂不支持绑定已有会话/,
|
||||
);
|
||||
assert.match(
|
||||
(await runWorkspaceCommand('/session session-1', {
|
||||
async bindWorkspaceSession() { throw new Error('must not be called'); },
|
||||
})).message,
|
||||
/缺少可绑定的会话上下文/,
|
||||
);
|
||||
});
|
||||
|
||||
test('/session maps adoption, lifecycle, and concurrent failures to safe messages', async () => {
|
||||
const cases = [
|
||||
['session-id-invalid', /Session ID 格式无效/],
|
||||
['session-not-registered', /未找到该会话/],
|
||||
['session-workspace-ambiguous', /工作区归属不明确/],
|
||||
['session-summary-unavailable', /暂时无法读取该会话的信息/],
|
||||
['session-subagent-unsupported', /子代理会话不能绑定/],
|
||||
['workspace-bot-not-found', /正在移除或已重新接入/],
|
||||
['workspace-session-stale', /状态已发生变化/],
|
||||
['agent-busy', /状态已发生变化/],
|
||||
['session-conflict', /状态已发生变化/],
|
||||
['internal', /暂时无法绑定会话/],
|
||||
];
|
||||
|
||||
for (const [code, expected] of cases) {
|
||||
const internal = new Error(`private detail for ${code}`);
|
||||
internal.code = code;
|
||||
const result = await runWorkspaceCommand('/session session-1', {
|
||||
async bindWorkspaceSession() { throw internal; },
|
||||
}, 'direct:conversation-1');
|
||||
assert.match(result.message, expected);
|
||||
assert.doesNotMatch(result.message, /private detail/);
|
||||
}
|
||||
|
||||
const stale = new Error('old bot lifecycle');
|
||||
stale.code = 'workspace-bot-not-found';
|
||||
const staleAfterBinding = await runWorkspaceCommand('/session session-1', {
|
||||
async bindWorkspaceSession(sessionKey, sessionId) {
|
||||
return { sessionId, workspace: '/workspace/project', sessionKey };
|
||||
},
|
||||
assertWorkspaceScope() { throw stale; },
|
||||
}, 'direct:conversation-1');
|
||||
assert.match(staleAfterBinding.message, /正在移除或已重新接入/);
|
||||
});
|
||||
|
||||
test('the shared bridge binds locally with its conversation key and never prompts or creates', async () => {
|
||||
const sent = [];
|
||||
const seen = new Set();
|
||||
const calls = [];
|
||||
let forbiddenCalls = 0;
|
||||
const bridge = new TextHarnessBridge({
|
||||
descriptor: { key: 'test', label: 'Test' },
|
||||
bot: { async sendText(_target, text) { sent.push(text); } },
|
||||
harness: {
|
||||
async bindWorkspaceSession(key, sessionId) {
|
||||
calls.push({ key, sessionId });
|
||||
return {
|
||||
sessionId,
|
||||
workspace: '/workspace/project',
|
||||
title: null,
|
||||
archived: false,
|
||||
};
|
||||
},
|
||||
async createSession() { forbiddenCalls += 1; },
|
||||
async ask() { forbiddenCalls += 1; },
|
||||
},
|
||||
state: {
|
||||
hasSeen(messageId) { return seen.has(messageId); },
|
||||
async markSeen(messageId) { seen.add(messageId); },
|
||||
},
|
||||
});
|
||||
|
||||
await bridge.accept({
|
||||
messageId: 'message-session-bind',
|
||||
senderId: 'sender',
|
||||
conversationId: 'conversation-1',
|
||||
kind: 'direct',
|
||||
content: '/session session-123',
|
||||
replyTarget: 'target',
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [{ key: 'direct:conversation-1', sessionId: 'session-123' }]);
|
||||
assert.equal(forbiddenCalls, 0);
|
||||
assert.equal(sent.length, 1);
|
||||
assert.match(sent[0], /当前聊天已绑定会话/);
|
||||
assert.match(sent[0], /标题:暂无标题/);
|
||||
assert.match(sent[0], /归档:否/);
|
||||
assert.equal(seen.has('message-session-bind'), true);
|
||||
});
|
||||
|
||||
test('all nine channel bridges advertise /session and pass their current conversation key', async () => {
|
||||
const bridgeFamilies = [
|
||||
['../src/channels/shared/text-harness-bridge.mjs', 'conversationKey'],
|
||||
['../src/channels/weixin/weixin-bridge.mjs', 'key'],
|
||||
['../src/channels/feishu/bridge.mjs', 'key'],
|
||||
['../src/channels/dingtalk/dingtalk-bridge.mjs', 'key'],
|
||||
['../src/channels/wecom/wecom-bridge.mjs', 'key'],
|
||||
['../src/channels/qq/qq-bridge.mjs', 'key'],
|
||||
];
|
||||
for (const [file, key] of bridgeFamilies) {
|
||||
const source = await readFile(new URL(file, import.meta.url), 'utf8');
|
||||
assert.match(source, /\/session Session ID 将当前聊天绑定到指定会话/);
|
||||
assert.ok(
|
||||
source.includes(`runWorkspaceCommand(text, this.#harness, ${key})`),
|
||||
`${file} must pass ${key} to the shared command`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const file of [
|
||||
'../src/channels/discord/discord-bridge.mjs',
|
||||
'../src/channels/slack/slack-bridge.mjs',
|
||||
'../src/channels/telegram/telegram-bridge.mjs',
|
||||
'../src/channels/whatsapp/whatsapp-bridge.mjs',
|
||||
]) {
|
||||
const source = await readFile(new URL(file, import.meta.url), 'utf8');
|
||||
assert.match(source, /extends TextHarnessBridge/);
|
||||
}
|
||||
});
|
||||
613
test/session-bind-store.test.mjs
Normal file
613
test/session-bind-store.test.mjs
Normal file
|
|
@ -0,0 +1,613 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
mkdtemp,
|
||||
mkdir,
|
||||
readFile,
|
||||
realpath,
|
||||
rename,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
BotWorkspaceStore,
|
||||
createBotWorkspaceScope,
|
||||
} from '../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { askInWorkspaceSession } from '../src/channels/shared/workspace-session.mjs';
|
||||
|
||||
async function fixture(t) {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-im-session-bind-')));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
const defaultWorkspace = join(root, 'default');
|
||||
const alternateWorkspace = join(root, 'alternate');
|
||||
const thirdWorkspace = join(root, 'third');
|
||||
await Promise.all([
|
||||
mkdir(defaultWorkspace),
|
||||
mkdir(alternateWorkspace),
|
||||
mkdir(thirdWorkspace),
|
||||
]);
|
||||
return {
|
||||
root,
|
||||
defaultWorkspace,
|
||||
alternateWorkspace,
|
||||
thirdWorkspace,
|
||||
path: join(root, 'workspaces.json'),
|
||||
};
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((settle) => { resolve = settle; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function memoryState(initial = {}) {
|
||||
let sessions = { ...initial };
|
||||
let clears = 0;
|
||||
let sets = 0;
|
||||
return {
|
||||
sessionFor(key) { return sessions[key] ?? null; },
|
||||
async setSession(key, sessionId) {
|
||||
sets += 1;
|
||||
sessions[key] = sessionId;
|
||||
},
|
||||
async clearSessions() {
|
||||
clears += 1;
|
||||
sessions = {};
|
||||
},
|
||||
snapshot() { return { ...sessions }; },
|
||||
get clears() { return clears; },
|
||||
get sets() { return sets; },
|
||||
};
|
||||
}
|
||||
|
||||
test('binding across workspaces clears old mappings, fences the generation, and binds one conversation', async (t) => {
|
||||
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
|
||||
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
await workspaces.ensure('bot_bind');
|
||||
const previousGeneration = workspaces.generationFor('bot_bind');
|
||||
const state = memoryState({ 'direct:one': 'old-one', 'group:two': 'old-two' });
|
||||
const calls = [];
|
||||
const harness = {
|
||||
async adoptWorkspaceSession(sessionId) {
|
||||
calls.push(sessionId);
|
||||
return {
|
||||
sessionId,
|
||||
workspace: alternateWorkspace,
|
||||
title: 'Existing conversation',
|
||||
archived: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
const scope = createBotWorkspaceScope(harness, { botId: 'bot_bind', workspaces, state });
|
||||
|
||||
const result = await scope.harness.bindWorkspaceSession('direct:one', 'session-target');
|
||||
|
||||
assert.deepEqual(calls, ['session-target']);
|
||||
assert.deepEqual(result, {
|
||||
sessionId: 'session-target',
|
||||
workspace: alternateWorkspace,
|
||||
title: 'Existing conversation',
|
||||
archived: true,
|
||||
});
|
||||
assert.equal(workspaces.workspaceFor('bot_bind'), alternateWorkspace);
|
||||
assert.notEqual(workspaces.generationFor('bot_bind'), previousGeneration);
|
||||
assert.deepEqual(state.snapshot(), { 'direct:one': 'session-target' });
|
||||
assert.equal(state.clears, 1);
|
||||
assert.equal(state.sets, 1);
|
||||
assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), {
|
||||
version: 1,
|
||||
workspaces: { bot_bind: alternateWorkspace },
|
||||
});
|
||||
});
|
||||
|
||||
test('binding inside the current workspace only replaces the selected conversation', async (t) => {
|
||||
const { path, defaultWorkspace } = await fixture(t);
|
||||
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
await workspaces.ensure('bot_same');
|
||||
const generation = workspaces.generationFor('bot_same');
|
||||
const state = memoryState({ 'direct:one': 'old-one', 'group:two': 'kept-two' });
|
||||
const scope = createBotWorkspaceScope({
|
||||
async adoptWorkspaceSession(sessionId) {
|
||||
return { sessionId, workspace: defaultWorkspace };
|
||||
},
|
||||
}, { botId: 'bot_same', workspaces, state });
|
||||
|
||||
await scope.harness.bindWorkspaceSession('direct:one', 'session-current-workspace');
|
||||
|
||||
assert.equal(workspaces.workspaceFor('bot_same'), defaultWorkspace);
|
||||
assert.equal(workspaces.generationFor('bot_same'), generation);
|
||||
assert.equal(state.clears, 0);
|
||||
assert.deepEqual(state.snapshot(), {
|
||||
'direct:one': 'session-current-workspace',
|
||||
'group:two': 'kept-two',
|
||||
});
|
||||
});
|
||||
|
||||
test('binding through an equivalent real path does not clear a symlink workspace', async (t) => {
|
||||
const { root, path, defaultWorkspace } = await fixture(t);
|
||||
const linkedWorkspace = join(root, 'default-link');
|
||||
await symlink(defaultWorkspace, linkedWorkspace);
|
||||
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace: linkedWorkspace }).load();
|
||||
await workspaces.ensure('bot_symlink', { workspace: linkedWorkspace });
|
||||
const generation = workspaces.generationFor('bot_symlink');
|
||||
const state = memoryState({ selected: 'session-old', other: 'session-kept' });
|
||||
const scope = createBotWorkspaceScope({
|
||||
async adoptWorkspaceSession(sessionId) {
|
||||
return { sessionId, workspace: defaultWorkspace };
|
||||
},
|
||||
}, { botId: 'bot_symlink', workspaces, state });
|
||||
|
||||
await scope.harness.bindWorkspaceSession('selected', 'session-target');
|
||||
|
||||
assert.equal(workspaces.workspaceFor('bot_symlink'), linkedWorkspace);
|
||||
assert.equal(workspaces.generationFor('bot_symlink'), generation);
|
||||
assert.equal(state.clears, 0);
|
||||
assert.deepEqual(state.snapshot(), {
|
||||
selected: 'session-target',
|
||||
other: 'session-kept',
|
||||
});
|
||||
});
|
||||
|
||||
test('the next message continues the bound Session without creating a new one', async (t) => {
|
||||
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
|
||||
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
await workspaces.ensure('bot_continue');
|
||||
const state = memoryState({ conversation: 'session-old' });
|
||||
const asked = [];
|
||||
let createCalls = 0;
|
||||
const scope = createBotWorkspaceScope({
|
||||
async adoptWorkspaceSession(sessionId) {
|
||||
return { sessionId, workspace: alternateWorkspace };
|
||||
},
|
||||
async sessionExists(sessionId) { return sessionId === 'session-target'; },
|
||||
async createSession() {
|
||||
createCalls += 1;
|
||||
return 'session-created-unexpectedly';
|
||||
},
|
||||
async ask(sessionId, text) {
|
||||
asked.push({ sessionId, text });
|
||||
return 'continued answer';
|
||||
},
|
||||
}, { botId: 'bot_continue', workspaces, state });
|
||||
|
||||
await scope.harness.bindWorkspaceSession('conversation', 'session-target');
|
||||
const reply = await askInWorkspaceSession({
|
||||
harness: scope.harness,
|
||||
state: scope.state,
|
||||
key: 'conversation',
|
||||
text: 'continue here',
|
||||
});
|
||||
|
||||
assert.deepEqual(reply, { sessionId: 'session-target', answer: 'continued answer' });
|
||||
assert.deepEqual(asked, [{ sessionId: 'session-target', text: 'continue here' }]);
|
||||
assert.equal(createCalls, 0);
|
||||
});
|
||||
|
||||
test('adoption errors and invalid adoption responses leave local state unchanged', async (t) => {
|
||||
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
|
||||
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
await workspaces.ensure('bot_reject');
|
||||
const state = memoryState({ conversation: 'session-old' });
|
||||
const rejection = Object.assign(new Error('subagent sessions cannot be adopted'), {
|
||||
code: 'agent-busy',
|
||||
});
|
||||
const rejectedScope = createBotWorkspaceScope({
|
||||
async adoptWorkspaceSession() { throw rejection; },
|
||||
}, { botId: 'bot_reject', workspaces, state });
|
||||
|
||||
await assert.rejects(
|
||||
rejectedScope.harness.bindWorkspaceSession('conversation', 'session-child'),
|
||||
{ code: 'agent-busy' },
|
||||
);
|
||||
assert.equal(workspaces.workspaceFor('bot_reject'), defaultWorkspace);
|
||||
assert.deepEqual(state.snapshot(), { conversation: 'session-old' });
|
||||
|
||||
const invalidScope = createBotWorkspaceScope({
|
||||
async adoptWorkspaceSession() {
|
||||
return { sessionId: 'different-session', workspace: alternateWorkspace };
|
||||
},
|
||||
}, { botId: 'bot_reject', workspaces, state });
|
||||
await assert.rejects(
|
||||
invalidScope.harness.bindWorkspaceSession('conversation', 'session-requested'),
|
||||
/invalid adopted workspace session/,
|
||||
);
|
||||
assert.equal(workspaces.workspaceFor('bot_reject'), defaultWorkspace);
|
||||
assert.deepEqual(state.snapshot(), { conversation: 'session-old' });
|
||||
});
|
||||
|
||||
test('a workspace switch during adoption wins instead of being overwritten by the stale bind', async (t) => {
|
||||
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
|
||||
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
await workspaces.ensure('bot_adoption_race');
|
||||
const state = memoryState({ conversation: 'session-old', other: 'session-other' });
|
||||
const adoptionStarted = deferred();
|
||||
const releaseAdoption = deferred();
|
||||
const scope = createBotWorkspaceScope({
|
||||
async adoptWorkspaceSession(sessionId) {
|
||||
adoptionStarted.resolve();
|
||||
await releaseAdoption.promise;
|
||||
return { sessionId, workspace: defaultWorkspace };
|
||||
},
|
||||
}, { botId: 'bot_adoption_race', workspaces, state });
|
||||
|
||||
const binding = scope.harness.bindWorkspaceSession('conversation', 'session-target');
|
||||
await adoptionStarted.promise;
|
||||
await scope.harness.switchWorkspace(alternateWorkspace);
|
||||
releaseAdoption.resolve();
|
||||
|
||||
await assert.rejects(binding, { code: 'workspace-session-stale' });
|
||||
assert.equal(workspaces.workspaceFor('bot_adoption_race'), alternateWorkspace);
|
||||
assert.deepEqual(state.snapshot(), {});
|
||||
assert.equal(state.sets, 0);
|
||||
});
|
||||
|
||||
test('the store queue rejects a generation change after adoption validation', async (t) => {
|
||||
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
|
||||
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
await workspaces.ensure('bot_queue_fence');
|
||||
const state = memoryState({ conversation: 'session-old', other: 'session-other' });
|
||||
const bindInQueue = workspaces.bindWorkspaceSession.bind(workspaces);
|
||||
workspaces.bindWorkspaceSession = async (...args) => {
|
||||
await workspaces.setWorkspace('bot_queue_fence', alternateWorkspace, {
|
||||
clearSessions: () => state.clearSessions(),
|
||||
});
|
||||
return bindInQueue(...args);
|
||||
};
|
||||
const scope = createBotWorkspaceScope({
|
||||
async adoptWorkspaceSession(sessionId) {
|
||||
return { sessionId, workspace: defaultWorkspace };
|
||||
},
|
||||
}, { botId: 'bot_queue_fence', workspaces, state });
|
||||
|
||||
await assert.rejects(
|
||||
scope.harness.bindWorkspaceSession('conversation', 'session-target'),
|
||||
{ code: 'workspace-session-stale' },
|
||||
);
|
||||
|
||||
assert.equal(workspaces.workspaceFor('bot_queue_fence'), alternateWorkspace);
|
||||
assert.deepEqual(state.snapshot(), {});
|
||||
assert.equal(state.sets, 0);
|
||||
});
|
||||
|
||||
test('a workspace switch cannot interleave between bind persistence and its session write', async (t) => {
|
||||
const {
|
||||
path, defaultWorkspace, alternateWorkspace, thirdWorkspace,
|
||||
} = await fixture(t);
|
||||
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
await workspaces.ensure('bot_serial');
|
||||
let sessions = { first: 'session-old', second: 'session-other' };
|
||||
const setStarted = deferred();
|
||||
const releaseSet = deferred();
|
||||
const state = {
|
||||
sessionFor(key) { return sessions[key] ?? null; },
|
||||
async clearSessions() { sessions = {}; },
|
||||
async setSession(key, sessionId) {
|
||||
setStarted.resolve();
|
||||
await releaseSet.promise;
|
||||
sessions[key] = sessionId;
|
||||
},
|
||||
snapshot() { return { ...sessions }; },
|
||||
};
|
||||
const scope = createBotWorkspaceScope({
|
||||
async adoptWorkspaceSession(sessionId) {
|
||||
return { sessionId, workspace: alternateWorkspace };
|
||||
},
|
||||
}, { botId: 'bot_serial', workspaces, state });
|
||||
|
||||
const binding = scope.harness.bindWorkspaceSession('first', 'session-target');
|
||||
await setStarted.promise;
|
||||
const switching = scope.harness.switchWorkspace(thirdWorkspace);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.equal(workspaces.workspaceFor('bot_serial'), alternateWorkspace);
|
||||
assert.deepEqual(state.snapshot(), {});
|
||||
|
||||
releaseSet.resolve();
|
||||
await binding;
|
||||
await switching;
|
||||
|
||||
assert.equal(workspaces.workspaceFor('bot_serial'), thirdWorkspace);
|
||||
assert.deepEqual(state.snapshot(), {});
|
||||
});
|
||||
|
||||
test('a later workspace generation cannot be reported as the completed binding', async (t) => {
|
||||
const {
|
||||
path, defaultWorkspace, alternateWorkspace, thirdWorkspace,
|
||||
} = await fixture(t);
|
||||
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
await workspaces.ensure('bot_late_switch');
|
||||
const state = memoryState({ conversation: 'session-old' });
|
||||
const bindInQueue = workspaces.bindWorkspaceSession.bind(workspaces);
|
||||
workspaces.bindWorkspaceSession = async (...args) => {
|
||||
const bound = await bindInQueue(...args);
|
||||
await workspaces.setWorkspace('bot_late_switch', thirdWorkspace, {
|
||||
clearSessions: () => state.clearSessions(),
|
||||
});
|
||||
return bound;
|
||||
};
|
||||
const scope = createBotWorkspaceScope({
|
||||
async adoptWorkspaceSession(sessionId) {
|
||||
return { sessionId, workspace: alternateWorkspace };
|
||||
},
|
||||
}, { botId: 'bot_late_switch', workspaces, state });
|
||||
|
||||
await assert.rejects(
|
||||
scope.harness.bindWorkspaceSession('conversation', 'session-target'),
|
||||
{ code: 'workspace-session-stale' },
|
||||
);
|
||||
|
||||
assert.equal(workspaces.workspaceFor('bot_late_switch'), thirdWorkspace);
|
||||
assert.deepEqual(state.snapshot(), {});
|
||||
});
|
||||
|
||||
test('a bind started by an old bot incarnation cannot mutate a same-id replacement', async (t) => {
|
||||
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
|
||||
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
await workspaces.ensure('bot_rebound');
|
||||
const oldState = memoryState({ conversation: 'session-old' });
|
||||
const adoptionStarted = deferred();
|
||||
const releaseAdoption = deferred();
|
||||
const oldScope = createBotWorkspaceScope({
|
||||
async adoptWorkspaceSession(sessionId) {
|
||||
adoptionStarted.resolve();
|
||||
await releaseAdoption.promise;
|
||||
return { sessionId, workspace: alternateWorkspace };
|
||||
},
|
||||
}, { botId: 'bot_rebound', workspaces, state: oldState });
|
||||
|
||||
const staleBinding = oldScope.harness.bindWorkspaceSession('conversation', 'session-target');
|
||||
await adoptionStarted.promise;
|
||||
const removal = await workspaces.beginRemoval('bot_rebound', {
|
||||
clearSessions: () => oldState.clearSessions(),
|
||||
});
|
||||
await workspaces.finishRemoval(removal);
|
||||
await workspaces.ensure('bot_rebound', { workspace: defaultWorkspace });
|
||||
const replacementIncarnation = workspaces.incarnationFor('bot_rebound');
|
||||
|
||||
releaseAdoption.resolve();
|
||||
await assert.rejects(staleBinding, { code: 'workspace-bot-not-found' });
|
||||
|
||||
assert.equal(workspaces.incarnationFor('bot_rebound'), replacementIncarnation);
|
||||
assert.equal(workspaces.workspaceFor('bot_rebound'), defaultWorkspace);
|
||||
assert.deepEqual(oldState.snapshot(), {});
|
||||
assert.equal(oldState.sets, 0);
|
||||
});
|
||||
|
||||
test('binding fences a session creation that started in the previous workspace generation', async (t) => {
|
||||
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
|
||||
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
await workspaces.ensure('bot_creation');
|
||||
const creationStarted = deferred();
|
||||
const releaseCreation = deferred();
|
||||
const state = memoryState();
|
||||
const scope = createBotWorkspaceScope({
|
||||
async createSession() {
|
||||
creationStarted.resolve();
|
||||
await releaseCreation.promise;
|
||||
return 'session-from-old-workspace';
|
||||
},
|
||||
async adoptWorkspaceSession(sessionId) {
|
||||
return { sessionId, workspace: alternateWorkspace };
|
||||
},
|
||||
}, { botId: 'bot_creation', workspaces, state });
|
||||
|
||||
const oldCreation = scope.harness.createSession();
|
||||
await creationStarted.promise;
|
||||
await scope.harness.bindWorkspaceSession('bound', 'session-target');
|
||||
releaseCreation.resolve();
|
||||
const staleSessionId = await oldCreation;
|
||||
|
||||
assert.equal(await scope.state.setSession('other', staleSessionId), false);
|
||||
assert.deepEqual(state.snapshot(), { bound: 'session-target' });
|
||||
});
|
||||
|
||||
test('two conversations sharing one Session keep independent generation handles', async (t) => {
|
||||
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
|
||||
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
await workspaces.ensure('bot_shared_session');
|
||||
const state = memoryState({ first: 'session-shared', second: 'session-shared' });
|
||||
const bothExistenceChecksStarted = deferred();
|
||||
const releaseExistenceChecks = deferred();
|
||||
let existenceChecks = 0;
|
||||
let creations = 0;
|
||||
const asked = [];
|
||||
const scope = createBotWorkspaceScope({
|
||||
async sessionExists(sessionId) {
|
||||
assert.equal(sessionId, 'session-shared');
|
||||
existenceChecks += 1;
|
||||
if (existenceChecks === 2) bothExistenceChecksStarted.resolve();
|
||||
await releaseExistenceChecks.promise;
|
||||
return true;
|
||||
},
|
||||
async createSession({ workspace }) {
|
||||
assert.equal(workspace, alternateWorkspace);
|
||||
creations += 1;
|
||||
return `session-current-${creations}`;
|
||||
},
|
||||
async ask(sessionId, text) {
|
||||
asked.push({ sessionId, text });
|
||||
return `answer:${text}`;
|
||||
},
|
||||
}, { botId: 'bot_shared_session', workspaces, state });
|
||||
|
||||
const first = askInWorkspaceSession({
|
||||
harness: scope.harness,
|
||||
state: scope.state,
|
||||
key: 'first',
|
||||
text: 'first prompt',
|
||||
});
|
||||
const second = askInWorkspaceSession({
|
||||
harness: scope.harness,
|
||||
state: scope.state,
|
||||
key: 'second',
|
||||
text: 'second prompt',
|
||||
});
|
||||
await bothExistenceChecksStarted.promise;
|
||||
await scope.harness.switchWorkspace(alternateWorkspace);
|
||||
releaseExistenceChecks.resolve();
|
||||
|
||||
const replies = await Promise.all([first, second]);
|
||||
assert.equal(replies.length, 2);
|
||||
assert.equal(asked.some(({ sessionId }) => sessionId === 'session-shared'), false);
|
||||
assert.deepEqual(new Set(asked.map(({ sessionId }) => sessionId)), new Set([
|
||||
'session-current-1',
|
||||
'session-current-2',
|
||||
]));
|
||||
assert.equal(workspaces.workspaceFor('bot_shared_session'), alternateWorkspace);
|
||||
});
|
||||
|
||||
test('an old handle stays stale after switching away and rebinding the same Session id', async (t) => {
|
||||
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
|
||||
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
await workspaces.ensure('bot_rebind_same_id');
|
||||
const state = memoryState({ first: 'session-shared' });
|
||||
const existenceStarted = deferred();
|
||||
const releaseExistence = deferred();
|
||||
const asked = [];
|
||||
const scope = createBotWorkspaceScope({
|
||||
async adoptWorkspaceSession(sessionId) {
|
||||
return { sessionId, workspace: defaultWorkspace };
|
||||
},
|
||||
async sessionExists(sessionId) {
|
||||
assert.equal(sessionId, 'session-shared');
|
||||
existenceStarted.resolve();
|
||||
await releaseExistence.promise;
|
||||
return true;
|
||||
},
|
||||
async createSession({ workspace }) {
|
||||
assert.equal(workspace, defaultWorkspace);
|
||||
return 'session-after-rebind';
|
||||
},
|
||||
async ask(sessionId, text) {
|
||||
asked.push({ sessionId, text });
|
||||
return 'answer';
|
||||
},
|
||||
}, { botId: 'bot_rebind_same_id', workspaces, state });
|
||||
|
||||
const oldPrompt = askInWorkspaceSession({
|
||||
harness: scope.harness,
|
||||
state: scope.state,
|
||||
key: 'first',
|
||||
text: 'old prompt',
|
||||
});
|
||||
await existenceStarted.promise;
|
||||
await scope.harness.switchWorkspace(alternateWorkspace);
|
||||
await scope.harness.bindWorkspaceSession('second', 'session-shared');
|
||||
releaseExistence.resolve();
|
||||
|
||||
assert.deepEqual(await oldPrompt, { sessionId: 'session-after-rebind', answer: 'answer' });
|
||||
assert.deepEqual(asked, [{ sessionId: 'session-after-rebind', text: 'old prompt' }]);
|
||||
assert.equal(workspaces.workspaceFor('bot_rebind_same_id'), defaultWorkspace);
|
||||
assert.deepEqual(state.snapshot(), {
|
||||
second: 'session-shared',
|
||||
first: 'session-after-rebind',
|
||||
});
|
||||
});
|
||||
|
||||
test('a Session created before a switch keeps its original generation provenance', async (t) => {
|
||||
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
|
||||
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
await workspaces.ensure('bot_create_provenance');
|
||||
const state = memoryState();
|
||||
const creationStarted = deferred();
|
||||
const releaseCreation = deferred();
|
||||
const createdIn = [];
|
||||
const asked = [];
|
||||
const scope = createBotWorkspaceScope({
|
||||
async createSession({ workspace }) {
|
||||
createdIn.push(workspace);
|
||||
if (createdIn.length === 1) {
|
||||
creationStarted.resolve();
|
||||
await releaseCreation.promise;
|
||||
return 'session-created-before-switch';
|
||||
}
|
||||
return 'session-created-after-switch';
|
||||
},
|
||||
async ask(sessionId, text) {
|
||||
asked.push({ sessionId, text });
|
||||
return 'answer';
|
||||
},
|
||||
}, { botId: 'bot_create_provenance', workspaces, state });
|
||||
|
||||
const prompting = askInWorkspaceSession({
|
||||
harness: scope.harness,
|
||||
state: scope.state,
|
||||
key: 'conversation',
|
||||
text: 'prompt',
|
||||
});
|
||||
await creationStarted.promise;
|
||||
await scope.harness.switchWorkspace(alternateWorkspace);
|
||||
releaseCreation.resolve();
|
||||
|
||||
assert.deepEqual(await prompting, {
|
||||
sessionId: 'session-created-after-switch',
|
||||
answer: 'answer',
|
||||
});
|
||||
assert.deepEqual(createdIn, [defaultWorkspace, alternateWorkspace]);
|
||||
assert.deepEqual(asked, [{ sessionId: 'session-created-after-switch', text: 'prompt' }]);
|
||||
assert.deepEqual(state.snapshot(), { conversation: 'session-created-after-switch' });
|
||||
});
|
||||
|
||||
test('workspace persistence failure leaves old mappings cleared and never writes the target session', async (t) => {
|
||||
const { root, defaultWorkspace, alternateWorkspace } = await fixture(t);
|
||||
const storeDirectory = join(root, 'store');
|
||||
const storePath = join(storeDirectory, 'workspaces.json');
|
||||
await mkdir(storeDirectory);
|
||||
const workspaces = await new BotWorkspaceStore(storePath, { defaultWorkspace }).load();
|
||||
await workspaces.ensure('bot_persist');
|
||||
const generation = workspaces.generationFor('bot_persist');
|
||||
const state = memoryState({ first: 'session-old', second: 'session-other' });
|
||||
const scope = createBotWorkspaceScope({
|
||||
async adoptWorkspaceSession(sessionId) {
|
||||
return { sessionId, workspace: alternateWorkspace };
|
||||
},
|
||||
}, { botId: 'bot_persist', workspaces, state });
|
||||
|
||||
const savedDirectory = `${storeDirectory}-saved`;
|
||||
await rename(storeDirectory, savedDirectory);
|
||||
await writeFile(storeDirectory, 'blocks persistence');
|
||||
await assert.rejects(scope.harness.bindWorkspaceSession('first', 'session-target'));
|
||||
|
||||
assert.equal(workspaces.workspaceFor('bot_persist'), defaultWorkspace);
|
||||
assert.notEqual(workspaces.generationFor('bot_persist'), generation);
|
||||
assert.deepEqual(state.snapshot(), {});
|
||||
assert.equal(state.sets, 0);
|
||||
|
||||
await rm(storeDirectory, { force: true });
|
||||
await rename(savedDirectory, storeDirectory);
|
||||
});
|
||||
|
||||
test('session persistence failure keeps the new workspace and does not restore old mappings', async (t) => {
|
||||
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
|
||||
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
await workspaces.ensure('bot_state_failure');
|
||||
let sessions = { first: 'session-old', second: 'session-other' };
|
||||
const state = {
|
||||
sessionFor(key) { return sessions[key] ?? null; },
|
||||
async clearSessions() { sessions = {}; },
|
||||
async setSession() { throw new Error('state persistence failed'); },
|
||||
snapshot() { return { ...sessions }; },
|
||||
};
|
||||
const scope = createBotWorkspaceScope({
|
||||
async adoptWorkspaceSession(sessionId) {
|
||||
return { sessionId, workspace: alternateWorkspace };
|
||||
},
|
||||
}, { botId: 'bot_state_failure', workspaces, state });
|
||||
|
||||
await assert.rejects(
|
||||
scope.harness.bindWorkspaceSession('first', 'session-target'),
|
||||
/state persistence failed/,
|
||||
);
|
||||
|
||||
assert.equal(workspaces.workspaceFor('bot_state_failure'), alternateWorkspace);
|
||||
assert.deepEqual(state.snapshot(), {});
|
||||
assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), {
|
||||
version: 1,
|
||||
workspaces: { bot_state_failure: alternateWorkspace },
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue