mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-08 23:13:17 +08:00
feat: add agent preset chat commands
This commit is contained in:
parent
2ad6f1a44e
commit
eea29fe296
33 changed files with 1519 additions and 234 deletions
13
README.en.md
13
README.en.md
|
|
@ -106,6 +106,10 @@ Each Telegram bot has its own access-mode control on its bot card. Existing and
|
|||
| `/models` | List every currently configured model with a number. |
|
||||
| `/model` | Show the model used by the Session bound to this chat. |
|
||||
| `/model <number or provider/model-id>` | Switch the model for the Session bound to this chat. |
|
||||
| `/presetlist` | List the Host's currently available Agent Presets, marking the Host default and this bot's selection. |
|
||||
| `/preset` | Show this bot's Agent Preset setting for new Sessions. |
|
||||
| `/preset <number or Preset ID>` | Set this bot's Agent Preset; use `/preset id:<ID>` for a numeric ID. |
|
||||
| `/preset --default` | Clear this bot's explicit selection so later new Sessions follow the Host default. |
|
||||
| `/stop` | Immediately stop this chat's running task while preserving work that has not started. |
|
||||
| `/steer <additional instruction>` | Inject an additional instruction into this chat's running task. |
|
||||
| `/compact` | Immediately compact older context in the Session bound to the current chat. |
|
||||
|
|
@ -116,7 +120,9 @@ Each Telegram bot has its own access-mode control on its bot card. Existing and
|
|||
| Interactive question | Reply with an option number, option label, or custom text; separate multiple choices with commas. |
|
||||
| Remote approval | Reply with `批准` / `拒绝` / `同意` / `不同意` / `yes` / `no`. |
|
||||
|
||||
Example: send `/models`, then `/model 2` to switch to the second model in the list. Other examples: `/help`, `/new`, `/status`, `/model deepseek-official/deepseek-v4-pro`, `/steer inspect only the configuration file`, `/stop`, `/compact`, `/workspace /Users/alice/projects/my-app`, `/sessionlist 2`, `/sessionlist /Users/alice/projects/my-app`, or `/session session-id`
|
||||
Example: send `/models`, then `/model 2` to switch to the second model in the list; send `/presetlist`, then `/preset 2` to select the second Agent Preset for this bot. Other examples: `/help`, `/new`, `/status`, `/model deepseek-official/deepseek-v4-pro`, `/preset marketing-jeep`, `/preset --default`, `/steer inspect only the configuration file`, `/stop`, `/compact`, `/workspace /Users/alice/projects/my-app`, `/sessionlist 2`, `/sessionlist /Users/alice/projects/my-app`, or `/session session-id`
|
||||
|
||||
If the Slack desktop app has no native Slash Command registered with the same name, it intercepts messages that begin directly with `/`. Send the command with one leading space instead, for example ` /presetlist` or ` /preset 2`; the plugin command layer trims surrounding whitespace, so it executes exactly like the unspaced form.
|
||||
|
||||
### Command details
|
||||
|
||||
|
|
@ -126,6 +132,10 @@ Example: send `/models`, then `/model 2` to switch to the second model in the li
|
|||
- `/models` takes no arguments and never creates a Session. It assigns a number to every currently configured Harness model and also shows its stable, copyable `provider/model-id`. If one provider fails, models from the remaining providers are still shown.
|
||||
- Bare `/model` only displays the current Session model. A model can be selected by the number or exact full ID returned by `/models`, for example `/model 2`. When the chat has no Session yet, a valid switch creates and binds a blank Session without prompting the model. The switch affects only that Session; Harness also attempts to save it as the default for future Sessions, while other existing Sessions remain unchanged.
|
||||
- A model cannot be switched while a task is running or waiting for an approval or question answer. Wait for it to finish or use `/stop` first. A Session containing images cannot switch to a model that does not accept image input.
|
||||
- `/presetlist` takes no arguments and never creates a Session. It reads the Host's currently available Agent Presets every time, showing their names, stable IDs, the Host default, and this bot's selection. A selected Preset that has been deleted or become broken is retained and marked unavailable instead of being cleared automatically. Only safe names and IDs are shown; paths, errors, and other Host internals are never exposed.
|
||||
- Bare `/preset` shows this bot's setting for future new Sessions; it does not inspect or change the current Session. With an argument, it accepts a number from the most recent `/presetlist` in this chat or an exact ID; use `/preset id:<ID>` for a numeric ID. A numbered selection resolves the ID from that displayed list and then validates it against the latest Host catalog, asking for a fresh list if it has changed.
|
||||
- `/preset --default` clears this bot's explicit override so future Sessions resolve the Host default when they are created. Explicitly selecting an ID that currently matches the Host default pins that ID instead. Following the Host default remains available even while the catalog cannot be read.
|
||||
- An Agent Preset change is bot-wide: it affects future new Sessions in every chat for this bot, but never modifies, stops, unbinds, or rebuilds an existing Session and never runs `/new` automatically. If this chat already has a Session, ordinary messages keep using it; the first ordinary message after `/new` creates a Session with the new setting. Presets can still be queried or changed while a task is running or awaiting interaction because the command does not touch that Session.
|
||||
- `/stop` and `/steer` control only a running task started by this chat. Even when multiple chats bind the same Session, they do not intentionally control another chat's task. `/stop` does not delete the Session or its history, preserves queued work that has not started, and is safe to repeat.
|
||||
- `/steer` accepts text only, including multiple lines. It neither creates another Session nor starts a second task. Send an ordinary message when no task is running; while an approval or question is pending, answer it first or use `/stop`.
|
||||
- `/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.
|
||||
|
|
@ -137,6 +147,7 @@ Example: send `/models`, then `/model 2` to switch to the second model in the li
|
|||
- `/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 within the platform bot's visibility scope who can normally message it can run these commands; there is no separate administrator role. Telegram Compatible mode follows the original DM and group mention/reply rules. Safe mode admits only private users in that bot's allowlist and always ignores group commands.
|
||||
- Agent Preset names and IDs come from the same Harness Host, and any command-authorized user can change the Preset used by all future new Sessions across this bot's chats. Expose `/presetlist` and `/preset` only to trusted users.
|
||||
- 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.
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -109,6 +109,10 @@ GitHub 源安装会直接拉取并构建 Git 依赖;pnpm 10 及以上版本可
|
|||
| `/models` | 按序号列出当前配置的全部可用模型。 |
|
||||
| `/model` | 查看当前聊天绑定会话正在使用的模型。 |
|
||||
| `/model <序号或 Provider/模型ID>` | 切换当前聊天绑定会话的模型。 |
|
||||
| `/presetlist` | 按序号列出 Host 当前可用的 Agent Preset,并标记 Host 默认项和当前机器人的选择。 |
|
||||
| `/preset` | 查看当前机器人的新会话 Agent Preset 设置。 |
|
||||
| `/preset <序号或 Preset ID>` | 设置当前机器人的 Agent Preset;纯数字 ID 使用 `/preset id:<ID>`。 |
|
||||
| `/preset --default` | 清除当前机器人的显式选择,让后续新 Session 跟随 Host 默认。 |
|
||||
| `/stop` | 立即停止当前聊天正在运行的任务,并保留尚未开始的排队消息。 |
|
||||
| `/steer <补充指令>` | 把补充指令立即加入当前聊天正在运行的任务。 |
|
||||
| `/compact` | 立即压缩当前聊天绑定会话的较早上下文。 |
|
||||
|
|
@ -119,7 +123,9 @@ GitHub 源安装会直接拉取并构建 Git 依赖;pnpm 10 及以上版本可
|
|||
| 交互式提问 | 回复选项序号、选项文字或自定义文字;多选时用逗号分隔。 |
|
||||
| 远程审批 | 回复 `批准` / `拒绝` / `同意` / `不同意` / `yes` / `no`。 |
|
||||
|
||||
示例:先发送 `/models`,再发送 `/model 2` 切换到列表中的第 2 个模型。其他命令示例:`/help`、`/new`、`/status`、`/model deepseek-official/deepseek-v4-pro`、`/steer 只检查配置文件`、`/stop`、`/compact`、`/workspace /Users/alice/projects/my-app`、`/sessionlist 2`、`/sessionlist /Users/alice/projects/my-app` 或 `/session session-id`
|
||||
示例:先发送 `/models`,再发送 `/model 2` 切换到列表中的第 2 个模型;先发送 `/presetlist`,再发送 `/preset 2` 为当前机器人选择第 2 个 Agent Preset。其他命令示例:`/help`、`/new`、`/status`、`/model deepseek-official/deepseek-v4-pro`、`/preset marketing-jeep`、`/preset --default`、`/steer 只检查配置文件`、`/stop`、`/compact`、`/workspace /Users/alice/projects/my-app`、`/sessionlist 2`、`/sessionlist /Users/alice/projects/my-app` 或 `/session session-id`
|
||||
|
||||
Slack 桌面端若未注册同名的原生 Slash Command,会拦截直接以 `/` 开头的消息。此时请加一个前导空格发送,例如 ` /presetlist` 或 ` /preset 2`;插件命令层会去除首尾空白,执行效果与无空格命令相同。
|
||||
|
||||
### 命令说明
|
||||
|
||||
|
|
@ -129,6 +135,10 @@ GitHub 源安装会直接拉取并构建 Git 依赖;pnpm 10 及以上版本可
|
|||
- `/models` 不需要参数,也不会创建会话。它为 Harness 当前配置的全部可用模型分配序号,同时显示可稳定复制的 `Provider/模型ID`;某个 Provider 查询失败时,其他 Provider 的结果仍会显示。
|
||||
- `/model` 不带参数时只查看当前会话模型;带参数时接受 `/models` 列出的序号或完整模型 ID,例如 `/model 2`。完整 ID 必须精确匹配。聊天尚无会话时,有效的切换命令会创建并绑定一个空白会话,但不会触发模型回复。切换只影响当前会话;Harness 还会尝试把它保存为以后新会话的默认模型,已有其他会话不受影响。
|
||||
- 正在运行任务或等待审批、问题回答时不能切换模型;请等待完成,或先使用 `/stop`。含图片的会话无法切换到不支持图片输入的模型。
|
||||
- `/presetlist` 不需要参数,也不会创建会话。它每次都读取 Host 当前可用的 Agent Preset,显示名称、稳定 ID、Host 默认项和当前机器人的选择;已删除或损坏的当前选择会保留并标记为“已不可用”,不会被自动清除。列表只公开安全的名称和 ID,不公开 Preset 路径、错误或其他 Host 内部字段。
|
||||
- `/preset` 不带参数时查看当前机器人的“新会话设置”,不是查看或修改当前 Session。带参数时接受最近一次 `/presetlist` 在当前聊天中显示的序号或完整 ID;纯数字 ID 使用 `/preset id:<ID>`。选择序号时会先按该次列表解析 ID,再用 Host 最新目录复验,目录已经变化时会要求重新列出。
|
||||
- `/preset --default` 清除当前机器人的显式覆盖值,让以后新建的 Session 在创建时跟随 Host 当前默认;显式选择一个恰好等于 Host 默认的 ID 则会固定该 ID。目录暂时不可读时仍可恢复为跟随 Host 默认。
|
||||
- Agent Preset 修改是机器人级配置,会影响该机器人所有聊天以后创建的新 Session,但不会修改、停止、解绑或重建已有 Session,也不会自动执行 `/new`。若当前聊天已有会话,继续发送消息仍使用原 Session;发送 `/new` 后的下一条普通消息才会按新设置创建 Session。任务正在运行或等待交互时也可查询或修改 Preset,因为命令不会触碰当前 Session。
|
||||
- `/stop` 和 `/steer` 只控制当前聊天自己发起的运行任务,即使多个聊天绑定同一个 Session,也不会有意控制其他聊天的任务。`/stop` 不删除会话或历史,并保留尚未开始的排队消息;重复发送是安全的。
|
||||
- `/steer` 只接受文字,可包含多行;它不会创建新会话或第二个任务。没有运行任务时请直接发送普通消息;等待审批或问题回答时请先处理交互,或使用 `/stop`。
|
||||
- `/compact` 只作用于当前聊天已经绑定的 Harness 会话,不会把命令发送给模型。当前聊天尚未创建会话、会话正在生成回复或没有可压缩历史时,机器人会直接返回对应状态。
|
||||
|
|
@ -140,6 +150,7 @@ GitHub 源安装会直接拉取并构建 Git 依赖;pnpm 10 及以上版本可
|
|||
- `/session` 会自动定位会话唯一所属的工作区。同工作区绑定只替换当前聊天的映射;跨工作区绑定会切换该机器人的工作区、清除该机器人所有聊天的旧会话映射,再绑定当前聊天,因此会影响该机器人的其他聊天。已经开始生成的回复仍可完成。
|
||||
- 工作区切换和会话绑定只会清除或替换 dsh-im 的聊天映射,不会删除、清空或归档任何旧 Session 内容;旧 Session 仍可再次列出和绑定。
|
||||
- 任何已在对应平台可见范围内、能够正常向机器人发消息的用户都可以执行这些命令,不区分管理员和普通用户。Telegram 兼容模式遵循原有私聊及群聊提及/回复规则;安全模式只允许当前机器人白名单中的私聊用户执行,群聊命令始终忽略。
|
||||
- Agent Preset 名称和 ID 来自同一个 Harness Host,且任何有命令权限的用户都能修改该机器人所有聊天未来新 Session 的 Preset;请只向可信用户开放 `/presetlist` 和 `/preset`。
|
||||
- 工作区列表来自 Harness Host 的全局登记信息,可能包含其他机器人、其他渠道或非 IM 项目的本机绝对路径。请将机器人可见范围限制给可信用户。
|
||||
- 会话列表同样来自该全局 Harness Host;会话 ID 和标题可能属于其他机器人、其他渠道或非 IM 项目,并可能包含敏感元数据。开放命令前请确保所有可见用户都可信。
|
||||
- 任何能执行 `/session` 的用户都能接续所选会话,并通过后续消息写入会话或触发其可用工具。请只向可信用户开放机器人及其会话列表。
|
||||
|
|
|
|||
314
lib/index.js
314
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -53,6 +53,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
const logger = typeof ctx.logger === 'function'
|
||||
? ctx.logger('dsh-dingtalk')
|
||||
: (ctx.logger ?? console);
|
||||
const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
|
||||
const paths = pluginPaths(config);
|
||||
const configStore = await new ConfigStore(paths.config).load();
|
||||
const defaultWorkspace = resolve(config.workspace ?? process.cwd());
|
||||
|
|
@ -106,7 +107,9 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
createRuntime: async ({ botId, config: botConfig, clientSecret }) => {
|
||||
const state = await stateFor(botId);
|
||||
await workspaces.ensure(botId, { defaultAgentPreset: config.agentPreset });
|
||||
const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
|
||||
const workspaceScope = createBotWorkspaceScope(harness, {
|
||||
botId, workspaces, state, agentPresetCatalog,
|
||||
});
|
||||
return new Runtime({
|
||||
config: botConfig,
|
||||
clientSecret,
|
||||
|
|
@ -140,7 +143,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
const controller = createWorkspaceAwareController(coreController, {
|
||||
workspaces,
|
||||
stateFor,
|
||||
agentPresetCatalog: () => listAgentPresetCatalog(ctx),
|
||||
agentPresetCatalog,
|
||||
});
|
||||
const supervisor = createSupervisor({
|
||||
controller,
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
const logger = typeof ctx.logger === 'function'
|
||||
? ctx.logger('dsh-feishu')
|
||||
: (ctx.logger ?? console);
|
||||
const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
|
||||
const paths = pluginPaths(config);
|
||||
const configStore = await new ConfigStore(paths.config).load();
|
||||
const defaultWorkspace = resolve(config.workspace ?? process.cwd());
|
||||
|
|
@ -136,7 +137,9 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
const state = await stateFor(botConfig);
|
||||
const id = botId ?? botConfig.id ?? botConfig.appId;
|
||||
await workspaces.ensure(id, { defaultAgentPreset: config.agentPreset });
|
||||
const workspaceScope = createBotWorkspaceScope(harness, { botId: id, workspaces, state });
|
||||
const workspaceScope = createBotWorkspaceScope(harness, {
|
||||
botId: id, workspaces, state, agentPresetCatalog,
|
||||
});
|
||||
return new Runtime({
|
||||
lark,
|
||||
botId: id,
|
||||
|
|
@ -170,7 +173,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
const controller = createWorkspaceAwareController(coreController, {
|
||||
workspaces,
|
||||
stateFor: stateForBotId,
|
||||
agentPresetCatalog: () => listAgentPresetCatalog(ctx),
|
||||
agentPresetCatalog,
|
||||
});
|
||||
|
||||
const supervisor = createSupervisor({
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
const QrAuth = internals.QrAuth ?? QqQrAuth;
|
||||
const createSupervisor = internals.createConnectionSupervisor ?? createConnectionSupervisor;
|
||||
const logger = typeof ctx.logger === 'function' ? ctx.logger('dsh-im:qq') : (ctx.logger ?? console);
|
||||
const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
|
||||
const paths = pluginPaths(config);
|
||||
const configStore = await new ConfigStore(paths.config).load();
|
||||
const defaultWorkspace = resolve(config.workspace ?? process.cwd());
|
||||
|
|
@ -97,7 +98,9 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
createRuntime: async ({ botId, config: botConfig, appSecret }) => {
|
||||
const state = await stateFor(botId);
|
||||
await workspaces.ensure(botId, { defaultAgentPreset: config.agentPreset });
|
||||
const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
|
||||
const workspaceScope = createBotWorkspaceScope(harness, {
|
||||
botId, workspaces, state, agentPresetCatalog,
|
||||
});
|
||||
return new Runtime({
|
||||
config: botConfig,
|
||||
appSecret,
|
||||
|
|
@ -130,7 +133,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
const controller = createWorkspaceAwareController(coreController, {
|
||||
workspaces,
|
||||
stateFor,
|
||||
agentPresetCatalog: () => listAgentPresetCatalog(ctx),
|
||||
agentPresetCatalog,
|
||||
});
|
||||
const supervisor = createSupervisor({
|
||||
controller,
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export async function createTokenProductionController(ctx, config, internals, de
|
|||
const createSupervisor = internals.createConnectionSupervisor ?? createTokenConnectionSupervisor;
|
||||
const logger = typeof ctx.logger === 'function'
|
||||
? ctx.logger(`dsh-im:${channel}`) : (ctx.logger ?? console);
|
||||
const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
|
||||
const paths = pluginPaths(config, channel);
|
||||
const configStore = await new ResolvedConfigStore(paths.config).load();
|
||||
const defaultWorkspace = resolve(config.workspace ?? process.cwd());
|
||||
|
|
@ -98,7 +99,9 @@ export async function createTokenProductionController(ctx, config, internals, de
|
|||
createRuntime: async ({ botId, config: botConfig, token }) => {
|
||||
const state = await stateFor(botId);
|
||||
await workspaces.ensure(botId, { defaultAgentPreset: config.agentPreset });
|
||||
const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
|
||||
const workspaceScope = createBotWorkspaceScope(harness, {
|
||||
botId, workspaces, state, agentPresetCatalog,
|
||||
});
|
||||
return new ResolvedRuntime({
|
||||
...channelRuntimeOptions,
|
||||
config: botConfig,
|
||||
|
|
@ -132,7 +135,7 @@ export async function createTokenProductionController(ctx, config, internals, de
|
|||
const controller = createWorkspaceAwareController(coreController, {
|
||||
workspaces,
|
||||
stateFor,
|
||||
agentPresetCatalog: () => listAgentPresetCatalog(ctx),
|
||||
agentPresetCatalog,
|
||||
});
|
||||
const supervisor = createSupervisor({
|
||||
channel,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
const createSupervisor = internals.createConnectionSupervisor ?? createTokenConnectionSupervisor;
|
||||
const logger = typeof ctx.logger === 'function'
|
||||
? ctx.logger('dsh-im:slack') : (ctx.logger ?? console);
|
||||
const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
|
||||
const paths = pluginPaths(config, 'slack');
|
||||
const configStore = await new ResolvedConfigStore(paths.config).load();
|
||||
const defaultWorkspace = resolve(config.workspace ?? process.cwd());
|
||||
|
|
@ -76,7 +77,9 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
createRuntime: async ({ botId, config: botConfig, botToken, appToken }) => {
|
||||
const state = await stateFor(botId);
|
||||
await workspaces.ensure(botId, { defaultAgentPreset: config.agentPreset });
|
||||
const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
|
||||
const workspaceScope = createBotWorkspaceScope(harness, {
|
||||
botId, workspaces, state, agentPresetCatalog,
|
||||
});
|
||||
return new ResolvedRuntime({
|
||||
config: botConfig,
|
||||
botToken,
|
||||
|
|
@ -110,7 +113,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
const controller = createWorkspaceAwareController(coreController, {
|
||||
workspaces,
|
||||
stateFor,
|
||||
agentPresetCatalog: () => listAgentPresetCatalog(ctx),
|
||||
agentPresetCatalog,
|
||||
});
|
||||
const supervisor = createSupervisor({
|
||||
channel: 'slack',
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
const QrAuth = internals.QrAuth ?? WecomQrAuth;
|
||||
const createSupervisor = internals.createConnectionSupervisor ?? createConnectionSupervisor;
|
||||
const logger = typeof ctx.logger === 'function' ? ctx.logger('dsh-im:wecom') : (ctx.logger ?? console);
|
||||
const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
|
||||
const paths = pluginPaths(config);
|
||||
const configStore = await new ConfigStore(paths.config).load();
|
||||
const defaultWorkspace = resolve(config.workspace ?? process.cwd());
|
||||
|
|
@ -100,7 +101,9 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
createRuntime: async ({ botId, config: botConfig, secret }) => {
|
||||
const state = await stateFor(botId);
|
||||
await workspaces.ensure(botId, { defaultAgentPreset: config.agentPreset });
|
||||
const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
|
||||
const workspaceScope = createBotWorkspaceScope(harness, {
|
||||
botId, workspaces, state, agentPresetCatalog,
|
||||
});
|
||||
return new Runtime({
|
||||
config: botConfig,
|
||||
secret,
|
||||
|
|
@ -134,7 +137,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
const controller = createWorkspaceAwareController(coreController, {
|
||||
workspaces,
|
||||
stateFor,
|
||||
agentPresetCatalog: () => listAgentPresetCatalog(ctx),
|
||||
agentPresetCatalog,
|
||||
});
|
||||
const supervisor = createSupervisor({
|
||||
controller,
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
const logger = typeof ctx.logger === 'function'
|
||||
? ctx.logger('dsh-weixin')
|
||||
: (ctx.logger ?? console);
|
||||
const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
|
||||
const paths = pluginPaths(config);
|
||||
const configStore = await new ConfigStore(paths.config).load();
|
||||
const defaultWorkspace = resolve(config.workspace ?? process.cwd());
|
||||
|
|
@ -100,7 +101,9 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
createRuntime: async ({ botId, config: accountConfig, token }) => {
|
||||
const state = await stateFor(botId);
|
||||
await workspaces.ensure(botId, { defaultAgentPreset: config.agentPreset });
|
||||
const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
|
||||
const workspaceScope = createBotWorkspaceScope(harness, {
|
||||
botId, workspaces, state, agentPresetCatalog,
|
||||
});
|
||||
return new Runtime({
|
||||
api,
|
||||
config: accountConfig,
|
||||
|
|
@ -134,7 +137,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
const controller = createWorkspaceAwareController(coreController, {
|
||||
workspaces,
|
||||
stateFor,
|
||||
agentPresetCatalog: () => listAgentPresetCatalog(ctx),
|
||||
agentPresetCatalog,
|
||||
});
|
||||
const supervisor = createSupervisor({
|
||||
controller,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
if (!ctx?.webServer) throw new TypeError('dsh-im WhatsApp requires ctx.webServer');
|
||||
const logger = typeof ctx.logger === 'function'
|
||||
? ctx.logger('dsh-im:whatsapp') : (ctx.logger ?? console);
|
||||
const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
|
||||
const ConfigStore = internals.ConfigStore ?? WhatsappConfigStore;
|
||||
const StateStore = internals.StateStore ?? WhatsappStateStore;
|
||||
const Harness = internals.HarnessClient ?? WhatsappHarnessClient;
|
||||
|
|
@ -103,7 +104,9 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
createRuntime: async ({ botId, config: botConfig, authDir }) => {
|
||||
const state = await stateFor(botId);
|
||||
await workspaces.ensure(botId, { defaultAgentPreset: config.agentPreset });
|
||||
const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
|
||||
const workspaceScope = createBotWorkspaceScope(harness, {
|
||||
botId, workspaces, state, agentPresetCatalog,
|
||||
});
|
||||
return new Runtime({
|
||||
config: botConfig,
|
||||
authDir,
|
||||
|
|
@ -141,7 +144,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
const controller = createWorkspaceAwareController(coreController, {
|
||||
workspaces,
|
||||
stateFor,
|
||||
agentPresetCatalog: () => listAgentPresetCatalog(ctx),
|
||||
agentPresetCatalog,
|
||||
});
|
||||
const supervisor = createSupervisor({
|
||||
channel: 'whatsapp',
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ import {
|
|||
isModelCommand,
|
||||
runModelCommand,
|
||||
} from '../shared/model-command.mjs';
|
||||
import {
|
||||
isPresetCommand,
|
||||
runPresetCommand,
|
||||
} from '../shared/preset-command.mjs';
|
||||
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||
import {
|
||||
|
|
@ -44,6 +48,10 @@ const HELP_TEXT = [
|
|||
'/models 按序号列出所有可用模型',
|
||||
'/model [序号或完整模型ID] 查看或切换当前会话模型',
|
||||
'示例:先发 /models,再发 /model 2',
|
||||
'/presetlist 按序号列出可用 Agent Preset',
|
||||
'/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
|
||||
'纯数字 ID:/preset id:<ID>',
|
||||
'/preset --default 跟随 Host 默认',
|
||||
'/stop 停止当前任务',
|
||||
'/steer 补充指令 纠偏当前任务',
|
||||
'/status 检查连接状态',
|
||||
|
|
@ -336,7 +344,9 @@ export class DingtalkHarnessBridge {
|
|||
const commandText = nonEmptyString(promptMessage.content) ?? '';
|
||||
const commandRunner = isControlCommand(commandText)
|
||||
? runControlCommand
|
||||
: (isModelCommand(commandText) ? runModelCommand : null);
|
||||
: (isModelCommand(commandText)
|
||||
? runModelCommand
|
||||
: (isPresetCommand(commandText) ? runPresetCommand : null));
|
||||
const addressed = String(message.conversationType) !== '2' || message?.isInAtList === true;
|
||||
if (commandRunner && sessionWebhook && addressed) {
|
||||
let task;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ import {
|
|||
isModelCommand,
|
||||
runModelCommand,
|
||||
} from '../shared/model-command.mjs';
|
||||
import {
|
||||
isPresetCommand,
|
||||
runPresetCommand,
|
||||
} from '../shared/preset-command.mjs';
|
||||
import { runWorkspaceCommand, resolveSessionListWorkspace, workspacePathSnapshot } from '../shared/workspace-command.mjs';
|
||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||
import {
|
||||
|
|
@ -88,6 +92,10 @@ const HELP_TEXT = [
|
|||
'/models 按序号列出所有可用模型',
|
||||
'/model [序号或完整模型ID] 查看或切换当前会话模型',
|
||||
'示例:先发 /models,再发 /model 2',
|
||||
'/presetlist 按序号列出可用 Agent Preset',
|
||||
'/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
|
||||
'纯数字 ID:/preset id:<ID>',
|
||||
'/preset --default 跟随 Host 默认',
|
||||
'/stop 停止当前任务',
|
||||
'/steer 补充指令 纠偏当前任务',
|
||||
'/status 检查连接状态',
|
||||
|
|
@ -389,7 +397,9 @@ export class FeishuHarnessBridge {
|
|||
const commandText = nonEmptyString(commandMessage.content) ?? '';
|
||||
const commandRunner = isControlCommand(commandText)
|
||||
? runControlCommand
|
||||
: (isModelCommand(commandText) ? runModelCommand : null);
|
||||
: (isModelCommand(commandText)
|
||||
? runModelCommand
|
||||
: (isPresetCommand(commandText) ? runPresetCommand : null));
|
||||
if (commandRunner && addressed) {
|
||||
const processing = this.#processFastCommand(
|
||||
event,
|
||||
|
|
|
|||
|
|
@ -189,6 +189,10 @@ export function menuHelpText() {
|
|||
'/watch ID 或序号 关注会话(完成后推送)',
|
||||
'/compact 压缩上下文',
|
||||
'/workspace 绝对路径 切换工作区',
|
||||
'/presetlist 列出可用 Agent Preset',
|
||||
'/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
|
||||
'纯数字 ID:/preset id:<ID>',
|
||||
'/preset --default 跟随 Host 默认',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ import {
|
|||
isModelCommand,
|
||||
runModelCommand,
|
||||
} from '../shared/model-command.mjs';
|
||||
import {
|
||||
isPresetCommand,
|
||||
runPresetCommand,
|
||||
} from '../shared/preset-command.mjs';
|
||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||
import {
|
||||
fetchImageBuffer,
|
||||
|
|
@ -49,6 +53,10 @@ const HELP_TEXT = [
|
|||
'/models 按序号列出所有可用模型',
|
||||
'/model [序号或完整模型ID] 查看或切换当前会话模型',
|
||||
'示例:先发 /models,再发 /model 2',
|
||||
'/presetlist 按序号列出可用 Agent Preset',
|
||||
'/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
|
||||
'纯数字 ID:/preset id:<ID>',
|
||||
'/preset --default 跟随 Host 默认',
|
||||
'/stop 停止当前任务',
|
||||
'/steer 补充指令 纠偏当前任务',
|
||||
'/status 检查连接状态',
|
||||
|
|
@ -201,7 +209,9 @@ export class QqHarnessBridge {
|
|||
const commandText = safeText(message);
|
||||
const commandRunner = isControlCommand(commandText)
|
||||
? runControlCommand
|
||||
: (isModelCommand(commandText) ? runModelCommand : null);
|
||||
: (isModelCommand(commandText)
|
||||
? runModelCommand
|
||||
: (isPresetCommand(commandText) ? runPresetCommand : null));
|
||||
const allowed = this.#ownerUserOpenid === '*' || sender === this.#ownerUserOpenid;
|
||||
const addressed = message.kind !== 'group'
|
||||
|| message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
|
||||
|
|
|
|||
|
|
@ -535,6 +535,19 @@ function resolveAgentPresetCatalog(catalog) {
|
|||
: normalizeAgentPresetCatalog(value);
|
||||
}
|
||||
|
||||
function unavailableAgentPreset() {
|
||||
const error = new Error('Agent Preset 不存在或不可用。');
|
||||
error.code = 'agent-preset-unavailable';
|
||||
return error;
|
||||
}
|
||||
|
||||
function assertCurrentBotScope(isCurrentScope) {
|
||||
if (isCurrentScope()) return;
|
||||
const error = new Error('找不到要修改的机器人。');
|
||||
error.code = 'workspace-bot-not-found';
|
||||
throw error;
|
||||
}
|
||||
|
||||
function decorateResult(workspaces, result, catalog) {
|
||||
const decorate = (value) => {
|
||||
const decorated = workspaces.decorateStatus(value);
|
||||
|
|
@ -580,14 +593,74 @@ export function observeBotWorkspaceRemovals(
|
|||
});
|
||||
}
|
||||
|
||||
export function createBotWorkspaceScope(harness, { botId, workspaces, state }) {
|
||||
export function createBotWorkspaceScope(
|
||||
harness,
|
||||
{ botId, workspaces, state, agentPresetCatalog } = {},
|
||||
) {
|
||||
if (!harness || !workspaces || !state) throw new TypeError('harness, workspaces, and state are required');
|
||||
const incarnation = workspaces.incarnationFor(botId);
|
||||
const isCurrentScope = () => workspaces.has(botId)
|
||||
&& workspaces.incarnationFor(botId) === incarnation;
|
||||
const presetSettings = async (catalog = agentPresetCatalog) => {
|
||||
let normalizedCatalog;
|
||||
try {
|
||||
normalizedCatalog = await resolveAgentPresetCatalog(catalog)
|
||||
?? normalizeAgentPresetCatalog(null);
|
||||
} catch (error) {
|
||||
assertCurrentBotScope(isCurrentScope);
|
||||
throw error;
|
||||
}
|
||||
assertCurrentBotScope(isCurrentScope);
|
||||
return {
|
||||
agentPreset: workspaces.agentPresetFor(botId),
|
||||
agentPresetCatalog: normalizedCatalog,
|
||||
};
|
||||
};
|
||||
const sessionGenerations = new Map();
|
||||
const scopedHarness = new Proxy(harness, {
|
||||
get(target, property) {
|
||||
if (property === 'agentPresetSettings') {
|
||||
return async (options = {}) => {
|
||||
options?.signal?.throwIfAborted();
|
||||
assertCurrentBotScope(isCurrentScope);
|
||||
const settings = await presetSettings();
|
||||
options?.signal?.throwIfAborted();
|
||||
return settings;
|
||||
};
|
||||
}
|
||||
if (property === 'updateAgentPreset') {
|
||||
return async (value, options = {}) => {
|
||||
options?.signal?.throwIfAborted();
|
||||
assertCurrentBotScope(isCurrentScope);
|
||||
const agentPreset = value === '--default' ? null : validateAgentPresetId(value);
|
||||
let catalog = null;
|
||||
if (agentPreset) {
|
||||
({ agentPresetCatalog: catalog } = await presetSettings());
|
||||
options?.signal?.throwIfAborted();
|
||||
if (!catalog.items.some((item) => item.id === agentPreset)) {
|
||||
throw unavailableAgentPreset();
|
||||
}
|
||||
}
|
||||
await workspaces.setAgentPreset(botId, agentPreset, { incarnation });
|
||||
assertCurrentBotScope(isCurrentScope);
|
||||
if (catalog) {
|
||||
return {
|
||||
agentPreset: workspaces.agentPresetFor(botId),
|
||||
agentPresetCatalog: catalog,
|
||||
};
|
||||
}
|
||||
try {
|
||||
return await presetSettings();
|
||||
} catch (error) {
|
||||
if (error?.code === 'workspace-bot-not-found') throw error;
|
||||
assertCurrentBotScope(isCurrentScope);
|
||||
return {
|
||||
agentPreset: workspaces.agentPresetFor(botId),
|
||||
agentPresetCatalog: normalizeAgentPresetCatalog(null),
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
if (property === 'currentWorkspace') {
|
||||
return () => {
|
||||
if (!isCurrentScope()) {
|
||||
|
|
@ -912,9 +985,7 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
|
|||
: null;
|
||||
if (normalizedAgentPreset && agentPresetCatalog
|
||||
&& !catalog?.items.some((item) => item.id === normalizedAgentPreset)) {
|
||||
const error = new Error('Agent Preset 不存在或不可用。');
|
||||
error.code = 'agent-preset-unavailable';
|
||||
throw error;
|
||||
throw unavailableAgentPreset();
|
||||
}
|
||||
await workspaces.setAgentPreset(botId, normalizedAgentPreset, { incarnation });
|
||||
return decorateResult(
|
||||
|
|
|
|||
305
src/channels/shared/preset-command.mjs
Normal file
305
src/channels/shared/preset-command.mjs
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
import {
|
||||
normalizeAgentPresetCatalog,
|
||||
normalizeAgentPresetId,
|
||||
} from './agent-preset.mjs';
|
||||
import { withSessionBindingLock } from './session-binding-lock.mjs';
|
||||
import { splitWorkspaceCommandMessage } from './workspace-command.mjs';
|
||||
import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
|
||||
|
||||
const PRESET_COMMAND = /^\/preset(?=$|\s)/iu;
|
||||
const PRESET_LIST_COMMAND = /^\/presetlist(?=$|\s)/iu;
|
||||
const PRESET_LIST_USAGE = '用法:/presetlist(不带参数)';
|
||||
const PRESET_USAGE = [
|
||||
'用法:',
|
||||
'/preset 查看当前设置',
|
||||
'/preset <序号> 按最近一次 /presetlist 的序号选择',
|
||||
'/preset <ID> 按 Agent Preset ID 选择',
|
||||
'/preset id:<纯数字 ID> 选择纯数字 ID',
|
||||
'/preset --default 跟随 Host 默认',
|
||||
].join('\n');
|
||||
const UNSAFE_DISPLAY_TEXT_GLOBAL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu;
|
||||
const LIST_SNAPSHOTS = new WeakMap();
|
||||
export const PRESET_LIST_SNAPSHOT_TTL_MS = 15 * 60_000;
|
||||
export const PRESET_LIST_SNAPSHOT_MAX_ENTRIES = 256;
|
||||
|
||||
function commandResult(message) {
|
||||
return {
|
||||
handled: true,
|
||||
message,
|
||||
messages: splitWorkspaceCommandMessage(message),
|
||||
};
|
||||
}
|
||||
|
||||
function safeDisplayText(value) {
|
||||
if (typeof value !== 'string') return '';
|
||||
return value.replace(UNSAFE_DISPLAY_TEXT_GLOBAL, ' ').replace(/\s+/gu, ' ').trim();
|
||||
}
|
||||
|
||||
function rpcOptions(signal) {
|
||||
return signal ? { signal } : {};
|
||||
}
|
||||
|
||||
function normalizeSettings(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)
|
||||
|| !value.agentPresetCatalog || typeof value.agentPresetCatalog !== 'object'
|
||||
|| !Array.isArray(value.agentPresetCatalog.items)) {
|
||||
throw new TypeError('Harness returned invalid Agent Preset settings');
|
||||
}
|
||||
const agentPreset = value.agentPreset === null
|
||||
? null
|
||||
: normalizeAgentPresetId(value.agentPreset);
|
||||
if (value.agentPreset !== null && agentPreset === null) {
|
||||
throw new TypeError('Harness returned an invalid current Agent Preset');
|
||||
}
|
||||
return {
|
||||
agentPreset,
|
||||
agentPresetCatalog: normalizeAgentPresetCatalog(value.agentPresetCatalog),
|
||||
};
|
||||
}
|
||||
|
||||
function presetItemText(item) {
|
||||
const label = safeDisplayText(item.label) || item.id;
|
||||
return `${label}(${item.id})`;
|
||||
}
|
||||
|
||||
function itemFor(catalog, id) {
|
||||
return catalog.items.find((item) => item.id === id) ?? null;
|
||||
}
|
||||
|
||||
function defaultDescription(catalog) {
|
||||
if (!catalog.defaultId) return '未设置或当前不可用';
|
||||
const item = itemFor(catalog, catalog.defaultId);
|
||||
return item
|
||||
? presetItemText(item)
|
||||
: `${catalog.defaultId}(当前不可用)`;
|
||||
}
|
||||
|
||||
function currentDescription(settings) {
|
||||
const { agentPreset, agentPresetCatalog: catalog } = settings;
|
||||
if (agentPreset === null) {
|
||||
const item = itemFor(catalog, catalog.defaultId);
|
||||
return item
|
||||
? `跟随 Host 默认:${presetItemText(item)}`
|
||||
: '跟随 Host 默认(Host 默认当前不可用)';
|
||||
}
|
||||
const item = itemFor(catalog, agentPreset);
|
||||
return item
|
||||
? presetItemText(item)
|
||||
: `${agentPreset}(已不可用)`;
|
||||
}
|
||||
|
||||
function formatCurrent(settings) {
|
||||
return [
|
||||
'当前机器人用于新会话的 Agent Preset:',
|
||||
currentDescription(settings),
|
||||
'',
|
||||
'已有会话不会受此设置影响。',
|
||||
'查看可用项:/presetlist',
|
||||
'恢复跟随 Host 默认:/preset --default',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function formatList(settings) {
|
||||
const { agentPreset, agentPresetCatalog: catalog } = settings;
|
||||
const lines = [
|
||||
'当前机器人用于新会话的 Agent Preset:',
|
||||
currentDescription(settings),
|
||||
'',
|
||||
`Host 默认:${defaultDescription(catalog)}`,
|
||||
'',
|
||||
`可用 Agent Preset(${catalog.items.length}):`,
|
||||
];
|
||||
if (catalog.items.length === 0) {
|
||||
lines.push('当前没有可用 Agent Preset。');
|
||||
} else {
|
||||
catalog.items.forEach((item, index) => {
|
||||
const markers = [];
|
||||
if (item.id === catalog.defaultId) markers.push('Host 默认');
|
||||
if (item.id === agentPreset) markers.push('当前选择');
|
||||
if (agentPreset === null && item.id === catalog.defaultId) markers.push('当前生效');
|
||||
const annotation = markers.length > 0 ? `(${markers.join(',')})` : '';
|
||||
lines.push(`${index + 1}. ${presetItemText(item)}${annotation}`);
|
||||
});
|
||||
}
|
||||
lines.push(
|
||||
'',
|
||||
'选择:/preset <序号或 ID>',
|
||||
'纯数字 ID:/preset id:<ID>',
|
||||
'恢复跟随 Host 默认:/preset --default',
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatUpdated(settings) {
|
||||
return [
|
||||
'当前机器人用于新会话的 Agent Preset 已设置为:',
|
||||
currentDescription(settings),
|
||||
'',
|
||||
'已有会话不变。若当前聊天已有会话,请先发送 /new,再发送普通消息,才会使用新设置创建会话。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function stateSnapshots(state, { create = false } = {}) {
|
||||
if ((typeof state !== 'object' || state === null) && typeof state !== 'function') return null;
|
||||
let snapshots = LIST_SNAPSHOTS.get(state);
|
||||
if (!snapshots && create) {
|
||||
snapshots = new Map();
|
||||
LIST_SNAPSHOTS.set(state, snapshots);
|
||||
}
|
||||
return snapshots ?? null;
|
||||
}
|
||||
|
||||
function pruneExpiredSnapshots(snapshots, now) {
|
||||
for (const [snapshotKey, snapshot] of snapshots) {
|
||||
if (snapshot.expiresAt <= now) snapshots.delete(snapshotKey);
|
||||
}
|
||||
}
|
||||
|
||||
function saveSnapshot(state, key, items) {
|
||||
const snapshots = stateSnapshots(state, { create: true });
|
||||
if (!snapshots) return;
|
||||
const now = Date.now();
|
||||
pruneExpiredSnapshots(snapshots, now);
|
||||
snapshots.delete(key);
|
||||
snapshots.set(key, {
|
||||
expiresAt: now + PRESET_LIST_SNAPSHOT_TTL_MS,
|
||||
ids: items.map((item) => item.id),
|
||||
});
|
||||
while (snapshots.size > PRESET_LIST_SNAPSHOT_MAX_ENTRIES) {
|
||||
const oldest = snapshots.keys().next();
|
||||
if (oldest.done) break;
|
||||
snapshots.delete(oldest.value);
|
||||
}
|
||||
}
|
||||
|
||||
function loadSnapshot(state, key) {
|
||||
const snapshots = stateSnapshots(state);
|
||||
const snapshot = snapshots?.get(key);
|
||||
if (!snapshots || !snapshot) return null;
|
||||
if (snapshot.expiresAt <= Date.now()) {
|
||||
snapshots.delete(key);
|
||||
return null;
|
||||
}
|
||||
snapshots.delete(key);
|
||||
snapshots.set(key, snapshot);
|
||||
return snapshot.ids;
|
||||
}
|
||||
|
||||
function presetFromSnapshot(state, key, requested) {
|
||||
if (!/^\d+$/u.test(requested)) return { numeric: false, id: null };
|
||||
const index = Number(requested);
|
||||
if (!Number.isSafeInteger(index) || index < 1) {
|
||||
return { numeric: true, error: 'Agent Preset 序号无效,请先执行 /presetlist。' };
|
||||
}
|
||||
const snapshot = loadSnapshot(state, key);
|
||||
if (!snapshot) {
|
||||
return { numeric: true, error: '请先执行 /presetlist,再按列表序号选择 Agent Preset。' };
|
||||
}
|
||||
const id = snapshot[index - 1];
|
||||
return id
|
||||
? { numeric: true, id }
|
||||
: { numeric: true, error: 'Agent Preset 序号不存在,请重新执行 /presetlist。' };
|
||||
}
|
||||
|
||||
function errorCode(error) {
|
||||
return error?.code ?? error?.failure?.code;
|
||||
}
|
||||
|
||||
function presetErrorMessage(error, action) {
|
||||
const code = errorCode(error);
|
||||
if (code === 'agent-preset-invalid') {
|
||||
return `Agent Preset ID 格式无效。\n${PRESET_USAGE}`;
|
||||
}
|
||||
if (code === 'agent-preset-unavailable') {
|
||||
return 'Agent Preset 不存在或当前不可用,请重新执行 /presetlist。';
|
||||
}
|
||||
if (code === WORKSPACE_SESSION_STALE || code === 'workspace-bot-not-found') {
|
||||
return '工作区或机器人状态已发生变化,请重试。';
|
||||
}
|
||||
if (code === 'cancelled' || error?.name === 'AbortError') {
|
||||
if (action === 'list') return '获取 Agent Preset 列表已取消。';
|
||||
if (action === 'current') return '获取 Agent Preset 设置已取消。';
|
||||
return 'Agent Preset 修改已取消。';
|
||||
}
|
||||
if (action === 'list') return '暂时无法获取 Agent Preset 列表,请稍后重试。';
|
||||
if (action === 'current') return '暂时无法获取 Agent Preset 设置,请稍后重试。';
|
||||
return 'Agent Preset 修改失败,请稍后重试。';
|
||||
}
|
||||
|
||||
async function settings(harness, options) {
|
||||
if (typeof harness?.agentPresetSettings !== 'function') {
|
||||
throw new TypeError('Harness does not support Agent Preset settings');
|
||||
}
|
||||
return normalizeSettings(await harness.agentPresetSettings(options));
|
||||
}
|
||||
|
||||
async function update(harness, value, options) {
|
||||
if (typeof harness?.updateAgentPreset !== 'function') {
|
||||
throw new TypeError('Harness does not support updating Agent Preset settings');
|
||||
}
|
||||
return normalizeSettings(await harness.updateAgentPreset(value, options));
|
||||
}
|
||||
|
||||
export function isPresetCommand(text) {
|
||||
if (typeof text !== 'string') return false;
|
||||
const command = text.trim();
|
||||
return PRESET_LIST_COMMAND.test(command) || PRESET_COMMAND.test(command);
|
||||
}
|
||||
|
||||
export async function runPresetCommand(text, harness, state, key, options = {}) {
|
||||
if (!isPresetCommand(text)) return null;
|
||||
const command = text.trim();
|
||||
if (options.hasImages) {
|
||||
return commandResult('Agent Preset 命令仅支持纯文字,请移除图片后重试。');
|
||||
}
|
||||
const requestOptions = rpcOptions(options.signal);
|
||||
|
||||
if (PRESET_LIST_COMMAND.test(command)) {
|
||||
if (!/^\/presetlist[ \t]*$/iu.test(command)) return commandResult(PRESET_LIST_USAGE);
|
||||
try {
|
||||
const current = await settings(harness, requestOptions);
|
||||
saveSnapshot(state, key, current.agentPresetCatalog.items);
|
||||
return commandResult(formatList(current));
|
||||
} catch (error) {
|
||||
return commandResult(presetErrorMessage(error, 'list'));
|
||||
}
|
||||
}
|
||||
|
||||
const match = /^\/preset(?:[ \t]+([^\s]+))?[ \t]*$/iu.exec(command);
|
||||
if (!match) return commandResult(PRESET_USAGE);
|
||||
const requested = match[1];
|
||||
if (!requested) {
|
||||
try {
|
||||
return commandResult(formatCurrent(await settings(harness, requestOptions)));
|
||||
} catch (error) {
|
||||
return commandResult(presetErrorMessage(error, 'current'));
|
||||
}
|
||||
}
|
||||
|
||||
let selected;
|
||||
if (requested.toLowerCase() === '--default') {
|
||||
selected = null;
|
||||
} else {
|
||||
const explicitNumericId = /^id:(\d+)$/iu.exec(requested);
|
||||
if (explicitNumericId) {
|
||||
selected = explicitNumericId[1];
|
||||
} else {
|
||||
const fromSnapshot = presetFromSnapshot(state, key, requested);
|
||||
if (fromSnapshot.numeric) {
|
||||
if (fromSnapshot.error) return commandResult(fromSnapshot.error);
|
||||
selected = fromSnapshot.id;
|
||||
} else {
|
||||
selected = normalizeAgentPresetId(requested);
|
||||
if (!selected) return commandResult(`Agent Preset ID 格式无效。\n${PRESET_USAGE}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await withSessionBindingLock(state, key, async () => (
|
||||
commandResult(formatUpdated(await update(harness, selected, requestOptions)))
|
||||
));
|
||||
} catch (error) {
|
||||
return commandResult(presetErrorMessage(error, 'update'));
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,10 @@ import {
|
|||
isModelCommand,
|
||||
runModelCommand,
|
||||
} from './model-command.mjs';
|
||||
import {
|
||||
isPresetCommand,
|
||||
runPresetCommand,
|
||||
} from './preset-command.mjs';
|
||||
import { askInWorkspaceSession } from './workspace-session.mjs';
|
||||
import { HarnessApprovalQueue } from './harness-approval.mjs';
|
||||
import {
|
||||
|
|
@ -122,7 +126,9 @@ export class TextHarnessBridge {
|
|||
const text = cleanText(normalized.content);
|
||||
const commandRunner = isControlCommand(text)
|
||||
? runControlCommand
|
||||
: (isModelCommand(text) ? runModelCommand : null);
|
||||
: (isModelCommand(text)
|
||||
? runModelCommand
|
||||
: (isPresetCommand(text) ? runPresetCommand : null));
|
||||
if (commandRunner && (normalized.kind !== 'group' || normalized.addressed === true)) {
|
||||
let task;
|
||||
task = this.#processFastCommand(
|
||||
|
|
@ -319,6 +325,10 @@ export class TextHarnessBridge {
|
|||
'/models 按序号列出所有可用模型',
|
||||
'/model [序号或完整模型ID] 查看或切换当前会话模型',
|
||||
'示例:先发 /models,再发 /model 2',
|
||||
'/presetlist 按序号列出可用 Agent Preset',
|
||||
'/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
|
||||
'纯数字 ID:/preset id:<ID>',
|
||||
'/preset --default 跟随 Host 默认',
|
||||
'/stop 停止当前任务',
|
||||
'/steer 补充指令 纠偏当前任务',
|
||||
'/status 检查连接状态',
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ export const TELEGRAM_COMMAND_MENU = Object.freeze([
|
|||
{ command: 'session', description: '将当前聊天绑定到指定会话' },
|
||||
{ command: 'models', description: '按序号列出所有可用模型' },
|
||||
{ command: 'model', description: '查看或切换当前会话模型' },
|
||||
{ command: 'presetlist', description: '列出可用 Agent Preset' },
|
||||
{ command: 'preset', description: '查看或设置新会话 Agent Preset' },
|
||||
{ command: 'stop', description: '停止当前任务' },
|
||||
{ command: 'steer', description: '纠偏当前任务' },
|
||||
{ command: 'status', description: '检查连接状态' },
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ import {
|
|||
isModelCommand,
|
||||
runModelCommand,
|
||||
} from '../shared/model-command.mjs';
|
||||
import {
|
||||
isPresetCommand,
|
||||
runPresetCommand,
|
||||
} from '../shared/preset-command.mjs';
|
||||
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||
import {
|
||||
|
|
@ -37,6 +41,10 @@ const HELP_TEXT = [
|
|||
'/models 按序号列出所有可用模型',
|
||||
'/model [序号或完整模型ID] 查看或切换当前会话模型',
|
||||
'示例:先发 /models,再发 /model 2',
|
||||
'/presetlist 按序号列出可用 Agent Preset',
|
||||
'/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
|
||||
'纯数字 ID:/preset id:<ID>',
|
||||
'/preset --default 跟随 Host 默认',
|
||||
'/stop 停止当前任务',
|
||||
'/steer 补充指令 纠偏当前任务',
|
||||
'/status 检查连接状态',
|
||||
|
|
@ -292,7 +300,9 @@ export class WecomHarnessBridge {
|
|||
const commandText = nonEmptyString(commandMessage.content) ?? '';
|
||||
const commandRunner = isControlCommand(commandText)
|
||||
? runControlCommand
|
||||
: (isModelCommand(commandText) ? runModelCommand : null);
|
||||
: (isModelCommand(commandText)
|
||||
? runModelCommand
|
||||
: (isPresetCommand(commandText) ? runPresetCommand : null));
|
||||
if (commandRunner) {
|
||||
let task;
|
||||
task = this.#processFastCommand(
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ import {
|
|||
isModelCommand,
|
||||
runModelCommand,
|
||||
} from '../shared/model-command.mjs';
|
||||
import {
|
||||
isPresetCommand,
|
||||
runPresetCommand,
|
||||
} from '../shared/preset-command.mjs';
|
||||
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||
import {
|
||||
|
|
@ -45,6 +49,10 @@ const HELP_TEXT = [
|
|||
'/models 按序号列出所有可用模型',
|
||||
'/model [序号或完整模型ID] 查看或切换当前会话模型',
|
||||
'示例:先发 /models,再发 /model 2',
|
||||
'/presetlist 按序号列出可用 Agent Preset',
|
||||
'/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
|
||||
'纯数字 ID:/preset id:<ID>',
|
||||
'/preset --default 跟随 Host 默认',
|
||||
'/stop 停止当前任务',
|
||||
'/steer 补充指令 纠偏当前任务',
|
||||
'/status 检查连接状态',
|
||||
|
|
@ -166,7 +174,9 @@ export class WeixinHarnessBridge {
|
|||
const commandText = nonEmptyString(extractWeixinText(message)) ?? '';
|
||||
const commandRunner = isControlCommand(commandText)
|
||||
? runControlCommand
|
||||
: (isModelCommand(commandText) ? runModelCommand : null);
|
||||
: (isModelCommand(commandText)
|
||||
? runModelCommand
|
||||
: (isPresetCommand(commandText) ? runPresetCommand : null));
|
||||
if (commandRunner && sender === this.#ownerUserId) {
|
||||
let task;
|
||||
task = this.#processFastCommand(
|
||||
|
|
|
|||
|
|
@ -7,14 +7,9 @@ import test from 'node:test';
|
|||
import {
|
||||
BotWorkspaceStore,
|
||||
createBotWorkspaceScope,
|
||||
createWorkspaceAwareController,
|
||||
} from '../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { ConversationStateStore } from '../src/channels/shared/conversation-state-store.mjs';
|
||||
import { TextHarnessBridge } from '../src/channels/shared/text-harness-bridge.mjs';
|
||||
import {
|
||||
TOKEN_BOT_ENDPOINTS,
|
||||
createTokenBotRpcHandler,
|
||||
} from '../plugin-src/host/channels/shared/rpc.mjs';
|
||||
|
||||
function message(messageId, content) {
|
||||
return {
|
||||
|
|
@ -29,7 +24,7 @@ function message(messageId, content) {
|
|||
};
|
||||
}
|
||||
|
||||
test('an agent preset change applies only after /new creates the next session', async (t) => {
|
||||
test('/preset changes only the sessions created after /new and --default follows Host', async (t) => {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-im-preset-lifecycle-')));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
|
||||
|
|
@ -46,7 +41,7 @@ test('an agent preset change applies only after /new creates the next session',
|
|||
const sessions = new Set();
|
||||
const harness = {
|
||||
async createSession(options) {
|
||||
const sessionId = `session-${creations.length === 0 ? 'a' : 'b'}`;
|
||||
const sessionId = `session-${String.fromCharCode(97 + creations.length)}`;
|
||||
creations.push({ sessionId, options });
|
||||
sessions.add(sessionId);
|
||||
return sessionId;
|
||||
|
|
@ -59,7 +54,19 @@ test('an agent preset change applies only after /new creates the next session',
|
|||
return `answer-from-${sessionId}`;
|
||||
},
|
||||
};
|
||||
const scope = createBotWorkspaceScope(harness, { botId, workspaces, state });
|
||||
const agentPresetCatalog = {
|
||||
defaultId: 'preset-old',
|
||||
items: [
|
||||
{ id: 'preset-old', label: 'Old preset' },
|
||||
{ id: 'preset-new', label: 'New preset' },
|
||||
],
|
||||
};
|
||||
const scope = createBotWorkspaceScope(harness, {
|
||||
botId,
|
||||
workspaces,
|
||||
state,
|
||||
agentPresetCatalog,
|
||||
});
|
||||
const sent = [];
|
||||
const bridge = new TextHarnessBridge({
|
||||
descriptor: { key: 'test', label: 'Test' },
|
||||
|
|
@ -69,25 +76,6 @@ test('an agent preset change applies only after /new creates the next session',
|
|||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const baseController = {
|
||||
status() { return { bots: [{ botId, connected: true }] }; },
|
||||
bindCredentials() { return this.status(); },
|
||||
reconnectBot() { return this.status(); },
|
||||
deleteBot() { return { bots: [] }; },
|
||||
};
|
||||
const controller = createWorkspaceAwareController(baseController, {
|
||||
workspaces,
|
||||
stateFor: async () => state,
|
||||
agentPresetCatalog: {
|
||||
defaultId: 'preset-old',
|
||||
items: [
|
||||
{ id: 'preset-old', label: 'Old preset' },
|
||||
{ id: 'preset-new', label: 'New preset' },
|
||||
],
|
||||
},
|
||||
});
|
||||
const rpc = createTokenBotRpcHandler(controller, { channel: 'Test' });
|
||||
|
||||
await bridge.accept(message('message-one', 'first prompt'));
|
||||
assert.equal(state.sessionFor(conversationKey), 'session-a');
|
||||
assert.deepEqual(creations, [{
|
||||
|
|
@ -95,13 +83,14 @@ test('an agent preset change applies only after /new creates the next session',
|
|||
options: { workspace: root, agentPreset: 'preset-old' },
|
||||
}]);
|
||||
|
||||
const updated = await rpc(TOKEN_BOT_ENDPOINTS.setAgentPreset, {
|
||||
botId,
|
||||
agentPreset: 'preset-new',
|
||||
});
|
||||
assert.equal(updated.ok, true);
|
||||
assert.equal(updated.value.bots[0].agentPreset, 'preset-new');
|
||||
await bridge.accept(message('message-list', '/presetlist'));
|
||||
assert.match(sent.at(-1), /2\. New preset(preset-new)/u);
|
||||
|
||||
await bridge.accept(message('message-preset', '/preset 2'));
|
||||
assert.equal(workspaces.agentPresetFor(botId), 'preset-new');
|
||||
assert.match(sent.at(-1), /已有会话不变/u);
|
||||
assert.equal(state.sessionFor(conversationKey), 'session-a');
|
||||
assert.equal(creations.length, 1, '/preset itself must not create a session');
|
||||
|
||||
await bridge.accept(message('message-two', 'still in the current chat'));
|
||||
assert.equal(creations.length, 1, 'the current chat must reuse session A');
|
||||
|
|
@ -123,15 +112,24 @@ test('an agent preset change applies only after /new creates the next session',
|
|||
options: { workspace: root, agentPreset: 'preset-new' },
|
||||
},
|
||||
]);
|
||||
|
||||
await bridge.accept(message('message-default', '/preset --default'));
|
||||
assert.equal(workspaces.agentPresetFor(botId), null);
|
||||
assert.equal(state.sessionFor(conversationKey), 'session-b');
|
||||
assert.equal(creations.length, 2, '--default itself must not create a session');
|
||||
|
||||
await bridge.accept(message('message-new-default', '/new'));
|
||||
await bridge.accept(message('message-four', 'first prompt using Host default'));
|
||||
assert.equal(state.sessionFor(conversationKey), 'session-c');
|
||||
assert.deepEqual(creations.at(-1), {
|
||||
sessionId: 'session-c',
|
||||
options: { workspace: root },
|
||||
});
|
||||
assert.deepEqual(asks, [
|
||||
{ sessionId: 'session-a', text: 'first prompt' },
|
||||
{ sessionId: 'session-a', text: 'still in the current chat' },
|
||||
{ sessionId: 'session-b', text: 'first prompt after /new' },
|
||||
{ sessionId: 'session-c', text: 'first prompt using Host default' },
|
||||
]);
|
||||
assert.deepEqual(sent, [
|
||||
'answer-from-session-a',
|
||||
'answer-from-session-a',
|
||||
'已开启新会话。请发送你的问题。',
|
||||
'answer-from-session-b',
|
||||
]);
|
||||
assert.equal(sent.at(-1), 'answer-from-session-c');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -321,11 +321,20 @@ test('DingTalk executes /compact for the bound Session without prompting the mod
|
|||
assert.equal(sent.at(-1).text, '暂无可压缩的历史记录。');
|
||||
});
|
||||
|
||||
test('DingTalk lists models without prompting and help advertises all four commands', async () => {
|
||||
test('DingTalk lists models and presets without prompting and advertises fast commands', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const presetUpdates = [];
|
||||
let agentPreset = null;
|
||||
let asks = 0;
|
||||
let creates = 0;
|
||||
const agentPresetCatalog = {
|
||||
defaultId: 'preset-001',
|
||||
items: Array.from({ length: 70 }, (_, index) => ({
|
||||
id: `preset-${String(index + 1).padStart(3, '0')}`,
|
||||
label: `DingTalk Preset ${index + 1} ${'x'.repeat(64)}`,
|
||||
})),
|
||||
};
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: { sendText: async (request) => sent.push(request) },
|
||||
clientId: 'ding-client',
|
||||
|
|
@ -339,6 +348,12 @@ test('DingTalk lists models without prompting and help advertises all four comma
|
|||
}],
|
||||
failures: [],
|
||||
}),
|
||||
agentPresetSettings: async () => ({ agentPreset, agentPresetCatalog }),
|
||||
updateAgentPreset: async (value) => {
|
||||
presetUpdates.push(value);
|
||||
agentPreset = value;
|
||||
return { agentPreset, agentPresetCatalog };
|
||||
},
|
||||
createSession: async () => { creates += 1; return 'dingtalk-session'; },
|
||||
ask: async () => { asks += 1; return 'unexpected model reply'; },
|
||||
},
|
||||
|
|
@ -351,12 +366,42 @@ test('DingTalk lists models without prompting and help advertises all four comma
|
|||
assert.equal(creates, 0);
|
||||
assert.equal(fixture.sessions.size, 0);
|
||||
|
||||
const presetReplyStart = sent.length;
|
||||
await bridge.accept(message('presets-dingtalk', '/presetlist'));
|
||||
const presetReplies = sent.slice(presetReplyStart).map((entry) => entry.text);
|
||||
assert.ok(presetReplies.length > 1);
|
||||
assert.match(presetReplies.join('\n'), /preset-070/);
|
||||
assert.equal(asks, 0);
|
||||
assert.equal(creates, 0);
|
||||
assert.equal(fixture.sessions.size, 0);
|
||||
|
||||
await bridge.accept(message('preset-current-dingtalk', '/preset'));
|
||||
assert.match(sent.at(-1).text, /跟随 Host 默认/);
|
||||
assert.equal(asks, 0);
|
||||
assert.equal(creates, 0);
|
||||
|
||||
const selectReplyStart = sent.length;
|
||||
await bridge.accept(message('preset-select-dingtalk', '/preset 2'));
|
||||
assert.deepEqual(presetUpdates, ['preset-002']);
|
||||
assert.equal(sent.length, selectReplyStart + 1);
|
||||
assert.match(sent.at(-1).text, /preset-002/);
|
||||
|
||||
const defaultReplyStart = sent.length;
|
||||
await bridge.accept(message('preset-default-dingtalk', '/preset --default'));
|
||||
assert.deepEqual(presetUpdates, ['preset-002', null]);
|
||||
assert.equal(sent.length, defaultReplyStart + 1);
|
||||
assert.match(sent.at(-1).text, /跟随 Host 默认/);
|
||||
assert.equal(asks, 0);
|
||||
assert.equal(creates, 0);
|
||||
assert.equal(fixture.sessions.size, 0);
|
||||
|
||||
await bridge.accept(message('help-models-dingtalk', '/help'));
|
||||
const help = sent.at(-1).text;
|
||||
for (const command of ['/models', '/model', '/stop', '/steer']) {
|
||||
for (const command of ['/models', '/model', '/presetlist', '/preset', '/preset --default', '/stop', '/steer']) {
|
||||
assert.equal(help.includes(command), true, command);
|
||||
}
|
||||
assert.match(help, /\/model 2/);
|
||||
assert.match(help, /\/preset id:<ID>/);
|
||||
});
|
||||
|
||||
test('bridge maps a DingTalk direct conversation to one persistent Harness session', async () => {
|
||||
|
|
|
|||
|
|
@ -164,11 +164,20 @@ test('Feishu executes /compact for the bound Session without prompting the model
|
|||
assert.deepEqual(sent, ['暂无可压缩的历史记录。']);
|
||||
});
|
||||
|
||||
test('Feishu lists models without prompting and help advertises all four commands', async () => {
|
||||
test('Feishu lists models and presets without prompting and advertises fast commands', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const presetUpdates = [];
|
||||
let agentPreset = null;
|
||||
let asks = 0;
|
||||
let creates = 0;
|
||||
const agentPresetCatalog = {
|
||||
defaultId: 'preset-001',
|
||||
items: Array.from({ length: 70 }, (_, index) => ({
|
||||
id: `preset-${String(index + 1).padStart(3, '0')}`,
|
||||
label: `Feishu Preset ${index + 1} ${'x'.repeat(64)}`,
|
||||
})),
|
||||
};
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async ({ text }) => sent.push(text)),
|
||||
channel: {},
|
||||
|
|
@ -181,6 +190,12 @@ test('Feishu lists models without prompting and help advertises all four command
|
|||
}],
|
||||
failures: [],
|
||||
}),
|
||||
agentPresetSettings: async () => ({ agentPreset, agentPresetCatalog }),
|
||||
updateAgentPreset: async (value) => {
|
||||
presetUpdates.push(value);
|
||||
agentPreset = value;
|
||||
return { agentPreset, agentPresetCatalog };
|
||||
},
|
||||
createSession: async () => { creates += 1; return 'feishu-session'; },
|
||||
ask: async () => { asks += 1; return 'unexpected model reply'; },
|
||||
},
|
||||
|
|
@ -196,13 +211,47 @@ test('Feishu lists models without prompting and help advertises all four command
|
|||
assert.equal(creates, 0);
|
||||
assert.equal(fixture.sessions.size, 0);
|
||||
|
||||
const presetReplyStart = sent.length;
|
||||
await bridge.accept(event('presets-feishu', '/presetlist'));
|
||||
await bridge.waitForIdle();
|
||||
const presetReplies = sent.slice(presetReplyStart);
|
||||
assert.ok(presetReplies.length > 1);
|
||||
assert.match(presetReplies.join('\n'), /preset-070/);
|
||||
assert.equal(asks, 0);
|
||||
assert.equal(creates, 0);
|
||||
assert.equal(fixture.sessions.size, 0);
|
||||
|
||||
await bridge.accept(event('preset-current-feishu', '/preset'));
|
||||
await bridge.waitForIdle();
|
||||
assert.match(sent.at(-1), /跟随 Host 默认/);
|
||||
assert.equal(asks, 0);
|
||||
assert.equal(creates, 0);
|
||||
|
||||
const selectReplyStart = sent.length;
|
||||
await bridge.accept(event('preset-select-feishu', '/preset 2'));
|
||||
await bridge.waitForIdle();
|
||||
assert.deepEqual(presetUpdates, ['preset-002']);
|
||||
assert.equal(sent.length, selectReplyStart + 1);
|
||||
assert.match(sent.at(-1), /preset-002/);
|
||||
|
||||
const defaultReplyStart = sent.length;
|
||||
await bridge.accept(event('preset-default-feishu', '/preset --default'));
|
||||
await bridge.waitForIdle();
|
||||
assert.deepEqual(presetUpdates, ['preset-002', null]);
|
||||
assert.equal(sent.length, defaultReplyStart + 1);
|
||||
assert.match(sent.at(-1), /跟随 Host 默认/);
|
||||
assert.equal(asks, 0);
|
||||
assert.equal(creates, 0);
|
||||
assert.equal(fixture.sessions.size, 0);
|
||||
|
||||
await bridge.accept(event('help-feishu', '/help'));
|
||||
await bridge.waitForIdle();
|
||||
const help = sent.at(-1);
|
||||
for (const command of ['/models', '/model', '/stop', '/steer']) {
|
||||
for (const command of ['/models', '/model', '/presetlist', '/preset', '/preset --default', '/stop', '/steer']) {
|
||||
assert.equal(help.includes(command), true, command);
|
||||
}
|
||||
assert.match(help, /\/model 2/);
|
||||
assert.match(help, /\/preset id:<ID>/);
|
||||
});
|
||||
|
||||
test('bridge maps a Feishu conversation to a persistent Harness session and replies', async () => {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import test from 'node:test';
|
|||
import {
|
||||
cardActionProbeCard,
|
||||
menuCard,
|
||||
menuHelpText,
|
||||
} from '../../../src/channels/feishu/feishu-cards.mjs';
|
||||
|
||||
function buttons(value, result = []) {
|
||||
|
|
@ -26,6 +27,14 @@ test('menu appends watchlist and keeps repair number-only', () => {
|
|||
assert.equal(actions.includes('repair'), false);
|
||||
});
|
||||
|
||||
test('menu help advertises Agent Preset commands', () => {
|
||||
const help = menuHelpText();
|
||||
assert.match(help, /\/presetlist/);
|
||||
assert.match(help, /\/preset \[序号或完整ID\]/);
|
||||
assert.match(help, /\/preset id:<ID>/);
|
||||
assert.match(help, /\/preset --default/);
|
||||
});
|
||||
|
||||
test('card-action probe carries only its action and opaque nonce', () => {
|
||||
const nonce = '0123456789abcdef0123456789abcdef';
|
||||
const card = JSON.parse(cardActionProbeCard(nonce));
|
||||
|
|
|
|||
|
|
@ -259,11 +259,20 @@ test('QQ executes /compact for the bound Session without prompting the model', a
|
|||
assert.equal(fixture.seen.has('compact-qq'), true);
|
||||
});
|
||||
|
||||
test('QQ lists models without prompting and help advertises all four commands', async () => {
|
||||
test('QQ lists models and presets without prompting and advertises fast commands', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const presetUpdates = [];
|
||||
let agentPreset = null;
|
||||
let asks = 0;
|
||||
let creates = 0;
|
||||
const agentPresetCatalog = {
|
||||
defaultId: 'preset-001',
|
||||
items: Array.from({ length: 70 }, (_, index) => ({
|
||||
id: `preset-${String(index + 1).padStart(3, '0')}`,
|
||||
label: `QQ Preset ${index + 1} ${'x'.repeat(64)}`,
|
||||
})),
|
||||
};
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
|
|
@ -276,6 +285,12 @@ test('QQ lists models without prompting and help advertises all four commands',
|
|||
}],
|
||||
failures: [],
|
||||
}),
|
||||
agentPresetSettings: async () => ({ agentPreset, agentPresetCatalog }),
|
||||
updateAgentPreset: async (value) => {
|
||||
presetUpdates.push(value);
|
||||
agentPreset = value;
|
||||
return { agentPreset, agentPresetCatalog };
|
||||
},
|
||||
createSession: async () => { creates += 1; return 'qq-session'; },
|
||||
ask: async () => { asks += 1; return 'unexpected model reply'; },
|
||||
},
|
||||
|
|
@ -288,12 +303,42 @@ test('QQ lists models without prompting and help advertises all four commands',
|
|||
assert.equal(creates, 0);
|
||||
assert.equal(fixture.sessions.size, 0);
|
||||
|
||||
const presetReplyStart = sent.length;
|
||||
await bridge.accept(message({ messageId: 'presets-qq', content: '/presetlist' }));
|
||||
const presetReplies = sent.slice(presetReplyStart);
|
||||
assert.ok(presetReplies.length > 1);
|
||||
assert.match(presetReplies.join('\n'), /preset-070/);
|
||||
assert.equal(asks, 0);
|
||||
assert.equal(creates, 0);
|
||||
assert.equal(fixture.sessions.size, 0);
|
||||
|
||||
await bridge.accept(message({ messageId: 'preset-current-qq', content: '/preset' }));
|
||||
assert.match(sent.at(-1), /跟随 Host 默认/);
|
||||
assert.equal(asks, 0);
|
||||
assert.equal(creates, 0);
|
||||
|
||||
const selectReplyStart = sent.length;
|
||||
await bridge.accept(message({ messageId: 'preset-select-qq', content: '/preset 2' }));
|
||||
assert.deepEqual(presetUpdates, ['preset-002']);
|
||||
assert.equal(sent.length, selectReplyStart + 1);
|
||||
assert.match(sent.at(-1), /preset-002/);
|
||||
|
||||
const defaultReplyStart = sent.length;
|
||||
await bridge.accept(message({ messageId: 'preset-default-qq', content: '/preset --default' }));
|
||||
assert.deepEqual(presetUpdates, ['preset-002', null]);
|
||||
assert.equal(sent.length, defaultReplyStart + 1);
|
||||
assert.match(sent.at(-1), /跟随 Host 默认/);
|
||||
assert.equal(asks, 0);
|
||||
assert.equal(creates, 0);
|
||||
assert.equal(fixture.sessions.size, 0);
|
||||
|
||||
await bridge.accept(message({ messageId: 'help-models-qq', content: '/help' }));
|
||||
const help = sent.at(-1);
|
||||
for (const command of ['/models', '/model', '/stop', '/steer']) {
|
||||
for (const command of ['/models', '/model', '/presetlist', '/preset', '/preset --default', '/stop', '/steer']) {
|
||||
assert.equal(help.includes(command), true, command);
|
||||
}
|
||||
assert.match(help, /\/model 2/);
|
||||
assert.match(help, /\/preset id:<ID>/);
|
||||
});
|
||||
|
||||
test('QQ remembers any authorized private inbound as a connection-test target', async () => {
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ test('all four shared text channels execute /compact outside the model prompt pa
|
|||
}
|
||||
});
|
||||
|
||||
test('all four shared text channels list models locally and advertise all four commands', async () => {
|
||||
test('all four shared text channels list models and presets locally and advertise fast commands', async () => {
|
||||
for (const [name, Bridge] of [
|
||||
['slack', SlackHarnessBridge],
|
||||
['telegram', TelegramHarnessBridge],
|
||||
|
|
@ -173,8 +173,17 @@ test('all four shared text channels list models locally and advertise all four c
|
|||
]) {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const presetUpdates = [];
|
||||
let agentPreset = null;
|
||||
let asks = 0;
|
||||
let creates = 0;
|
||||
const agentPresetCatalog = {
|
||||
defaultId: 'preset-001',
|
||||
items: Array.from({ length: 70 }, (_, index) => ({
|
||||
id: `preset-${String(index + 1).padStart(3, '0')}`,
|
||||
label: `${name} Preset ${index + 1} ${'x'.repeat(64)}`,
|
||||
})),
|
||||
};
|
||||
const bridge = new Bridge({
|
||||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||||
state: fixture.state,
|
||||
|
|
@ -187,6 +196,12 @@ test('all four shared text channels list models locally and advertise all four c
|
|||
}],
|
||||
failures: [],
|
||||
}),
|
||||
agentPresetSettings: async () => ({ agentPreset, agentPresetCatalog }),
|
||||
updateAgentPreset: async (value) => {
|
||||
presetUpdates.push(value);
|
||||
agentPreset = value;
|
||||
return { agentPreset, agentPresetCatalog };
|
||||
},
|
||||
createSession: async () => { creates += 1; return `${name}-session`; },
|
||||
ask: async () => { asks += 1; return 'unexpected model reply'; },
|
||||
},
|
||||
|
|
@ -198,12 +213,42 @@ test('all four shared text channels list models locally and advertise all four c
|
|||
assert.equal(creates, 0, `${name} create`);
|
||||
assert.equal(fixture.sessions.size, 0, `${name} session binding`);
|
||||
|
||||
const presetReplyStart = sent.length;
|
||||
await bridge.accept(message(`presets-${name}`, '/presetlist'));
|
||||
const presetReplies = sent.slice(presetReplyStart);
|
||||
assert.ok(presetReplies.length > 1, `${name} sends every preset-list chunk`);
|
||||
assert.match(presetReplies.join('\n'), /preset-070/, name);
|
||||
assert.equal(asks, 0, `${name} preset ask`);
|
||||
assert.equal(creates, 0, `${name} preset create`);
|
||||
assert.equal(fixture.sessions.size, 0, `${name} preset session binding`);
|
||||
|
||||
await bridge.accept(message(`preset-current-${name}`, '/preset'));
|
||||
assert.match(sent.at(-1), /跟随 Host 默认/, name);
|
||||
assert.equal(asks, 0, `${name} preset current ask`);
|
||||
assert.equal(creates, 0, `${name} preset current create`);
|
||||
|
||||
const selectReplyStart = sent.length;
|
||||
await bridge.accept(message(`preset-select-${name}`, '/preset 2'));
|
||||
assert.deepEqual(presetUpdates, ['preset-002'], `${name} scoped preset update`);
|
||||
assert.equal(sent.length, selectReplyStart + 1, `${name} sends the complete select reply`);
|
||||
assert.match(sent.at(-1), /preset-002/, name);
|
||||
|
||||
const defaultReplyStart = sent.length;
|
||||
await bridge.accept(message(`preset-default-${name}`, '/preset --default'));
|
||||
assert.deepEqual(presetUpdates, ['preset-002', null], `${name} scoped preset reset`);
|
||||
assert.equal(sent.length, defaultReplyStart + 1, `${name} sends the complete default reply`);
|
||||
assert.match(sent.at(-1), /跟随 Host 默认/, name);
|
||||
assert.equal(asks, 0, `${name} mutation ask`);
|
||||
assert.equal(creates, 0, `${name} mutation create`);
|
||||
assert.equal(fixture.sessions.size, 0, `${name} mutation session binding`);
|
||||
|
||||
await bridge.accept(message(`help-${name}`, '/help'));
|
||||
const help = sent.at(-1);
|
||||
for (const command of ['/models', '/model', '/stop', '/steer']) {
|
||||
for (const command of ['/models', '/model', '/presetlist', '/preset', '/preset --default', '/stop', '/steer']) {
|
||||
assert.match(help, new RegExp(`\\${command}`), `${name} ${command}`);
|
||||
}
|
||||
assert.match(help, /\/model 2/, `${name} numbered model selection`);
|
||||
assert.match(help, /\/preset id:<ID>/, `${name} numeric preset ID selection`);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -168,6 +168,13 @@ test('Telegram API registers the command menu and commands-type menu button', as
|
|||
await api.setMyCommands({ commands: TELEGRAM_COMMAND_MENU });
|
||||
await api.setChatMenuButton();
|
||||
assert.equal(calls.length, 2);
|
||||
assert.deepEqual(
|
||||
TELEGRAM_COMMAND_MENU.filter(({ command }) => command === 'presetlist' || command === 'preset'),
|
||||
[
|
||||
{ command: 'presetlist', description: '列出可用 Agent Preset' },
|
||||
{ command: 'preset', description: '查看或设置新会话 Agent Preset' },
|
||||
],
|
||||
);
|
||||
assert.match(calls[0].url.pathname, /setMyCommands$/);
|
||||
assert.deepEqual(calls[0].body, { commands: TELEGRAM_COMMAND_MENU });
|
||||
assert.match(calls[1].url.pathname, /setChatMenuButton$/);
|
||||
|
|
|
|||
|
|
@ -153,12 +153,21 @@ test('Enterprise WeChat executes /compact for the bound Session without promptin
|
|||
assert.deepEqual(transport.active, []);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat lists models without prompting and help advertises all four commands', async () => {
|
||||
test('Enterprise WeChat lists models and presets without prompting and advertises fast commands', async () => {
|
||||
const store = state();
|
||||
store.sessionFor = () => null;
|
||||
const transport = testClient();
|
||||
const presetUpdates = [];
|
||||
let agentPreset = null;
|
||||
let asks = 0;
|
||||
let creates = 0;
|
||||
const agentPresetCatalog = {
|
||||
defaultId: 'preset-001',
|
||||
items: Array.from({ length: 70 }, (_, index) => ({
|
||||
id: `preset-${String(index + 1).padStart(3, '0')}`,
|
||||
label: `WeCom Preset ${index + 1} ${'x'.repeat(64)}`,
|
||||
})),
|
||||
};
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: transport.client,
|
||||
generateStreamId: () => 'models-stream',
|
||||
|
|
@ -171,6 +180,12 @@ test('Enterprise WeChat lists models without prompting and help advertises all f
|
|||
}],
|
||||
failures: [],
|
||||
}),
|
||||
agentPresetSettings: async () => ({ agentPreset, agentPresetCatalog }),
|
||||
updateAgentPreset: async (value) => {
|
||||
presetUpdates.push(value);
|
||||
agentPreset = value;
|
||||
return { agentPreset, agentPresetCatalog };
|
||||
},
|
||||
createSession: async () => { creates += 1; return 'wecom-session'; },
|
||||
ask: async () => { asks += 1; return 'unexpected model reply'; },
|
||||
},
|
||||
|
|
@ -182,12 +197,44 @@ test('Enterprise WeChat lists models without prompting and help advertises all f
|
|||
assert.equal(asks, 0);
|
||||
assert.equal(creates, 0);
|
||||
|
||||
const presetReplyStart = transport.streamed.length;
|
||||
await bridge.accept(frame({ msgid: 'presets-wecom', text: { content: '/presetlist' } }));
|
||||
const presetReplies = transport.streamed
|
||||
.slice(presetReplyStart)
|
||||
.map((entry) => entry.content);
|
||||
assert.ok(presetReplies.length > 1);
|
||||
assert.match(presetReplies.join('\n'), /preset-070/);
|
||||
assert.equal(asks, 0);
|
||||
assert.equal(creates, 0);
|
||||
|
||||
await bridge.accept(frame({ msgid: 'preset-current-wecom', text: { content: '/preset' } }));
|
||||
assert.match(transport.streamed.at(-1).content, /跟随 Host 默认/);
|
||||
assert.equal(asks, 0);
|
||||
assert.equal(creates, 0);
|
||||
|
||||
const selectReplyStart = transport.streamed.length;
|
||||
await bridge.accept(frame({ msgid: 'preset-select-wecom', text: { content: '/preset 2' } }));
|
||||
assert.deepEqual(presetUpdates, ['preset-002']);
|
||||
assert.equal(transport.streamed.length, selectReplyStart + 1);
|
||||
assert.match(transport.streamed.at(-1).content, /preset-002/);
|
||||
assert.equal(transport.streamed.at(-1).finish, true);
|
||||
|
||||
const defaultReplyStart = transport.streamed.length;
|
||||
await bridge.accept(frame({ msgid: 'preset-default-wecom', text: { content: '/preset --default' } }));
|
||||
assert.deepEqual(presetUpdates, ['preset-002', null]);
|
||||
assert.equal(transport.streamed.length, defaultReplyStart + 1);
|
||||
assert.match(transport.streamed.at(-1).content, /跟随 Host 默认/);
|
||||
assert.equal(transport.streamed.at(-1).finish, true);
|
||||
assert.equal(asks, 0);
|
||||
assert.equal(creates, 0);
|
||||
|
||||
await bridge.accept(frame({ msgid: 'help-models-wecom', text: { content: '/help' } }));
|
||||
const help = transport.streamed.at(-1).content;
|
||||
for (const command of ['/models', '/model', '/stop', '/steer']) {
|
||||
for (const command of ['/models', '/model', '/presetlist', '/preset', '/preset --default', '/stop', '/steer']) {
|
||||
assert.equal(help.includes(command), true, command);
|
||||
}
|
||||
assert.match(help, /\/model 2/);
|
||||
assert.match(help, /\/preset id:<ID>/);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat messages stream Harness progress and finalize once', async () => {
|
||||
|
|
|
|||
|
|
@ -262,11 +262,20 @@ test('Weixin executes /compact for the bound Session without prompting the model
|
|||
assert.equal(fixture.seen.has('compact-weixin'), true);
|
||||
});
|
||||
|
||||
test('Weixin lists models without prompting and help advertises all four commands', async () => {
|
||||
test('Weixin lists models and presets without prompting and advertises fast commands', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const presetUpdates = [];
|
||||
let agentPreset = null;
|
||||
let asks = 0;
|
||||
let creates = 0;
|
||||
const agentPresetCatalog = {
|
||||
defaultId: 'preset-001',
|
||||
items: Array.from({ length: 70 }, (_, index) => ({
|
||||
id: `preset-${String(index + 1).padStart(3, '0')}`,
|
||||
label: `Weixin Preset ${index + 1} ${'x'.repeat(64)}`,
|
||||
})),
|
||||
};
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: { sendText: async (request) => sent.push(request) },
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
|
|
@ -281,6 +290,12 @@ test('Weixin lists models without prompting and help advertises all four command
|
|||
}],
|
||||
failures: [],
|
||||
}),
|
||||
agentPresetSettings: async () => ({ agentPreset, agentPresetCatalog }),
|
||||
updateAgentPreset: async (value) => {
|
||||
presetUpdates.push(value);
|
||||
agentPreset = value;
|
||||
return { agentPreset, agentPresetCatalog };
|
||||
},
|
||||
createSession: async () => { creates += 1; return 'weixin-session'; },
|
||||
ask: async () => { asks += 1; return 'unexpected model reply'; },
|
||||
},
|
||||
|
|
@ -293,12 +308,42 @@ test('Weixin lists models without prompting and help advertises all four command
|
|||
assert.equal(creates, 0);
|
||||
assert.equal(fixture.sessions.size, 0);
|
||||
|
||||
const presetReplyStart = sent.length;
|
||||
await bridge.accept(message('presets-weixin', '/presetlist'));
|
||||
const presetReplies = sent.slice(presetReplyStart).map((entry) => entry.text);
|
||||
assert.ok(presetReplies.length > 1);
|
||||
assert.match(presetReplies.join('\n'), /preset-070/);
|
||||
assert.equal(asks, 0);
|
||||
assert.equal(creates, 0);
|
||||
assert.equal(fixture.sessions.size, 0);
|
||||
|
||||
await bridge.accept(message('preset-current-weixin', '/preset'));
|
||||
assert.match(sent.at(-1).text, /跟随 Host 默认/);
|
||||
assert.equal(asks, 0);
|
||||
assert.equal(creates, 0);
|
||||
|
||||
const selectReplyStart = sent.length;
|
||||
await bridge.accept(message('preset-select-weixin', '/preset 2'));
|
||||
assert.deepEqual(presetUpdates, ['preset-002']);
|
||||
assert.equal(sent.length, selectReplyStart + 1);
|
||||
assert.match(sent.at(-1).text, /preset-002/);
|
||||
|
||||
const defaultReplyStart = sent.length;
|
||||
await bridge.accept(message('preset-default-weixin', '/preset --default'));
|
||||
assert.deepEqual(presetUpdates, ['preset-002', null]);
|
||||
assert.equal(sent.length, defaultReplyStart + 1);
|
||||
assert.match(sent.at(-1).text, /跟随 Host 默认/);
|
||||
assert.equal(asks, 0);
|
||||
assert.equal(creates, 0);
|
||||
assert.equal(fixture.sessions.size, 0);
|
||||
|
||||
await bridge.accept(message('help-models-weixin', '/help'));
|
||||
const help = sent.at(-1).text;
|
||||
for (const command of ['/models', '/model', '/stop', '/steer']) {
|
||||
for (const command of ['/models', '/model', '/presetlist', '/preset', '/preset --default', '/stop', '/steer']) {
|
||||
assert.equal(help.includes(command), true, command);
|
||||
}
|
||||
assert.match(help, /\/model 2/);
|
||||
assert.match(help, /\/preset id:<ID>/);
|
||||
});
|
||||
|
||||
test('bridge maps the scanning Weixin user to one persistent Harness session and echoes context_token', async () => {
|
||||
|
|
|
|||
|
|
@ -185,9 +185,19 @@ test('all nine production channels use channel presets only as bot creation defa
|
|||
);
|
||||
assert.match(
|
||||
source,
|
||||
/agentPresetCatalog:\s*\(\)\s*=>\s*listAgentPresetCatalog\(ctx\)/,
|
||||
/const agentPresetCatalog\s*=\s*\(\)\s*=>\s*listAgentPresetCatalog\(ctx\)/,
|
||||
`${path} must read the Host preset catalog dynamically`,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/createBotWorkspaceScope\([^;]*agentPresetCatalog[^;]*\)/,
|
||||
`${path} must expose the same dynamic catalog to bot commands`,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/createWorkspaceAwareController\([^;]*agentPresetCatalog,?[^;]*\)/,
|
||||
`${path} must expose the same dynamic catalog to RPC updates`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const channel of ['telegram', 'discord']) {
|
||||
|
|
|
|||
403
test/preset-command.test.mjs
Normal file
403
test/preset-command.test.mjs
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
isPresetCommand,
|
||||
PRESET_LIST_SNAPSHOT_MAX_ENTRIES,
|
||||
PRESET_LIST_SNAPSHOT_TTL_MS,
|
||||
runPresetCommand,
|
||||
} from '../src/channels/shared/preset-command.mjs';
|
||||
import { withSessionBindingLock } from '../src/channels/shared/session-binding-lock.mjs';
|
||||
|
||||
const CATALOG = Object.freeze({
|
||||
defaultId: 'standard',
|
||||
items: Object.freeze([
|
||||
Object.freeze({ id: 'standard', label: 'Standard' }),
|
||||
Object.freeze({ id: 'coding', label: 'Coding' }),
|
||||
Object.freeze({ id: 'marketing', label: 'Marketing' }),
|
||||
]),
|
||||
});
|
||||
|
||||
function fixture({
|
||||
agentPreset = null,
|
||||
catalog = CATALOG,
|
||||
settingsError = null,
|
||||
updateError = null,
|
||||
updateHook = null,
|
||||
} = {}) {
|
||||
const calls = [];
|
||||
let selected = agentPreset;
|
||||
const harness = {
|
||||
async agentPresetSettings(options) {
|
||||
calls.push(['agentPresetSettings', options]);
|
||||
if (settingsError) throw settingsError;
|
||||
const currentCatalog = typeof catalog === 'function' ? catalog() : catalog;
|
||||
return { agentPreset: selected, agentPresetCatalog: currentCatalog };
|
||||
},
|
||||
async updateAgentPreset(value, options) {
|
||||
calls.push(['updateAgentPreset', value, options]);
|
||||
if (updateHook) await updateHook({ value, options });
|
||||
if (updateError) throw updateError;
|
||||
selected = value;
|
||||
const currentCatalog = typeof catalog === 'function' ? catalog() : catalog;
|
||||
return { agentPreset: selected, agentPresetCatalog: currentCatalog };
|
||||
},
|
||||
};
|
||||
return { calls, harness, state: {}, selected: () => selected };
|
||||
}
|
||||
|
||||
test('isPresetCommand recognizes only /presetlist and /preset command prefixes', () => {
|
||||
for (const command of [
|
||||
'/presetlist', ' /PRESETLIST ', '/presetlist ignored',
|
||||
'/preset', '/PrEsEt coding',
|
||||
]) {
|
||||
assert.equal(isPresetCommand(command), true, command);
|
||||
}
|
||||
for (const value of [
|
||||
null, '', 'preset', '/presetx', '/presetlisting', 'hello /preset',
|
||||
]) {
|
||||
assert.equal(isPresetCommand(value), false, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
test('/presetlist dynamically lists, annotates, and snapshots presets without updating', async () => {
|
||||
const { calls, harness, state } = fixture({ agentPreset: 'coding' });
|
||||
const signal = new AbortController().signal;
|
||||
const result = await runPresetCommand('/PRESETLIST', harness, state, 'direct:one', { signal });
|
||||
|
||||
assert.match(result.message, /当前机器人用于新会话/);
|
||||
assert.match(result.message, /Host 默认:Standard(standard)/);
|
||||
assert.match(result.message, /1\. Standard(standard)(Host 默认)/);
|
||||
assert.match(result.message, /2\. Coding(coding)(当前选择)/);
|
||||
assert.match(result.message, /\/preset --default/);
|
||||
assert.deepEqual(calls, [['agentPresetSettings', { signal }]]);
|
||||
|
||||
const selected = await runPresetCommand('/preset 2', harness, state, 'direct:one');
|
||||
assert.match(selected.message, /Coding(coding)/);
|
||||
assert.deepEqual(calls.at(-1), ['updateAgentPreset', 'coding', {}]);
|
||||
});
|
||||
|
||||
test('/presetlist shows following Host default as the effective preset', async () => {
|
||||
const { harness, state } = fixture();
|
||||
const result = await runPresetCommand('/presetlist', harness, state, 'direct:one');
|
||||
|
||||
assert.match(result.message, /跟随 Host 默认:Standard(standard)/);
|
||||
assert.match(result.message, /Standard(standard)(Host 默认,当前生效)/);
|
||||
});
|
||||
|
||||
test('/presetlist filters invalid, duplicate, and broken entries and sanitizes labels', async () => {
|
||||
const catalog = {
|
||||
defaultId: 'safe',
|
||||
items: [
|
||||
{ id: 'safe', label: 'Safe\nPreset\u202e' },
|
||||
{ id: 'safe', label: 'Duplicate' },
|
||||
{ id: 'UPPER', label: 'Invalid' },
|
||||
{ id: 'broken', label: 'Broken', broken: { message: 'secret' } },
|
||||
],
|
||||
};
|
||||
const { harness, state } = fixture({ catalog });
|
||||
const result = await runPresetCommand('/presetlist', harness, state, 'direct:one');
|
||||
|
||||
assert.match(result.message, /可用 Agent Preset(1)/);
|
||||
assert.match(result.message, /Safe Preset(safe)/);
|
||||
assert.doesNotMatch(result.message, /Duplicate|Invalid|Broken|secret|\u202e/);
|
||||
});
|
||||
|
||||
test('/preset reports an explicit unavailable current preset without changing it', async () => {
|
||||
const { calls, harness, state, selected } = fixture({ agentPreset: 'removed' });
|
||||
const result = await runPresetCommand('/preset', harness, state, 'direct:one');
|
||||
|
||||
assert.match(result.message, /removed(已不可用)/);
|
||||
assert.match(result.message, /已有会话不会受此设置影响/);
|
||||
assert.equal(selected(), 'removed');
|
||||
assert.equal(calls.some(([name]) => name === 'updateAgentPreset'), false);
|
||||
});
|
||||
|
||||
test('/preset and /presetlist expose an unavailable Host default safely', async () => {
|
||||
const catalog = { defaultId: 'removed-default', items: CATALOG.items };
|
||||
const current = fixture({ catalog });
|
||||
assert.match(
|
||||
(await runPresetCommand('/preset', current.harness, current.state, 'direct:one')).message,
|
||||
/跟随 Host 默认(Host 默认当前不可用)/,
|
||||
);
|
||||
|
||||
const listed = fixture({ catalog });
|
||||
assert.match(
|
||||
(await runPresetCommand('/presetlist', listed.harness, listed.state, 'direct:one')).message,
|
||||
/Host 默认:removed-default(当前不可用)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('numeric selection requires a snapshot for the same state and conversation key', async () => {
|
||||
const first = fixture();
|
||||
assert.match(
|
||||
(await runPresetCommand('/preset 1', first.harness, first.state, 'direct:one')).message,
|
||||
/先执行 \/presetlist/,
|
||||
);
|
||||
assert.equal(first.calls.length, 0);
|
||||
|
||||
await runPresetCommand('/presetlist', first.harness, first.state, 'direct:one');
|
||||
assert.match(
|
||||
(await runPresetCommand('/preset 1', first.harness, first.state, 'direct:two')).message,
|
||||
/先执行 \/presetlist/,
|
||||
);
|
||||
|
||||
const otherState = {};
|
||||
assert.match(
|
||||
(await runPresetCommand('/preset 1', first.harness, otherState, 'direct:one')).message,
|
||||
/先执行 \/presetlist/,
|
||||
);
|
||||
});
|
||||
|
||||
test('numeric selection rejects an expired snapshot and requires a new /presetlist', async (t) => {
|
||||
let now = 1_000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
const { calls, harness, state } = fixture();
|
||||
await runPresetCommand('/presetlist', harness, state, 'direct:one');
|
||||
|
||||
now += PRESET_LIST_SNAPSHOT_TTL_MS - 1;
|
||||
const stillFresh = await runPresetCommand('/preset 1', harness, state, 'direct:one');
|
||||
assert.match(stillFresh.message, /Standard(standard)/);
|
||||
|
||||
now += 1;
|
||||
const expired = await runPresetCommand('/preset 1', harness, state, 'direct:one');
|
||||
|
||||
assert.match(expired.message, /请先执行 \/presetlist/);
|
||||
assert.equal(calls.filter(([name]) => name === 'updateAgentPreset').length, 1);
|
||||
});
|
||||
|
||||
test('numeric snapshots are capacity-bounded and evicted in LRU order', async () => {
|
||||
const { calls, harness, state } = fixture();
|
||||
for (let index = 0; index < PRESET_LIST_SNAPSHOT_MAX_ENTRIES; index += 1) {
|
||||
await runPresetCommand('/presetlist', harness, state, `direct:${index}`);
|
||||
}
|
||||
|
||||
await runPresetCommand('/preset 1', harness, state, 'direct:0');
|
||||
await runPresetCommand(
|
||||
'/presetlist',
|
||||
harness,
|
||||
state,
|
||||
`direct:${PRESET_LIST_SNAPSHOT_MAX_ENTRIES}`,
|
||||
);
|
||||
|
||||
const evicted = await runPresetCommand('/preset 1', harness, state, 'direct:1');
|
||||
assert.match(evicted.message, /请先执行 \/presetlist/);
|
||||
const retained = await runPresetCommand('/preset 1', harness, state, 'direct:0');
|
||||
assert.match(retained.message, /Standard(standard)/);
|
||||
assert.equal(calls.filter(([name]) => name === 'updateAgentPreset').length, 2);
|
||||
});
|
||||
|
||||
test('numeric selection keeps the listed ID when the fresh catalog is reordered', async () => {
|
||||
let currentCatalog = CATALOG;
|
||||
const fixtureValue = fixture({ catalog: () => currentCatalog });
|
||||
await runPresetCommand(
|
||||
'/presetlist',
|
||||
fixtureValue.harness,
|
||||
fixtureValue.state,
|
||||
'direct:one',
|
||||
);
|
||||
currentCatalog = {
|
||||
defaultId: 'standard',
|
||||
items: [CATALOG.items[2], CATALOG.items[0], CATALOG.items[1]],
|
||||
};
|
||||
|
||||
await runPresetCommand('/preset 2', fixtureValue.harness, fixtureValue.state, 'direct:one');
|
||||
assert.deepEqual(
|
||||
fixtureValue.calls.findLast(([name]) => name === 'updateAgentPreset'),
|
||||
['updateAgentPreset', 'coding', {}],
|
||||
);
|
||||
});
|
||||
|
||||
test('/preset rejects invalid and out-of-range numeric selections without updating', async () => {
|
||||
const { calls, harness, state } = fixture();
|
||||
await runPresetCommand('/presetlist', harness, state, 'direct:one');
|
||||
|
||||
for (const requested of ['0', '4', '9007199254740992']) {
|
||||
const result = await runPresetCommand(`/preset ${requested}`, harness, state, 'direct:one');
|
||||
assert.match(result.message, /序号.*无效|序号不存在/, requested);
|
||||
}
|
||||
assert.equal(calls.filter(([name]) => name === 'updateAgentPreset').length, 0);
|
||||
});
|
||||
|
||||
test('/preset accepts exact IDs and id: for a purely numeric ID', async () => {
|
||||
const direct = fixture();
|
||||
const directResult = await runPresetCommand(
|
||||
'/preset marketing',
|
||||
direct.harness,
|
||||
direct.state,
|
||||
'direct:one',
|
||||
);
|
||||
assert.match(directResult.message, /Marketing(marketing)/);
|
||||
assert.deepEqual(direct.calls.at(-1), ['updateAgentPreset', 'marketing', {}]);
|
||||
|
||||
const numericCatalog = {
|
||||
defaultId: '123',
|
||||
items: [{ id: '123', label: 'Numeric' }],
|
||||
};
|
||||
const numeric = fixture({ catalog: numericCatalog });
|
||||
const numericResult = await runPresetCommand(
|
||||
'/preset id:123',
|
||||
numeric.harness,
|
||||
numeric.state,
|
||||
'direct:one',
|
||||
);
|
||||
assert.match(numericResult.message, /Numeric(123)/);
|
||||
assert.deepEqual(numeric.calls.at(-1), ['updateAgentPreset', '123', {}]);
|
||||
});
|
||||
|
||||
test('/preset --default clears the override without first reading the catalog', async () => {
|
||||
const settingsError = new Error('catalog private failure');
|
||||
const { calls, harness, state, selected } = fixture({
|
||||
agentPreset: 'coding',
|
||||
settingsError,
|
||||
});
|
||||
const signal = new AbortController().signal;
|
||||
const result = await runPresetCommand(
|
||||
'/preset --DEFAULT',
|
||||
harness,
|
||||
state,
|
||||
'direct:one',
|
||||
{ signal },
|
||||
);
|
||||
|
||||
assert.match(result.message, /跟随 Host 默认/);
|
||||
assert.equal(selected(), null);
|
||||
assert.deepEqual(calls, [['updateAgentPreset', null, { signal }]]);
|
||||
assert.doesNotMatch(result.message, /private/);
|
||||
});
|
||||
|
||||
test('/preset updates even while an interaction is pending and never consults Session state', async () => {
|
||||
const { calls, harness, state } = fixture();
|
||||
state.sessionFor = () => {
|
||||
throw new Error('must not inspect current Session');
|
||||
};
|
||||
const result = await runPresetCommand(
|
||||
'/preset coding',
|
||||
harness,
|
||||
state,
|
||||
'direct:one',
|
||||
{ pendingInteraction: true, control: { owner: 'one' } },
|
||||
);
|
||||
|
||||
assert.match(result.message, /已设置为/);
|
||||
assert.match(result.message, /先发送 \/new/);
|
||||
assert.deepEqual(calls, [['updateAgentPreset', 'coding', {}]]);
|
||||
});
|
||||
|
||||
test('/preset serializes with the next Session-binding transaction', async () => {
|
||||
let releaseUpdate;
|
||||
let markUpdateStarted;
|
||||
const updateStarted = new Promise((resolve) => { markUpdateStarted = resolve; });
|
||||
const updateGate = new Promise((resolve) => { releaseUpdate = resolve; });
|
||||
const order = [];
|
||||
const { harness, state } = fixture({
|
||||
updateHook: async () => {
|
||||
markUpdateStarted();
|
||||
await updateGate;
|
||||
order.push('preset');
|
||||
},
|
||||
});
|
||||
const updating = runPresetCommand('/preset coding', harness, state, 'direct:one');
|
||||
await updateStarted;
|
||||
const binding = withSessionBindingLock(state, 'direct:one', async () => {
|
||||
order.push('session');
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(order, []);
|
||||
releaseUpdate();
|
||||
await Promise.all([updating, binding]);
|
||||
assert.deepEqual(order, ['preset', 'session']);
|
||||
});
|
||||
|
||||
test('preset commands validate syntax and reject images without calling the harness', async () => {
|
||||
const { calls, harness, state } = fixture();
|
||||
for (const command of ['/presetlist extra', '/preset coding extra', '/preset id:abc']) {
|
||||
const result = await runPresetCommand(command, harness, state, 'direct:one');
|
||||
assert.match(result.message, /用法|格式无效/, command);
|
||||
}
|
||||
|
||||
for (const command of ['/preset', '/presetlist', '/preset coding']) {
|
||||
const result = await runPresetCommand(command, harness, state, 'direct:one', {
|
||||
hasImages: true,
|
||||
});
|
||||
assert.match(result.message, /仅支持纯文字/, command);
|
||||
}
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test('fresh validation failures and internal errors use safe messages', async () => {
|
||||
const unavailableError = new Error('path=/private/preset token=secret');
|
||||
unavailableError.code = 'agent-preset-unavailable';
|
||||
const unavailable = fixture({ updateError: unavailableError });
|
||||
const unavailableResult = await runPresetCommand(
|
||||
'/preset coding',
|
||||
unavailable.harness,
|
||||
unavailable.state,
|
||||
'direct:one',
|
||||
);
|
||||
assert.match(unavailableResult.message, /不存在或当前不可用/);
|
||||
assert.doesNotMatch(unavailableResult.message, /private|secret/);
|
||||
|
||||
const missingError = new Error('bot details');
|
||||
missingError.code = 'workspace-bot-not-found';
|
||||
const missing = fixture({ updateError: missingError });
|
||||
assert.match(
|
||||
(await runPresetCommand('/preset coding', missing.harness, missing.state, 'direct:one')).message,
|
||||
/机器人状态已发生变化/,
|
||||
);
|
||||
|
||||
const listing = fixture({ settingsError: new Error('private catalog endpoint') });
|
||||
const listingResult = await runPresetCommand(
|
||||
'/presetlist',
|
||||
listing.harness,
|
||||
listing.state,
|
||||
'direct:one',
|
||||
);
|
||||
assert.match(listingResult.message, /暂时无法获取 Agent Preset 列表/);
|
||||
assert.doesNotMatch(listingResult.message, /private catalog endpoint/);
|
||||
});
|
||||
|
||||
test('cancelled and malformed harness results fail safely', async () => {
|
||||
const cancelledError = new Error('secret abort details');
|
||||
cancelledError.name = 'AbortError';
|
||||
const cancelled = fixture({ settingsError: cancelledError });
|
||||
assert.match(
|
||||
(await runPresetCommand(
|
||||
'/presetlist',
|
||||
cancelled.harness,
|
||||
cancelled.state,
|
||||
'direct:one',
|
||||
)).message,
|
||||
/列表已取消/,
|
||||
);
|
||||
|
||||
const badHarness = {
|
||||
async agentPresetSettings() {
|
||||
return { agentPreset: null, agentPresetCatalog: { items: null } };
|
||||
},
|
||||
};
|
||||
const result = await runPresetCommand('/preset', badHarness, {}, 'direct:one');
|
||||
assert.match(result.message, /暂时无法获取 Agent Preset 设置/);
|
||||
});
|
||||
|
||||
test('/presetlist splits long output into lossless 1,800-character messages', async () => {
|
||||
const catalog = {
|
||||
defaultId: 'preset-000',
|
||||
items: Array.from({ length: 80 }, (_, index) => ({
|
||||
id: `preset-${String(index).padStart(3, '0')}`,
|
||||
label: `Preset ${index} ${'x'.repeat(80)}`,
|
||||
})),
|
||||
};
|
||||
const { harness, state } = fixture({ catalog });
|
||||
const result = await runPresetCommand('/presetlist', harness, state, 'direct:one');
|
||||
|
||||
assert.ok(result.messages.length > 1);
|
||||
assert.ok(result.messages.every((message) => message.length <= 1_800));
|
||||
assert.equal(result.messages.join(''), result.message);
|
||||
assert.match(result.message, /80\. Preset 79/);
|
||||
});
|
||||
|
||||
test('non-preset input is left for ordinary message routing', async () => {
|
||||
assert.equal(await runPresetCommand('hello', {}, {}, 'direct:one'), null);
|
||||
});
|
||||
|
|
@ -1556,3 +1556,91 @@ test('workspace RPC refreshes the Agent Preset catalog and rejects unavailable c
|
|||
assert.equal(cleared.value.bots[0].agentPreset, null);
|
||||
assert.deepEqual(cleared.value.agentPresetCatalog, catalog);
|
||||
});
|
||||
|
||||
test('bot-scoped Agent Preset settings use the latest catalog and persist selections', async (t) => {
|
||||
const { path, defaultWorkspace } = await fixture(t);
|
||||
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
await workspaces.ensure('bot_preset');
|
||||
let catalog = {
|
||||
defaultId: 'standard',
|
||||
items: [
|
||||
{ id: 'standard', name: 'Standard' },
|
||||
{ id: 'broken-one', name: 'Broken', broken: 'missing entrypoint' },
|
||||
],
|
||||
};
|
||||
let catalogFailure = null;
|
||||
const scope = createBotWorkspaceScope({}, {
|
||||
botId: 'bot_preset',
|
||||
workspaces,
|
||||
state: {},
|
||||
agentPresetCatalog: async () => {
|
||||
if (catalogFailure) throw catalogFailure;
|
||||
return catalog;
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(await scope.harness.agentPresetSettings(), {
|
||||
agentPreset: null,
|
||||
agentPresetCatalog: {
|
||||
defaultId: 'standard',
|
||||
items: [{ id: 'standard', label: 'Standard' }],
|
||||
},
|
||||
});
|
||||
|
||||
catalog = {
|
||||
defaultId: 'marketing-jeep',
|
||||
items: [{ id: 'marketing-jeep', label: 'Marketing' }],
|
||||
};
|
||||
await assert.rejects(scope.harness.updateAgentPreset('standard'), {
|
||||
code: 'agent-preset-unavailable',
|
||||
});
|
||||
assert.equal(workspaces.agentPresetFor('bot_preset'), null);
|
||||
|
||||
assert.deepEqual(await scope.harness.updateAgentPreset('marketing-jeep'), {
|
||||
agentPreset: 'marketing-jeep',
|
||||
agentPresetCatalog: catalog,
|
||||
});
|
||||
assert.equal(workspaces.agentPresetFor('bot_preset'), 'marketing-jeep');
|
||||
|
||||
assert.equal((await scope.harness.updateAgentPreset(null)).agentPreset, null);
|
||||
await scope.harness.updateAgentPreset('marketing-jeep');
|
||||
catalogFailure = new Error('Host catalog temporarily unavailable');
|
||||
assert.deepEqual(await scope.harness.updateAgentPreset('--default'), {
|
||||
agentPreset: null,
|
||||
agentPresetCatalog: { defaultId: '', items: [] },
|
||||
});
|
||||
assert.equal(workspaces.agentPresetFor('bot_preset'), null);
|
||||
});
|
||||
|
||||
test('old bot scopes cannot read or update Agent Presets after same-id rebinding', async (t) => {
|
||||
const { path, defaultWorkspace } = await fixture(t);
|
||||
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
await workspaces.ensure('bot_preset_rebind');
|
||||
let releaseCatalog;
|
||||
let catalogCalls = 0;
|
||||
let markCatalogsStarted;
|
||||
const catalogsStarted = new Promise((resolveStarted) => { markCatalogsStarted = resolveStarted; });
|
||||
const catalogGate = new Promise((resolveCatalog) => { releaseCatalog = resolveCatalog; });
|
||||
const oldScope = createBotWorkspaceScope({}, {
|
||||
botId: 'bot_preset_rebind',
|
||||
workspaces,
|
||||
state: {},
|
||||
agentPresetCatalog: async () => {
|
||||
catalogCalls += 1;
|
||||
if (catalogCalls === 2) markCatalogsStarted();
|
||||
await catalogGate;
|
||||
return { defaultId: 'standard', items: [{ id: 'standard', label: 'Standard' }] };
|
||||
},
|
||||
});
|
||||
|
||||
const reading = oldScope.harness.agentPresetSettings();
|
||||
const updating = oldScope.harness.updateAgentPreset('standard');
|
||||
await catalogsStarted;
|
||||
await workspaces.remove('bot_preset_rebind');
|
||||
await workspaces.ensure('bot_preset_rebind');
|
||||
releaseCatalog();
|
||||
|
||||
await assert.rejects(reading, { code: 'workspace-bot-not-found' });
|
||||
await assert.rejects(updating, { code: 'workspace-bot-not-found' });
|
||||
assert.equal(workspaces.agentPresetFor('bot_preset_rebind'), null);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue