mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 00:33:20 +08:00
feat: support compact command across IM channels
This commit is contained in:
parent
4545cfe9c5
commit
1cc7a26f30
40 changed files with 728 additions and 136 deletions
|
|
@ -76,13 +76,15 @@ Each bot maintains an independent Harness workspace. A newly connected bot recor
|
|||
|
||||
| Command | Description |
|
||||
| --- | --- |
|
||||
| `/compact` | Immediately compact older context in the Session bound to the current chat. |
|
||||
| `/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 2`, `/sessionlist /Users/alice/projects/my-app`, or `/session session-id`
|
||||
Examples: `/compact`, `/workspace /Users/alice/projects/my-app`, `/sessionlist 2`, `/sessionlist /Users/alice/projects/my-app`, or `/session session-id`
|
||||
|
||||
- `/compact` acts only on the Harness Session already bound to the current chat and is never sent to the model. The bot reports the applicable status when the chat has no Session yet, the Session is generating a reply, or there is no compactable history.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -79,13 +79,15 @@ QQ 扫码接入使用腾讯 QQBot v2 官方流程。默认腾讯授权页会把
|
|||
|
||||
| 命令 | 作用 |
|
||||
| --- | --- |
|
||||
| `/compact` | 立即压缩当前聊天绑定会话的较早上下文。 |
|
||||
| `/workspace <工作区绝对路径>` | 切换当前机器人的 Harness 工作区。 |
|
||||
| `/workspacelist` | 列出当前 Harness Host 上仍然存在的工作区绝对路径。 |
|
||||
| `/sessionlist [工作区序号或绝对路径]` | 列出指定工作区登记的所有会话 ID 和标题;省略参数时使用当前工作区。 |
|
||||
| `/session <Session ID>` | 将当前聊天绑定到指定的已有 Harness 会话。 |
|
||||
|
||||
示例:`/workspace /Users/alice/projects/my-app`、`/sessionlist 2`、`/sessionlist /Users/alice/projects/my-app` 或 `/session session-id`
|
||||
示例:`/compact`、`/workspace /Users/alice/projects/my-app`、`/sessionlist 2`、`/sessionlist /Users/alice/projects/my-app` 或 `/session session-id`
|
||||
|
||||
- `/compact` 只作用于当前聊天已经绑定的 Harness 会话,不会把命令发送给模型。当前聊天尚未创建会话、会话正在生成回复或没有可压缩历史时,机器人会直接返回对应状态。
|
||||
- 只接受已经存在的绝对目录;路径无效时机器人会返回具体提示和正确用法。
|
||||
- `/workspacelist` 不需要参数。它合并 Harness 全局登记项与当前机器人的路径;当前路径仍存在且可安全显示时会排在首位并标记为“当前”。结果可直接复制到 `/workspace` 命令。
|
||||
- `/sessionlist` 的数字参数按命令执行时与 `/workspacelist` 相同的最新顺序解析;也可使用绝对路径直接指定工作区。结果会回显最终选中的路径。
|
||||
|
|
|
|||
244
lib/index.js
244
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -2,7 +2,7 @@ import { createProductionController } from './production.mjs';
|
|||
import { installDingtalkRpc } from './rpc.mjs';
|
||||
|
||||
export const name = 'dsh-dingtalk-host';
|
||||
export const inject = ['connection', 'credentials', 'webServer'];
|
||||
export const inject = ['connection', 'credentials', 'webServer', 'typertGateway'];
|
||||
|
||||
export async function apply(ctx, config = {}) {
|
||||
if (config?.controller) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
observeBotWorkspaceRemovals,
|
||||
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { createConnectionSupervisor } from './connection-supervisor.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
|
||||
function harnessOrigin(webServer, configured) {
|
||||
if (configured !== undefined) return new URL(configured);
|
||||
|
|
@ -79,12 +80,14 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
}
|
||||
return state;
|
||||
};
|
||||
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
||||
const harness = new Harness({
|
||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||
workspace: defaultWorkspace,
|
||||
agentPreset: config.agentPreset ?? 'standard',
|
||||
autostart: false,
|
||||
dshBin: config.dshBin ?? 'dsh',
|
||||
...(commandExecutor ? { commandExecutor } : {}),
|
||||
});
|
||||
const coreController = new Controller({
|
||||
deviceAuth,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { createProductionController } from './production.mjs';
|
|||
import { installDiscordRpc } from './rpc.mjs';
|
||||
|
||||
export const name = 'dsh-im-discord-host';
|
||||
export const inject = ['connection', 'credentials', 'webServer'];
|
||||
export const inject = ['connection', 'credentials', 'webServer', 'typertGateway'];
|
||||
|
||||
export async function apply(ctx, config = {}) {
|
||||
if (config?.controller) {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { createProductionController } from './production.mjs';
|
|||
import { installFeishuRpc } from './rpc.mjs';
|
||||
|
||||
export const name = 'dsh-feishu-host';
|
||||
export const inject = ['connection', 'credentials', 'webServer'];
|
||||
export const inject = ['connection', 'credentials', 'webServer', 'typertGateway'];
|
||||
|
||||
function controllerFrom(ctx, config) {
|
||||
if (config?.controller) return config.controller;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { join, resolve } from 'node:path';
|
|||
import { unlink } from 'node:fs/promises';
|
||||
import * as Lark from '@larksuiteoapi/node-sdk';
|
||||
import { createConnectionSupervisor } from './connection-supervisor.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
import { verifyFeishuApp } from '../../../../src/channels/feishu/feishu-app.mjs';
|
||||
import { FeishuRuntime } from '../../../../src/channels/feishu/feishu-runtime.mjs';
|
||||
import { HarnessClient } from '../../../../src/channels/feishu/harness-client.mjs';
|
||||
|
|
@ -105,6 +106,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
if (!botConfig) throw new Error('Unknown Feishu bot');
|
||||
return stateFor(botConfig);
|
||||
};
|
||||
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
||||
const harness = new Harness({
|
||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||
workspace: defaultWorkspace,
|
||||
|
|
@ -113,6 +115,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
// second DSH would create a competing server and lifecycle.
|
||||
autostart: false,
|
||||
dshBin: config.dshBin ?? 'dsh',
|
||||
...(commandExecutor ? { commandExecutor } : {}),
|
||||
});
|
||||
|
||||
const coreController = new Controller({
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { createProductionController } from './production.mjs';
|
|||
import { installQqRpc } from './rpc.mjs';
|
||||
|
||||
export const name = 'dsh-im-qq-host';
|
||||
export const inject = ['connection', 'credentials', 'webServer'];
|
||||
export const inject = ['connection', 'credentials', 'webServer', 'typertGateway'];
|
||||
|
||||
export async function apply(ctx, config = {}) {
|
||||
if (config?.controller) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
observeBotWorkspaceRemovals,
|
||||
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { createConnectionSupervisor } from './connection-supervisor.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
|
||||
function harnessOrigin(webServer, configured) {
|
||||
if (configured !== undefined) return new URL(configured);
|
||||
|
|
@ -70,12 +71,14 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
}
|
||||
return state;
|
||||
};
|
||||
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
||||
const harness = new Harness({
|
||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||
workspace: defaultWorkspace,
|
||||
agentPreset: config.agentPreset ?? 'standard',
|
||||
autostart: false,
|
||||
dshBin: config.dshBin ?? 'dsh',
|
||||
...(commandExecutor ? { commandExecutor } : {}),
|
||||
});
|
||||
const coreController = new Controller({
|
||||
qrAuth,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { homedir } from 'node:os';
|
|||
import { join, resolve } from 'node:path';
|
||||
|
||||
import { createTokenConnectionSupervisor } from './connection-supervisor.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
import {
|
||||
BotWorkspaceStore,
|
||||
createBotWorkspaceScope,
|
||||
|
|
@ -64,12 +65,14 @@ export async function createTokenProductionController(ctx, config, internals, de
|
|||
}
|
||||
return state;
|
||||
};
|
||||
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
||||
const harness = new ResolvedHarness({
|
||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||
workspace: defaultWorkspace,
|
||||
agentPreset: config.agentPreset ?? 'standard',
|
||||
autostart: false,
|
||||
dshBin: config.dshBin ?? 'dsh',
|
||||
...(commandExecutor ? { commandExecutor } : {}),
|
||||
});
|
||||
const coreController = new ResolvedController({
|
||||
credentials: ctx.credentials,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { createProductionController } from './production.mjs';
|
|||
import { installSlackRpc } from './rpc.mjs';
|
||||
|
||||
export const name = 'dsh-im-slack-host';
|
||||
export const inject = ['connection', 'credentials', 'webServer'];
|
||||
export const inject = ['connection', 'credentials', 'webServer', 'typertGateway'];
|
||||
|
||||
export async function apply(ctx, config = {}) {
|
||||
if (config?.controller) return installSlackRpc(ctx, config.controller, config.rpcAuthority);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { createTokenConnectionSupervisor } from '../shared/connection-supervisor.mjs';
|
||||
import { harnessOrigin, pluginPaths } from '../shared/production.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
|
||||
export async function createProductionController(ctx, config = {}, internals = {}) {
|
||||
if (!ctx?.credentials) throw new TypeError('dsh-im slack requires ctx.credentials');
|
||||
|
|
@ -49,12 +50,14 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
}
|
||||
return state;
|
||||
};
|
||||
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
||||
const harness = new ResolvedHarness({
|
||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||
workspace: defaultWorkspace,
|
||||
agentPreset: config.agentPreset ?? 'standard',
|
||||
autostart: false,
|
||||
dshBin: config.dshBin ?? 'dsh',
|
||||
...(commandExecutor ? { commandExecutor } : {}),
|
||||
});
|
||||
const coreController = new ResolvedController({
|
||||
credentials: ctx.credentials,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { createProductionController } from './production.mjs';
|
|||
import { installTelegramRpc } from './rpc.mjs';
|
||||
|
||||
export const name = 'dsh-im-telegram-host';
|
||||
export const inject = ['connection', 'credentials', 'webServer'];
|
||||
export const inject = ['connection', 'credentials', 'webServer', 'typertGateway'];
|
||||
|
||||
export async function apply(ctx, config = {}) {
|
||||
if (config?.controller) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { createProductionController } from './production.mjs';
|
|||
import { installWecomRpc } from './rpc.mjs';
|
||||
|
||||
export const name = 'dsh-im-wecom-host';
|
||||
export const inject = ['connection', 'credentials', 'webServer'];
|
||||
export const inject = ['connection', 'credentials', 'webServer', 'typertGateway'];
|
||||
|
||||
export async function apply(ctx, config = {}) {
|
||||
if (config?.controller) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
observeBotWorkspaceRemovals,
|
||||
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { createConnectionSupervisor } from './connection-supervisor.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
|
||||
function harnessOrigin(webServer, configured) {
|
||||
if (configured !== undefined) return new URL(configured);
|
||||
|
|
@ -73,12 +74,14 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
}
|
||||
return state;
|
||||
};
|
||||
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
||||
const harness = new Harness({
|
||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||
workspace: defaultWorkspace,
|
||||
agentPreset: config.agentPreset ?? 'standard',
|
||||
autostart: false,
|
||||
dshBin: config.dshBin ?? 'dsh',
|
||||
...(commandExecutor ? { commandExecutor } : {}),
|
||||
});
|
||||
const coreController = new Controller({
|
||||
qrAuth,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { createProductionController } from './production.mjs';
|
|||
import { installWeixinRpc } from './rpc.mjs';
|
||||
|
||||
export const name = 'dsh-weixin-host';
|
||||
export const inject = ['connection', 'credentials', 'webServer'];
|
||||
export const inject = ['connection', 'credentials', 'webServer', 'typertGateway'];
|
||||
|
||||
export async function apply(ctx, config = {}) {
|
||||
if (config?.controller) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
observeBotWorkspaceRemovals,
|
||||
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { createConnectionSupervisor } from './connection-supervisor.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
|
||||
function harnessOrigin(webServer, configured) {
|
||||
if (configured !== undefined) return new URL(configured);
|
||||
|
|
@ -73,12 +74,14 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
}
|
||||
return state;
|
||||
};
|
||||
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
||||
const harness = new Harness({
|
||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||
workspace: defaultWorkspace,
|
||||
agentPreset: config.agentPreset ?? 'standard',
|
||||
autostart: false,
|
||||
dshBin: config.dshBin ?? 'dsh',
|
||||
...(commandExecutor ? { commandExecutor } : {}),
|
||||
});
|
||||
const coreController = new Controller({
|
||||
api,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { createProductionController } from './production.mjs';
|
|||
import { installWhatsappRpc } from './rpc.mjs';
|
||||
|
||||
export const name = 'dsh-im-whatsapp-host';
|
||||
export const inject = ['connection', 'webServer'];
|
||||
export const inject = ['connection', 'webServer', 'typertGateway'];
|
||||
|
||||
export async function apply(ctx, config = {}) {
|
||||
if (config?.controller) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
observeBotWorkspaceRemovals,
|
||||
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { createTokenConnectionSupervisor } from '../shared/connection-supervisor.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
|
||||
const AUTH_DIRECTORY_PATTERN = /^[a-f0-9-]{36}$/;
|
||||
|
||||
|
|
@ -76,12 +77,14 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
}
|
||||
return state;
|
||||
};
|
||||
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
||||
const harness = new Harness({
|
||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||
workspace: defaultWorkspace,
|
||||
agentPreset: config.agentPreset ?? 'standard',
|
||||
autostart: false,
|
||||
dshBin: config.dshBin ?? 'dsh',
|
||||
...(commandExecutor ? { commandExecutor } : {}),
|
||||
});
|
||||
const coreController = new Controller({
|
||||
configStore: observedConfigStore,
|
||||
|
|
|
|||
21
plugin-src/host/harness-command-executor.mjs
Normal file
21
plugin-src/host/harness-command-executor.mjs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* Adapt the Host's Typert command gateway to the channel Harness client.
|
||||
* Programmatic production fixtures may omit the gateway and exercise other assembly paths.
|
||||
*/
|
||||
export function createHarnessCommandExecutor(ctx, provided) {
|
||||
if (provided !== undefined) {
|
||||
if (typeof provided !== 'function') throw new TypeError('commandExecutor must be a function');
|
||||
return provided;
|
||||
}
|
||||
const gateway = ctx?.typertGateway;
|
||||
if (!gateway) return undefined;
|
||||
if (typeof gateway.invoke !== 'function') {
|
||||
throw new TypeError('dsh-im requires a callable ctx.typertGateway');
|
||||
}
|
||||
return (sessionId, line, options = {}) => gateway.invoke({
|
||||
namespace: 'commands',
|
||||
method: 'execute',
|
||||
args: { agentId: sessionId, line },
|
||||
signal: options.signal,
|
||||
});
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ import { apply as applyWeixin } from './channels/weixin/index.mjs';
|
|||
import { apply as applyWhatsapp } from './channels/whatsapp/index.mjs';
|
||||
|
||||
export const name = 'dsh-im-host';
|
||||
export const inject = ['connection', 'credentials', 'webServer'];
|
||||
export const inject = ['connection', 'credentials', 'webServer', 'typertGateway'];
|
||||
|
||||
function channelConfig(config, name) {
|
||||
const channel = config[name] ?? {};
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
validHarnessQuestion,
|
||||
} from '../shared/harness-question.mjs';
|
||||
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
||||
import { runCompactCommand } from '../shared/compact-command.mjs';
|
||||
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||
|
||||
|
|
@ -21,6 +22,7 @@ const HELP_TEXT = [
|
|||
'',
|
||||
'直接发送文字即可继续当前会话。',
|
||||
'/new 开启一个全新会话',
|
||||
'/compact 压缩当前会话的较早上下文',
|
||||
'/workspace 工作区绝对路径 切换工作区',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
||||
|
|
@ -346,6 +348,17 @@ export class DingtalkHarnessBridge {
|
|||
}
|
||||
return;
|
||||
}
|
||||
const compactCommand = await runCompactCommand(
|
||||
text,
|
||||
this.#harness,
|
||||
this.#state,
|
||||
key,
|
||||
{ signal: this.#signal },
|
||||
);
|
||||
if (compactCommand) {
|
||||
await this.#send(sessionWebhook, compactCommand.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof this.#api.createAiCard === 'function'
|
||||
&& typeof this.#api.updateAiCard === 'function'
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
validHarnessQuestion,
|
||||
} from '../shared/harness-question.mjs';
|
||||
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
||||
import { runCompactCommand } from '../shared/compact-command.mjs';
|
||||
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||
|
||||
|
|
@ -22,6 +23,7 @@ const HELP_TEXT = [
|
|||
'',
|
||||
'直接发送问题即可继续当前会话。',
|
||||
'/new 开启一个全新会话',
|
||||
'/compact 压缩当前会话的较早上下文',
|
||||
'/workspace 工作区绝对路径 切换工作区',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
||||
|
|
@ -322,6 +324,17 @@ export class FeishuHarnessBridge {
|
|||
}
|
||||
return;
|
||||
}
|
||||
const compactCommand = await runCompactCommand(
|
||||
text,
|
||||
this.#harness,
|
||||
this.#state,
|
||||
key,
|
||||
{ signal: this.#signal },
|
||||
);
|
||||
if (compactCommand) {
|
||||
await this.#send(event.message.chat_id, compactCommand.message);
|
||||
return;
|
||||
}
|
||||
|
||||
this.#logger.info?.(`[dsh-feishu] processing ${event.message.chat_type} message ${messageId}`);
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
||||
import { runCompactCommand } from '../shared/compact-command.mjs';
|
||||
import {
|
||||
harnessAnswerForQuestion,
|
||||
harnessQuestionText,
|
||||
|
|
@ -14,6 +15,7 @@ const HELP_TEXT = [
|
|||
'',
|
||||
'直接发送文字即可继续当前会话。',
|
||||
'/new 开启一个全新会话',
|
||||
'/compact 压缩当前会话的较早上下文',
|
||||
'/workspace 工作区绝对路径 切换工作区',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
||||
|
|
@ -241,6 +243,18 @@ export class QqHarnessBridge {
|
|||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
const compactCommand = await runCompactCommand(
|
||||
text,
|
||||
this.#harness,
|
||||
this.#state,
|
||||
key,
|
||||
{ signal: this.#signal },
|
||||
);
|
||||
if (compactCommand) {
|
||||
await this.#bot.sendText(target, compactCommand.message);
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
let stream = null;
|
||||
let streamFinished = false;
|
||||
|
|
|
|||
|
|
@ -668,6 +668,19 @@ export function createBotWorkspaceScope(harness, { botId, workspaces, state }) {
|
|||
return target.ask(sessionId, ...args);
|
||||
};
|
||||
}
|
||||
if (property === 'executeCommand' && typeof target.executeCommand === 'function') {
|
||||
return (sessionId, ...args) => {
|
||||
const generation = sessionGenerations.get(sessionId);
|
||||
sessionGenerations.delete(sessionId);
|
||||
if (!isCurrentScope()
|
||||
|| (generation !== undefined && generation !== workspaces.generationFor(botId))) {
|
||||
const error = new Error('The bot workspace changed before this command started.');
|
||||
error.code = WORKSPACE_SESSION_STALE;
|
||||
throw error;
|
||||
}
|
||||
return target.executeCommand(sessionId, ...args);
|
||||
};
|
||||
}
|
||||
const value = Reflect.get(target, property, target);
|
||||
return typeof value === 'function' ? value.bind(target) : value;
|
||||
},
|
||||
|
|
|
|||
95
src/channels/shared/compact-command.mjs
Normal file
95
src/channels/shared/compact-command.mjs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
|
||||
|
||||
const COMPACT_COMMAND = /^\/compact(?=$|\s)([\s\S]*)$/i;
|
||||
const COMPACT_USAGE = '用法:/compact(不带参数)';
|
||||
|
||||
const COMPACT_RESULT_TEXT = new Map([
|
||||
['No compactable history yet.', '暂无可压缩的历史记录。'],
|
||||
[
|
||||
'Compaction is unavailable because this process has an active compaction, or the agent is not idle.',
|
||||
'当前会话正在生成回复或执行压缩,请稍后重试。',
|
||||
],
|
||||
['Compaction cancelled.', '上下文压缩已取消。'],
|
||||
[
|
||||
'The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.',
|
||||
'压缩期间会话历史发生变化,本次未修改会话,请重试。',
|
||||
],
|
||||
[
|
||||
'Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.',
|
||||
'未能生成有效的压缩摘要,本次未修改会话。',
|
||||
],
|
||||
[
|
||||
'Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.',
|
||||
'上下文压缩未正常完成,部分会话历史可能已变化,请检查会话后再重试。',
|
||||
],
|
||||
[
|
||||
'Compaction finished, but the session could not be saved.',
|
||||
'上下文已压缩,但会话保存失败。',
|
||||
],
|
||||
]);
|
||||
|
||||
function commandResult(message) {
|
||||
return { handled: true, message, messages: [message] };
|
||||
}
|
||||
|
||||
function compactResultText(result) {
|
||||
if (!result || typeof result !== 'object'
|
||||
|| !['success', 'error'].includes(result.kind)
|
||||
|| (result.text !== undefined && typeof result.text !== 'string')) {
|
||||
throw new TypeError('Harness returned an invalid /compact result');
|
||||
}
|
||||
const text = result.text?.trim() ?? '';
|
||||
const compacted = /^Compacted (\d+) history items \(~(\d+) tokens\)\.$/u.exec(text);
|
||||
if (compacted) {
|
||||
return `已压缩 ${compacted[1]} 条历史记录(约 ${compacted[2]} 个 token)。`;
|
||||
}
|
||||
if (COMPACT_RESULT_TEXT.has(text)) return COMPACT_RESULT_TEXT.get(text);
|
||||
if (text) return text;
|
||||
return result.kind === 'success' ? '上下文压缩完成。' : '上下文压缩失败。';
|
||||
}
|
||||
|
||||
function compactErrorMessage(error) {
|
||||
const code = error?.code ?? error?.failure?.code;
|
||||
if (code === 'session-not-found') {
|
||||
return '当前聊天绑定的会话已不存在,请发送新消息开启会话。';
|
||||
}
|
||||
if (code === 'agent-busy') return '当前会话正在生成回复,请稍后重试。';
|
||||
if (code === 'cancelled' || error?.name === 'AbortError') return '上下文压缩已取消。';
|
||||
if (code === WORKSPACE_SESSION_STALE || code === 'workspace-bot-not-found') {
|
||||
return '工作区或机器人状态已发生变化,请重试。';
|
||||
}
|
||||
if (code === 'commands-unavailable') {
|
||||
return '当前 Harness 暂不支持从机器人执行上下文压缩。';
|
||||
}
|
||||
return '上下文压缩失败,请稍后重试。';
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the explicit Harness compaction command for an existing IM conversation Session.
|
||||
* Unknown input returns null so the caller may continue ordinary message routing.
|
||||
*/
|
||||
export async function runCompactCommand(text, harness, state, conversationKey, options = {}) {
|
||||
if (typeof text !== 'string') return null;
|
||||
const match = COMPACT_COMMAND.exec(text.trim());
|
||||
if (!match) return null;
|
||||
if (match[1].trim()) return commandResult(COMPACT_USAGE);
|
||||
if (typeof state?.sessionFor !== 'function') {
|
||||
return commandResult('当前机器人没有可用的会话状态。');
|
||||
}
|
||||
const sessionId = state.sessionFor(conversationKey);
|
||||
if (typeof sessionId !== 'string' || !sessionId) {
|
||||
return commandResult('当前聊天还没有可压缩的会话,请先发送一条消息。');
|
||||
}
|
||||
if (typeof harness?.executeCommand !== 'function') {
|
||||
return commandResult('当前机器人暂不支持上下文压缩。');
|
||||
}
|
||||
try {
|
||||
const execution = await harness.executeCommand(sessionId, '/compact', options);
|
||||
if (execution === undefined) {
|
||||
return commandResult('当前 Harness 未注册 /compact 命令,请确认上下文压缩组件已启用。');
|
||||
}
|
||||
return commandResult(compactResultText(execution?.result));
|
||||
} catch (error) {
|
||||
return commandResult(compactErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
|
@ -318,6 +318,7 @@ export class HarnessClient {
|
|||
#interactionReconnectDelayMs;
|
||||
#rpcIdPrefix;
|
||||
#logPrefix;
|
||||
#commandExecutor;
|
||||
#managedProcess = null;
|
||||
#interactionRegistry;
|
||||
#interactionOwnerships;
|
||||
|
|
@ -334,6 +335,7 @@ export class HarnessClient {
|
|||
interactionReconnectDelayMs = 500,
|
||||
rpcIdPrefix = 'im',
|
||||
logPrefix = 'dsh-im',
|
||||
commandExecutor,
|
||||
}) {
|
||||
if (typeof createWebSocket !== 'function') {
|
||||
throw new TypeError('createWebSocket must be a function');
|
||||
|
|
@ -347,6 +349,9 @@ export class HarnessClient {
|
|||
if (typeof logPrefix !== 'string' || !logPrefix.trim()) {
|
||||
throw new TypeError('logPrefix must be a non-empty string');
|
||||
}
|
||||
if (commandExecutor !== undefined && typeof commandExecutor !== 'function') {
|
||||
throw new TypeError('commandExecutor must be a function');
|
||||
}
|
||||
this.#baseUrl = new URL(baseUrl);
|
||||
this.#workspace = workspace;
|
||||
this.#agentPreset = agentPreset;
|
||||
|
|
@ -357,6 +362,7 @@ export class HarnessClient {
|
|||
this.#interactionReconnectDelayMs = interactionReconnectDelayMs;
|
||||
this.#rpcIdPrefix = rpcIdPrefix.trim();
|
||||
this.#logPrefix = logPrefix.trim();
|
||||
this.#commandExecutor = commandExecutor;
|
||||
this.#interactionRegistry = interactionRegistry(this.#baseUrl.origin);
|
||||
this.#interactionOwnerships = this.#interactionRegistry.ownerships;
|
||||
this.#interactionClaims = this.#interactionRegistry.claims;
|
||||
|
|
@ -459,6 +465,24 @@ export class HarnessClient {
|
|||
return created.sessionId;
|
||||
}
|
||||
|
||||
async executeCommand(sessionId, line, options = {}) {
|
||||
if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
|
||||
if (typeof line !== 'string' || !line) throw new TypeError('command line is required');
|
||||
if (!this.#commandExecutor) {
|
||||
const error = new Error('Harness command execution is unavailable');
|
||||
error.code = 'commands-unavailable';
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
return await this.#commandExecutor(sessionId, line, options);
|
||||
} catch (error) {
|
||||
if (error?.failure && typeof error.failure === 'object') {
|
||||
throw new HarnessRpcError('commands.execute', error.failure);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async sessionExists(sessionId, options = {}) {
|
||||
try {
|
||||
await this.rpc('session.history', { sessionId, maxMessages: 1 }, 30_000, options);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { runWorkspaceCommand } from './workspace-command.mjs';
|
||||
import { runCompactCommand } from './compact-command.mjs';
|
||||
import { askInWorkspaceSession } from './workspace-session.mjs';
|
||||
import { HarnessApprovalQueue } from './harness-approval.mjs';
|
||||
import {
|
||||
|
|
@ -218,6 +219,7 @@ export class TextHarnessBridge {
|
|||
'',
|
||||
'直接发送文字即可继续当前会话。',
|
||||
'/new 开启一个全新会话',
|
||||
'/compact 压缩当前会话的较早上下文',
|
||||
'/workspace 工作区绝对路径 切换工作区',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
||||
|
|
@ -244,6 +246,17 @@ export class TextHarnessBridge {
|
|||
await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
|
||||
return;
|
||||
}
|
||||
const compactCommand = await runCompactCommand(
|
||||
text,
|
||||
this.#harness,
|
||||
this.#state,
|
||||
conversationKey,
|
||||
{ signal: this.#signal },
|
||||
);
|
||||
if (compactCommand) {
|
||||
await this.#bot.sendText(target, compactCommand.message);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.#bot.sendTyping?.(target).catch((error) => {
|
||||
this.#logger.warn?.(`[dsh-im:${this.#descriptor.key}] typing indicator failed:`, error);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
validHarnessQuestion,
|
||||
} from '../shared/harness-question.mjs';
|
||||
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
||||
import { runCompactCommand } from '../shared/compact-command.mjs';
|
||||
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||
|
||||
|
|
@ -13,6 +14,7 @@ const HELP_TEXT = [
|
|||
'',
|
||||
'直接发送文字即可继续当前会话。',
|
||||
'/new 开启一个全新会话',
|
||||
'/compact 压缩当前会话的较早上下文',
|
||||
'/workspace 工作区绝对路径 切换工作区',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
||||
|
|
@ -321,6 +323,18 @@ export class WecomHarnessBridge {
|
|||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
const compactCommand = await runCompactCommand(
|
||||
text,
|
||||
this.#harness,
|
||||
this.#state,
|
||||
key,
|
||||
{ signal: this.#signal },
|
||||
);
|
||||
if (compactCommand) {
|
||||
await this.#sendImmediate(frame, chatId, compactCommand.message);
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
streamId = this.#generateReqId('stream');
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
validHarnessQuestion,
|
||||
} from '../shared/harness-question.mjs';
|
||||
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
||||
import { runCompactCommand } from '../shared/compact-command.mjs';
|
||||
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||
|
||||
|
|
@ -19,6 +20,7 @@ const HELP_TEXT = [
|
|||
'',
|
||||
'直接发送文字或带文字识别结果的语音即可继续当前会话。',
|
||||
'/new 开启一个全新会话',
|
||||
'/compact 压缩当前会话的较早上下文',
|
||||
'/workspace 工作区绝对路径 切换工作区',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
||||
|
|
@ -248,6 +250,18 @@ export class WeixinHarnessBridge {
|
|||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
const compactCommand = await runCompactCommand(
|
||||
text,
|
||||
this.#harness,
|
||||
this.#state,
|
||||
key,
|
||||
{ signal: this.#signal },
|
||||
);
|
||||
if (compactCommand) {
|
||||
await this.#send(sender, compactCommand.message, contextToken, runId);
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
let answer;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -76,6 +76,31 @@ function stateFixture() {
|
|||
};
|
||||
}
|
||||
|
||||
test('DingTalk executes /compact for the bound Session without prompting the model', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:staff-approved', 'session-compact');
|
||||
const sent = [];
|
||||
const executed = [];
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: { sendText: async (request) => sent.push(request) },
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
executeCommand: async (sessionId, line) => {
|
||||
executed.push({ sessionId, line });
|
||||
return { commandId: 'compact-dingtalk', result: { kind: 'success', text: 'No compactable history yet.' } };
|
||||
},
|
||||
ask: async () => assert.fail('/compact must not be submitted to the model'),
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
await bridge.accept(message('compact-dingtalk', '/compact'));
|
||||
|
||||
assert.deepEqual(executed, [{ sessionId: 'session-compact', line: '/compact' }]);
|
||||
assert.equal(sent.at(-1).text, '暂无可压缩的历史记录。');
|
||||
});
|
||||
|
||||
test('bridge maps a DingTalk direct conversation to one persistent Harness session', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ function controller() {
|
|||
test('Host exports the DingTalk plugin identity and required services', () => {
|
||||
const plugin = createDingtalkHostPlugin({ controller: controller() });
|
||||
assert.equal(name, 'dsh-dingtalk-host');
|
||||
assert.deepEqual(inject, ['connection', 'credentials', 'webServer']);
|
||||
assert.deepEqual(inject, ['connection', 'credentials', 'webServer', 'typertGateway']);
|
||||
assert.equal(plugin.name, name);
|
||||
assert.deepEqual(plugin.inject, inject);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -79,6 +79,32 @@ function textClient(sendText) {
|
|||
};
|
||||
}
|
||||
|
||||
test('Feishu executes /compact for the bound Session without prompting the model', async () => {
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-compact']]);
|
||||
const sent = [];
|
||||
const executed = [];
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async ({ text }) => sent.push(text)),
|
||||
channel: {},
|
||||
harness: {
|
||||
executeCommand: async (sessionId, line) => {
|
||||
executed.push({ sessionId, line });
|
||||
return { commandId: 'compact-feishu', result: { kind: 'success', text: 'No compactable history yet.' } };
|
||||
},
|
||||
ask: async () => assert.fail('/compact must not be submitted to the model'),
|
||||
},
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
});
|
||||
|
||||
await bridge.accept(event('compact-feishu', '/compact'));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.deepEqual(executed, [{ sessionId: 'session-compact', line: '/compact' }]);
|
||||
assert.deepEqual(sent, ['暂无可压缩的历史记录。']);
|
||||
});
|
||||
|
||||
test('bridge maps a Feishu conversation to a persistent Harness session and replies', async () => {
|
||||
const sent = [];
|
||||
const reactions = [];
|
||||
|
|
|
|||
|
|
@ -51,6 +51,30 @@ function message(overrides = {}) {
|
|||
};
|
||||
}
|
||||
|
||||
test('QQ executes /compact for the bound Session without prompting the model', async () => {
|
||||
const fixture = stateFixture([['c2c:owner-openid', 'session-compact']]);
|
||||
const sent = [];
|
||||
const executed = [];
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
executeCommand: async (sessionId, line) => {
|
||||
executed.push({ sessionId, line });
|
||||
return { commandId: 'compact-qq', result: { kind: 'success', text: 'Compacted 3 history items (~900 tokens).' } };
|
||||
},
|
||||
ask: async () => assert.fail('/compact must not be submitted to the model'),
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
await bridge.accept(message({ messageId: 'compact-qq', content: '/compact' }));
|
||||
|
||||
assert.deepEqual(executed, [{ sessionId: 'session-compact', line: '/compact' }]);
|
||||
assert.deepEqual(sent, ['已压缩 3 条历史记录(约 900 个 token)。']);
|
||||
assert.equal(fixture.seen.has('compact-qq'), true);
|
||||
});
|
||||
|
||||
test('QQ private messages stream Harness snapshots and finalize once', async () => {
|
||||
const frames = [];
|
||||
const sent = [];
|
||||
|
|
|
|||
|
|
@ -117,6 +117,38 @@ function createBridge({ harness, state, bot, signal, logger } = {}) {
|
|||
});
|
||||
}
|
||||
|
||||
test('all four shared text channels execute /compact outside the model prompt path', async () => {
|
||||
for (const [name, Bridge] of [
|
||||
['slack', SlackHarnessBridge],
|
||||
['telegram', TelegramHarnessBridge],
|
||||
['discord', DiscordHarnessBridge],
|
||||
['whatsapp', WhatsappHarnessBridge],
|
||||
]) {
|
||||
const fixture = stateFixture({ 'direct:chat-a': `session-${name}` });
|
||||
const sent = [];
|
||||
const executed = [];
|
||||
const bridge = new Bridge({
|
||||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||||
state: fixture.state,
|
||||
harness: {
|
||||
executeCommand: async (sessionId, line) => {
|
||||
executed.push({ sessionId, line });
|
||||
return {
|
||||
commandId: `command-${name}`,
|
||||
result: { kind: 'success', text: 'Compacted 12 history items (~3456 tokens).' },
|
||||
};
|
||||
},
|
||||
ask: async () => assert.fail('/compact must not be submitted to the model'),
|
||||
},
|
||||
});
|
||||
|
||||
await bridge.accept(message(`compact-${name}`, '/compact'));
|
||||
|
||||
assert.deepEqual(executed, [{ sessionId: `session-${name}`, line: '/compact' }]);
|
||||
assert.deepEqual(sent, ['已压缩 12 条历史记录(约 3456 个 token)。']);
|
||||
}
|
||||
});
|
||||
|
||||
test('answers a multi-question interaction on the fast lane with canonical values', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
|
|
|
|||
|
|
@ -91,6 +91,31 @@ function questionInteraction({
|
|||
};
|
||||
}
|
||||
|
||||
test('Enterprise WeChat executes /compact for the bound Session without prompting the model', async () => {
|
||||
const store = state();
|
||||
const transport = testClient();
|
||||
const executed = [];
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: transport.client,
|
||||
generateStreamId: () => 'unused-stream',
|
||||
harness: {
|
||||
executeCommand: async (sessionId, line) => {
|
||||
executed.push({ sessionId, line });
|
||||
return { commandId: 'compact-wecom', result: { kind: 'success', text: 'No compactable history yet.' } };
|
||||
},
|
||||
ask: async () => assert.fail('/compact must not be submitted to the model'),
|
||||
},
|
||||
state: store,
|
||||
});
|
||||
|
||||
await bridge.accept(frame({ msgid: 'compact-wecom', text: { content: '/compact' } }));
|
||||
|
||||
assert.deepEqual(executed, [{ sessionId: 'session-existing', line: '/compact' }]);
|
||||
assert.equal(transport.streamed.at(-1).content, '暂无可压缩的历史记录。');
|
||||
assert.equal(transport.streamed.at(-1).finish, true);
|
||||
assert.deepEqual(transport.active, []);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat messages stream Harness progress and finalize once', async () => {
|
||||
const replies = [];
|
||||
const active = [];
|
||||
|
|
|
|||
|
|
@ -63,6 +63,33 @@ function stateFixture() {
|
|||
};
|
||||
}
|
||||
|
||||
test('Weixin executes /compact for the bound Session without prompting the model', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-compact');
|
||||
const sent = [];
|
||||
const executed = [];
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: { sendText: async (request) => sent.push(request) },
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: {
|
||||
executeCommand: async (sessionId, line) => {
|
||||
executed.push({ sessionId, line });
|
||||
return { commandId: 'compact-weixin', result: { kind: 'success', text: 'No compactable history yet.' } };
|
||||
},
|
||||
ask: async () => assert.fail('/compact must not be submitted to the model'),
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
await bridge.accept(message('compact-weixin', '/compact'));
|
||||
|
||||
assert.deepEqual(executed, [{ sessionId: 'session-compact', line: '/compact' }]);
|
||||
assert.equal(sent.at(-1).text, '暂无可压缩的历史记录。');
|
||||
assert.equal(fixture.seen.has('compact-weixin'), true);
|
||||
});
|
||||
|
||||
test('bridge maps the scanning Weixin user to one persistent Harness session and echoes context_token', async () => {
|
||||
const sent = [];
|
||||
const asked = [];
|
||||
|
|
|
|||
171
test/compact-command.test.mjs
Normal file
171
test/compact-command.test.mjs
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
import { runCompactCommand } from '../src/channels/shared/compact-command.mjs';
|
||||
import {
|
||||
HarnessClient,
|
||||
HarnessRpcError,
|
||||
} from '../src/channels/shared/harness-client.mjs';
|
||||
import { createHarnessCommandExecutor } from '../plugin-src/host/harness-command-executor.mjs';
|
||||
|
||||
function state(sessionId = 'session-one') {
|
||||
return { sessionFor: () => sessionId };
|
||||
}
|
||||
|
||||
test('compact command validates syntax and requires an existing conversation Session', async () => {
|
||||
assert.equal(await runCompactCommand('hello', {}, state(), 'direct:one'), null);
|
||||
assert.match(
|
||||
(await runCompactCommand('/compact now', {}, state(), 'direct:one')).message,
|
||||
/不带参数/,
|
||||
);
|
||||
assert.match(
|
||||
(await runCompactCommand('/COMPACT', {}, state(null), 'direct:one')).message,
|
||||
/还没有可压缩的会话/,
|
||||
);
|
||||
assert.match(
|
||||
(await runCompactCommand('/compact', {}, state(), 'direct:one')).message,
|
||||
/暂不支持/,
|
||||
);
|
||||
});
|
||||
|
||||
test('compact command renders Harness outcomes and never changes the command line', async () => {
|
||||
const calls = [];
|
||||
const harness = {
|
||||
executeCommand: async (sessionId, line, options) => {
|
||||
calls.push({ sessionId, line, options });
|
||||
return {
|
||||
commandId: 'command-one',
|
||||
result: { kind: 'error', text: 'Compaction cancelled.' },
|
||||
};
|
||||
},
|
||||
};
|
||||
const signal = new AbortController().signal;
|
||||
const result = await runCompactCommand(
|
||||
' /COMPACT ',
|
||||
harness,
|
||||
state(),
|
||||
'direct:one',
|
||||
{ signal },
|
||||
);
|
||||
|
||||
assert.equal(result.message, '上下文压缩已取消。');
|
||||
assert.deepEqual(calls, [{
|
||||
sessionId: 'session-one',
|
||||
line: '/compact',
|
||||
options: { signal },
|
||||
}]);
|
||||
});
|
||||
|
||||
test('compact command contains unavailable, busy, stale, and invalid command failures', async () => {
|
||||
for (const [failure, pattern] of [
|
||||
[{ code: 'session-not-found' }, /会话已不存在/],
|
||||
[{ code: 'agent-busy' }, /正在生成回复/],
|
||||
[{ code: 'workspace-session-stale' }, /状态已发生变化/],
|
||||
[{ code: 'commands-unavailable' }, /暂不支持/],
|
||||
[new Error('private internal detail'), /压缩失败/],
|
||||
]) {
|
||||
const result = await runCompactCommand('/compact', {
|
||||
executeCommand: async () => { throw failure; },
|
||||
}, state(), 'direct:one');
|
||||
assert.match(result.message, pattern);
|
||||
assert.doesNotMatch(result.message, /private internal detail/);
|
||||
}
|
||||
|
||||
assert.match((await runCompactCommand('/compact', {
|
||||
executeCommand: async () => undefined,
|
||||
}, state(), 'direct:one')).message, /未注册/);
|
||||
assert.match((await runCompactCommand('/compact', {
|
||||
executeCommand: async () => ({ commandId: 'bad', result: { kind: 'other' } }),
|
||||
}, state(), 'direct:one')).message, /压缩失败/);
|
||||
});
|
||||
|
||||
test('HarnessClient delegates command execution and normalizes Typert lookup failures', async () => {
|
||||
const calls = [];
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
workspace: '/tmp',
|
||||
commandExecutor: async (...args) => {
|
||||
calls.push(args);
|
||||
return { commandId: 'command-one', result: { kind: 'success' } };
|
||||
},
|
||||
});
|
||||
const signal = new AbortController().signal;
|
||||
assert.deepEqual(await client.executeCommand('session-one', '/compact', { signal }), {
|
||||
commandId: 'command-one',
|
||||
result: { kind: 'success' },
|
||||
});
|
||||
assert.deepEqual(calls, [['session-one', '/compact', { signal }]]);
|
||||
|
||||
const unavailable = new HarnessClient({ baseUrl: 'http://127.0.0.1:1', workspace: '/tmp' });
|
||||
await assert.rejects(unavailable.executeCommand('session-one', '/compact'), {
|
||||
code: 'commands-unavailable',
|
||||
});
|
||||
|
||||
const rejected = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
workspace: '/tmp',
|
||||
commandExecutor: async () => {
|
||||
const error = new Error('lookup rejected');
|
||||
error.failure = { code: 'agent-busy', message: 'busy', details: { reason: 'turn active' } };
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
rejected.executeCommand('session-one', '/compact'),
|
||||
(error) => error instanceof HarnessRpcError && error.code === 'agent-busy',
|
||||
);
|
||||
});
|
||||
|
||||
test('Host command executor invokes the commands Typert endpoint with the Session identity', async () => {
|
||||
const requests = [];
|
||||
const executor = createHarnessCommandExecutor({
|
||||
typertGateway: { invoke: async (request) => {
|
||||
requests.push(request);
|
||||
return { commandId: 'command-one', result: { kind: 'success' } };
|
||||
} },
|
||||
});
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
assert.deepEqual(await executor('session-one', '/compact', { signal }), {
|
||||
commandId: 'command-one',
|
||||
result: { kind: 'success' },
|
||||
});
|
||||
assert.deepEqual(requests, [{
|
||||
namespace: 'commands',
|
||||
method: 'execute',
|
||||
args: { agentId: 'session-one', line: '/compact' },
|
||||
signal,
|
||||
}]);
|
||||
assert.equal(createHarnessCommandExecutor({}), undefined);
|
||||
assert.throws(() => createHarnessCommandExecutor({}, 'invalid'), /must be a function/);
|
||||
});
|
||||
|
||||
test('all nine production channels receive the Host command executor', async () => {
|
||||
const productionFiles = [
|
||||
'plugin-src/host/channels/feishu/production.mjs',
|
||||
'plugin-src/host/channels/weixin/production.mjs',
|
||||
'plugin-src/host/channels/dingtalk/production.mjs',
|
||||
'plugin-src/host/channels/wecom/production.mjs',
|
||||
'plugin-src/host/channels/qq/production.mjs',
|
||||
'plugin-src/host/channels/slack/production.mjs',
|
||||
'plugin-src/host/channels/shared/production.mjs',
|
||||
'plugin-src/host/channels/whatsapp/production.mjs',
|
||||
];
|
||||
for (const path of productionFiles) {
|
||||
const source = await readFile(new URL(`../${path}`, import.meta.url), 'utf8');
|
||||
assert.match(source, /createHarnessCommandExecutor\(ctx, internals\.commandExecutor\)/, path);
|
||||
assert.match(source, /commandExecutor \? \{ commandExecutor \} : \{\}/, path);
|
||||
}
|
||||
|
||||
for (const channel of [
|
||||
'feishu', 'weixin', 'dingtalk', 'wecom', 'qq',
|
||||
'slack', 'telegram', 'discord', 'whatsapp',
|
||||
]) {
|
||||
const source = await readFile(
|
||||
new URL(`../plugin-src/host/channels/${channel}/index.mjs`, import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(source, /'typertGateway'/, channel);
|
||||
}
|
||||
});
|
||||
|
|
@ -33,7 +33,7 @@ test('Host composes all nine IM channels inside one plugin context', async () =>
|
|||
await plugin.apply(ctx, config);
|
||||
|
||||
assert.equal(name, 'dsh-im-host');
|
||||
assert.deepEqual(inject, ['connection', 'credentials', 'webServer']);
|
||||
assert.deepEqual(inject, ['connection', 'credentials', 'webServer', 'typertGateway']);
|
||||
assert.deepEqual(calls, [
|
||||
['feishu', ctx, { ...config.feishu, rpcAuthority: 'trusted-host' }],
|
||||
['weixin', ctx, { ...config.weixin, rpcAuthority: 'trusted-host' }],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue