feat: add reasoning effort commands

This commit is contained in:
xmanrui 2026-08-25 00:41:44 +08:00
parent 36cbbe48e7
commit 9e39333ff6
24 changed files with 1346 additions and 240 deletions

View file

@ -138,8 +138,12 @@ Each WhatsApp bot also has its own access mode. Existing bots migrate to **Only
| `/new` | Unbind the current chat so its next ordinary message starts a new Harness Session. |
| `/status` | Check the connection between the current bot and DeepSeek Harness. |
| `/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. |
| `/model` | Show the model and reasoning effort used by the Session bound to this chat. |
| `/model <number or provider/model-id> [reasoning effort ID]` | Switch the Session model and optionally select an effort supported by the target model. |
| `/reasoninglist`, `/reasonings` | Equivalent aliases that list the reasoning efforts supported by the current model. |
| `/reasoning` | Show the current Session model and reasoning effort. |
| `/reasoning <number or effort ID>` | Switch the current model's reasoning effort. |
| `/reasoning --default` | Restore the current model's default reasoning effort. |
| `/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. |
@ -154,7 +158,7 @@ Each WhatsApp bot also has its own access mode. Existing bots migrate to **Only
| 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; 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`
Example: send `/models`, then `/model 2` to switch to the second model in the list; send `/reasoninglist`, then `/reasoning 2` to switch to the current model's second reasoning effort; 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 max`, `/reasoning --default`, `/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.
@ -164,8 +168,9 @@ If the Slack desktop app has no native Slash Command registered with the same na
- `/status` takes no arguments, never prompts the model, and does not change the Session binding. It confirms that the current bot can reach DeepSeek Harness.
- `/new` only removes the current chat's saved dsh-im Session binding; it never deletes, empties, or archives the old Session. The next ordinary message creates and binds a new Session in the current workspace. If a task is running or waiting for a question or approval, finish the interaction or use `/stop` before `/new`.
- `/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.
- Bare `/model` shows the current Session model and reasoning effort. With arguments, it accepts a number from `/models` or an exact full model ID, followed optionally by an exact reasoning effort ID published in that target model's metadata, for example `/model 2 max`. When the effort is omitted, Harness resolves the target model's current default. If the chat has no Session yet, a valid switch creates and binds a blank Session without prompting the model.
- `/reasoninglist` and `/reasonings` are equivalent. They list the efforts from the current model's metadata and mark the current and default values. `/reasoning` shows the current value; `/reasoning <number or effort ID>` accepts a listed number or an exact metadata ID; `/reasoning --default` lets Harness resolve the current model's default again. Every `/reasoning...` command requires an existing Session and never creates one or prompts the model.
- The model or reasoning effort cannot be changed while a task is running or waiting for an approval or question answer. Wait for it to finish or use `/stop` first. A change takes effect on the next model request and keeps Harness's default-saving semantics: Harness attempts to save the accepted model and effort as the default selection for future Sessions, while other existing Sessions remain unchanged. 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.

View file

@ -141,8 +141,12 @@ dsh web
| `/new` | 解除当前聊天的会话绑定,让下一条普通消息开启全新 Harness 会话。 |
| `/status` | 检查当前机器人与 DeepSeek Harness 的连接状态。 |
| `/models` | 按序号列出当前配置的全部可用模型。 |
| `/model` | 查看当前聊天绑定会话正在使用的模型。 |
| `/model <序号或 Provider/模型ID>` | 切换当前聊天绑定会话的模型。 |
| `/model` | 查看当前聊天绑定会话正在使用的模型和推理等级。 |
| `/model <序号或 Provider/模型ID> [推理等级ID]` | 切换当前会话模型,并可同时指定目标模型支持的推理等级。 |
| `/reasoninglist`、`/reasonings` | 等价命令;列出当前模型支持的推理等级。 |
| `/reasoning` | 查看当前会话的模型和推理等级。 |
| `/reasoning <序号或等级ID>` | 切换当前模型的推理等级。 |
| `/reasoning --default` | 恢复当前模型的默认推理等级。 |
| `/presetlist` | 按序号列出 Host 当前可用的 Agent Preset,并标记 Host 默认项和当前机器人的选择。 |
| `/preset` | 查看当前机器人的新会话 Agent Preset 设置。 |
| `/preset <序号或 Preset ID>` | 设置当前机器人的 Agent Preset;纯数字 ID 使用 `/preset id:<ID>`。 |
@ -157,7 +161,7 @@ dsh web
| 交互式提问 | 回复选项序号、选项文字或自定义文字;多选时用逗号分隔。 |
| 远程审批 | 回复 `批准` / `拒绝` / `同意` / `不同意` / `yes` / `no`。 |
示例:先发送 `/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`
示例:先发送 `/models`,再发送 `/model 2` 切换到列表中的第 2 个模型;先发送 `/reasoninglist`,再发送 `/reasoning 2` 切换到当前模型的第 2 个推理等级;先发送 `/presetlist`,再发送 `/preset 2` 为当前机器人选择第 2 个 Agent Preset。其他命令示例:`/help`、`/new`、`/status`、`/model deepseek-official/deepseek-v4-pro max`、`/reasoning --default`、`/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`;插件命令层会去除首尾空白,执行效果与无空格命令相同。
@ -167,8 +171,9 @@ Slack 桌面端若未注册同名的原生 Slash Command,会拦截直接以 `/
- `/status` 不需要参数,也不会向模型发送消息或改变会话绑定;它用于确认当前机器人能够连接 DeepSeek Harness。
- `/new` 只解除当前聊天在 dsh-im 中保存的会话绑定,不会删除、清空或归档旧 Session。下一条普通消息会在当前工作区创建并绑定一个新 Session。任务正在运行或等待问题、审批时,应先完成交互或使用 `/stop`,再使用 `/new`。
- `/models` 不需要参数,也不会创建会话。它为 Harness 当前配置的全部可用模型分配序号,同时显示可稳定复制的 `Provider/模型ID`;某个 Provider 查询失败时,其他 Provider 的结果仍会显示。
- `/model` 不带参数时只查看当前会话模型;带参数时接受 `/models` 列出的序号或完整模型 ID,例如 `/model 2`。完整 ID 必须精确匹配。聊天尚无会话时,有效的切换命令会创建并绑定一个空白会话,但不会触发模型回复。切换只影响当前会话;Harness 还会尝试把它保存为以后新会话的默认模型,已有其他会话不受影响。
- 正在运行任务或等待审批、问题回答时不能切换模型;请等待完成,或先使用 `/stop`。含图片的会话无法切换到不支持图片输入的模型。
- `/model` 不带参数时查看当前会话的模型和推理等级;带参数时接受 `/models` 列出的序号或精确完整模型 ID,并可追加目标模型元数据公布的精确推理等级 ID,例如 `/model 2 max`。省略推理等级时,由 Harness 解析目标模型的当前默认值。聊天尚无会话时,有效的切换命令会创建并绑定一个空白会话,但不会触发模型回复。
- `/reasoninglist` 和 `/reasonings` 完全等价,按当前模型的元数据列出可选推理等级并标记当前值和默认值。`/reasoning` 查看当前值;`/reasoning <序号或等级ID>` 接受列表序号或元数据中的精确 ID;`/reasoning --default` 让 Harness 重新采用当前模型的默认推理等级。所有 `/reasoning...` 命令都要求当前聊天已有 Session,不会自行创建 Session 或触发模型回复。
- 正在运行任务或等待审批、问题回答时不能修改模型或推理等级;请等待完成,或先使用 `/stop`。修改从下一次模型请求起生效,并沿用 Harness 的默认保存语义:Harness 会尝试把已接受的模型和推理等级保存为以后新会话的默认选择,已有其他会话不受影响。含图片的会话无法切换到不支持图片输入的模型。
- `/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 默认。

File diff suppressed because one or more lines are too long

View file

@ -57,8 +57,10 @@ const HELP_TEXT_LINES = [
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
'/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
'/models 按序号列出所有可用模型',
'/model [序号或完整模型ID] 查看或切换当前会话模型',
'示例:先发 /models,再发 /model 2',
'/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级',
'/reasoning [序号、等级ID或 --default] 查看或切换当前推理等级',
'/model [序号或完整模型ID] [推理等级ID] 查看或切换当前会话模型',
'示例:先发 /models,再发 /model 2 [推理等级ID]',
'/presetlist 按序号列出可用 Agent Preset',
'/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
'纯数字 ID:/preset id:<ID>',

View file

@ -509,7 +509,9 @@ export function menuHelpText() {
'纯数字 ID:/preset id:<ID>',
'/preset --default 跟随 Host 默认',
'/models 列出模型',
'/model 2 按序号切换模型',
'/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级',
'/reasoning [序号、等级ID或 --default] 查看或切换当前推理等级',
'/model [序号或完整模型ID] [推理等级ID] 查看或切换当前会话模型',
'',
'🎮 任务控制',
'/stop 停止当前任务',
@ -559,7 +561,9 @@ const HELP_TEXT_COMMANDS = [
'`/preset [序号/ID]` — 切换预设',
'`/preset --default` — 跟随默认',
'`/models` — 列出模型',
'`/model 2` — 切换模型',
'`/reasoninglist` 或 `/reasonings` — 按序号列出当前模型可用推理等级',
'`/reasoning [序号、等级ID或 --default]` — 查看或切换当前推理等级',
'`/model [序号或完整模型ID] [推理等级ID]` — 查看或切换当前会话模型',
'`/repair` — 修复卡片按钮',
].join('\n');

View file

@ -70,8 +70,10 @@ function helpText() {
t('/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题'),
t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),
t('/models 按序号列出所有可用模型'),
t('/model [序号或完整模型ID] 查看或切换当前会话模型'),
t('示例:先发 /models,再发 /model 2'),
t('/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级'),
t('/reasoning [序号、等级ID或 --default] 查看或切换当前推理等级'),
t('/model [序号或完整模型ID] [推理等级ID] 查看或切换当前会话模型'),
t('示例:先发 /models,再发 /model 2 [推理等级ID]'),
t('/presetlist 按序号列出可用 Agent Preset'),
t('/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset'),
t('纯数字 ID:/preset id:<ID>'),

View file

@ -104,7 +104,26 @@ function validModelSelection(value) {
&& Boolean(value.provider)
&& typeof value.model === 'string'
&& Boolean(value.model)
&& (value.reasoningEffort === undefined || typeof value.reasoningEffort === 'string');
&& (value.reasoningEffort === undefined
|| (typeof value.reasoningEffort === 'string' && Boolean(value.reasoningEffort)));
}
function validModelReasoning(value) {
return value !== null
&& typeof value === 'object'
&& Array.isArray(value.efforts)
&& value.efforts.length > 0
&& value.efforts.every((effort) => (
effort !== null
&& typeof effort === 'object'
&& typeof effort.id === 'string'
&& Boolean(effort.id)
&& typeof effort.name === 'string'
&& Boolean(effort.name)
&& (effort.description === undefined || typeof effort.description === 'string')
))
&& (value.defaultEffort === undefined
|| (typeof value.defaultEffort === 'string' && Boolean(value.defaultEffort)));
}
function validateModelCatalog(value, method, { session = false } = {}) {
@ -123,7 +142,9 @@ function validateModelCatalog(value, method, { session = false } = {}) {
for (const model of group.models) {
if (!model || typeof model !== 'object'
|| typeof model.id !== 'string' || !model.id
|| typeof model.name !== 'string' || !model.name) {
|| typeof model.name !== 'string' || !model.name
|| (model.description !== undefined && typeof model.description !== 'string')
|| (model.reasoning !== undefined && !validModelReasoning(model.reasoning))) {
throw new Error(`Harness returned an invalid response for ${method}`);
}
}
@ -728,6 +749,9 @@ export class HarnessClient {
sessionId,
provider: selection.provider,
model: selection.model,
...(selection.reasoningEffort === undefined
? {}
: { reasoningEffort: selection.reasoningEffort }),
}, 30_000, signal ? { ...options, signal } : options);
};
const value = this.#sessionMaintenanceExecutor

View file

@ -236,13 +236,12 @@ export default {
'/unwatch ID 取消关注': '/unwatch ID Stop watching a session',
'🤖 预设 / 模型': '🤖 Presets / models',
'/models 列出模型': '/models List models',
'/model 2 按序号切换模型': '/model 2 Switch model by index',
'🎮 任务控制': '🎮 Task controls',
'/steer 指令 给 Agent 补充指令': '/steer INSTRUCTION Steer the Agent',
'**📋 卡片功能**\n\n1. 会话下拉 — 切换当前绑定会话\n2. 工作区下拉 — 切换工作区\n3. 🤖 预设下拉 — 切换 Agent 预设\n4. 🧠 模型下拉 — 切换模型\n5. 🆕 新会话 — 开启全新会话\n6. 📋 会话/关注 — 查看/绑定会话,管理关注\n7. ⏹ 停止 — 停止当前任务\n8. 📐 压缩 — 压缩当前会话上下文\n9. 补充指令 — 给 Agent 发送指令\n10. 🗄 归档切换 — 显示/隐藏归档会话\n11. 📊 状态 — 查看系统连接状态\n12. 📖 帮助 — 查看本帮助':
'**📋 Card features**\n\n1. Session dropdown — switch the bound session\n2. Workspace dropdown — switch workspace\n3. 🤖 Preset dropdown — switch Agent Preset\n4. 🧠 Model dropdown — switch model\n5. 🆕 New session — start fresh\n6. 📋 Sessions/watches — view or bind sessions and manage watches\n7. ⏹ Stop — stop the current task\n8. 📐 Compact — compact the current session context\n9. Steer task — send an instruction to the Agent\n10. 🗄 Archived toggle — show or hide archived sessions\n11. 📊 Status — view connection status\n12. 📖 Help — view this help',
'**⌨️ 文本命令**\n\n`/m` — 打开菜单卡片\n`/new` — 开启全新会话\n`/session ID` — 绑定已有会话\n`/sessionlist [工作区]` — 列出会话\n`/workspace 路径` — 切换工作区\n`/workspacelist` — 列出工作区\n`/status` — 查看连接状态\n`/compact` — 压缩上下文\n`/stop` — 停止当前任务\n`/steer 指令` — 补充指令\n`/watch ID` — 关注会话\n`/watchlist` — 关注列表\n`/unwatch ID` — 取消关注\n`/archived on/off` — 归档显隐\n`/presetlist` — 列出预设\n`/preset [序号/ID]` — 切换预设\n`/preset --default` — 跟随默认\n`/models` — 列出模型\n`/model 2` — 切换模型\n`/repair` — 修复卡片按钮':
'**⌨️ Text commands**\n\n`/m` — open the menu card\n`/new` — start a new session\n`/session ID` — bind an existing session\n`/sessionlist [workspace]` — list sessions\n`/workspace PATH` — switch workspace\n`/workspacelist` — list workspaces\n`/status` — view connection status\n`/compact` — compact context\n`/stop` — stop the current task\n`/steer INSTRUCTION` — steer the task\n`/watch ID` — watch a session\n`/watchlist` — list watched sessions\n`/unwatch ID` — stop watching\n`/archived on/off` — show or hide archived sessions\n`/presetlist` — list presets\n`/preset [index/ID]` — switch preset\n`/preset --default` — follow default\n`/models` — list models\n`/model 2` — switch model\n`/repair` — repair card buttons',
'**⌨️ 文本命令**\n\n`/m` — 打开菜单卡片\n`/new` — 开启全新会话\n`/session ID` — 绑定已有会话\n`/sessionlist [工作区]` — 列出会话\n`/workspace 路径` — 切换工作区\n`/workspacelist` — 列出工作区\n`/status` — 查看连接状态\n`/compact` — 压缩上下文\n`/stop` — 停止当前任务\n`/steer 指令` — 补充指令\n`/watch ID` — 关注会话\n`/watchlist` — 关注列表\n`/unwatch ID` — 取消关注\n`/archived on/off` — 归档显隐\n`/presetlist` — 列出预设\n`/preset [序号/ID]` — 切换预设\n`/preset --default` — 跟随默认\n`/models` — 列出模型\n`/reasoninglist` 或 `/reasonings` — 按序号列出当前模型可用推理等级\n`/reasoning [序号、等级ID或 --default]` — 查看或切换当前推理等级\n`/model [序号或完整模型ID] [推理等级ID]` — 查看或切换当前会话模型\n`/repair` — 修复卡片按钮':
'**⌨️ Text commands**\n\n`/m` — open the menu card\n`/new` — start a new session\n`/session ID` — bind an existing session\n`/sessionlist [workspace]` — list sessions\n`/workspace PATH` — switch workspace\n`/workspacelist` — list workspaces\n`/status` — view connection status\n`/compact` — compact context\n`/stop` — stop the current task\n`/steer INSTRUCTION` — steer the task\n`/watch ID` — watch a session\n`/watchlist` — list watched sessions\n`/unwatch ID` — stop watching\n`/archived on/off` — show or hide archived sessions\n`/presetlist` — list presets\n`/preset [index/ID]` — switch preset\n`/preset --default` — follow default\n`/models` — list models\n`/reasoninglist` or `/reasonings` — list reasoning efforts for the current model\n`/reasoning [index, effort ID, or --default]` — show or switch reasoning effort\n`/model [index or full model ID] [reasoning effort ID]` — show or switch the current Session model\n`/repair` — repair card buttons',
'**💡 数字兜底**\n回复数字快速操作:\n**1**工作区列表 · **2**新会话 · **3**会话/关注\n**4**状态 · **5**修复 · **6**帮助':
'**💡 Number fallback**\nReply with a number for a quick action:\n**1** Workspace list · **2** New session · **3** Sessions/watches\n**4** Status · **5** Repair · **6** Help',
'从下方下拉选择补充指令;最后一项可自定义输入。':

View file

@ -50,9 +50,17 @@ export default {
'/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话':
'/session <Session ID or workspace index> Bind this chat to the specified session',
'/models 按序号列出所有可用模型': '/models List all available models by index',
'/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级':
'/reasoninglist or /reasonings List reasoning efforts for the current model by index',
'/reasoning [序号、等级ID或 --default] 查看或切换当前推理等级':
'/reasoning [index, effort ID, or --default] Show or switch the current reasoning effort',
'/model [序号或完整模型ID] 查看或切换当前会话模型':
'/model [index or full model ID] Show or switch the model of the current session',
'/model [序号或完整模型ID] [推理等级ID] 查看或切换当前会话模型':
'/model [index or full model ID] [reasoning effort ID] Show or switch the current Session model',
'示例:先发 /models,再发 /model 2': 'Example: send /models first, then /model 2',
'示例:先发 /models,再发 /model 2 [推理等级ID]':
'Example: send /models first, then /model 2 [reasoning effort ID]',
'/presetlist 按序号列出可用 Agent Preset': '/presetlist List available Agent Presets by index',
'/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset':
'/preset [index or full ID] Show or set the Agent Preset of this bot',

View file

@ -134,17 +134,39 @@ export default {
// model-command.mjs
'用法:/model <序号> 或 /model <provider>/<model>': 'Usage: /model <index> or /model <provider>/<model>',
'用法:/model <序号或 provider/model> [推理等级ID]':
'Usage: /model <index or provider/model> [reasoning effort ID]',
'用法:/models(不带参数)': 'Usage: /models (no arguments)',
'用法:/reasoning [序号、等级ID或 --default]':
'Usage: /reasoning [index, effort ID, or --default]',
'用法:/reasoninglist 或 /reasonings(不带参数)':
'Usage: /reasoninglist or /reasonings (no arguments)',
'模型序号无效:{input}': 'Invalid model index: {input}',
'请发送 /models 查看并输入有效的正整数序号。':
'Please send /models to view the list and enter a valid positive integer index.',
'推理等级序号无效:{input}': 'Invalid reasoning effort index: {input}',
'请发送 /reasoninglist 查看并输入有效的正整数序号。':
'Please send /reasoninglist and enter a valid positive integer index.',
'可用模型:': 'Available models:',
'可用推理等级:': 'Available reasoning efforts:',
'可用推理等级:{efforts}': 'Available reasoning efforts: {efforts}',
'当前没有可用模型。': 'No models are currently available.',
'以下模型提供方暂时不可用:': 'The following model providers are temporarily unavailable:',
'切换模型:/model <序号>': 'To switch models: /model <index>',
'切换模型:/model <序号> [推理等级ID]':
'Switch model: /model <index> [reasoning effort ID]',
'当前模型:': 'Current model:',
'当前推理等级:{effort}': 'Current reasoning effort: {effort}',
'Default(由模型或 Provider 决定)': 'Default (determined by the model or Provider)',
'(当前、默认)': ' (current, default)',
'(默认)': ' (default)',
'查看全部模型:/models': 'See all models: /models',
'查看可用推理等级:/reasoninglist': 'See reasoning efforts: /reasoninglist',
'切换推理等级:/reasoning <序号或等级ID>':
'Switch reasoning effort: /reasoning <index or effort ID>',
'恢复默认等级:/reasoning --default': 'Restore the default effort: /reasoning --default',
'当前聊天还没有会话。': 'This chat has no Session yet.',
'请先发送一条普通消息创建会话。': 'Send a regular message first to create a Session.',
'查看模型:/models': 'View models: /models',
'选择模型:/model <序号>': 'Select a model: /model <index>',
'当前任务正在运行,请等待完成或先发送 /stop。':
@ -153,22 +175,39 @@ export default {
'The Session bound to this chat no longer exists; please try again.',
'无法切换到该模型。模型当前不可用,或不支持当前会话中的图片。':
'Cannot switch to that model. It is currently unavailable, or does not support the images in this Session.',
'无法切换推理等级。当前模型或推理等级不可用。':
'Cannot switch the reasoning effort. The current model or effort is unavailable.',
'当前聊天绑定的会话已发生变化,请重试。':
'The Session bound to this chat has changed; please try again.',
'获取模型列表已取消。': 'Fetching the model list was cancelled.',
'获取推理等级列表已取消。': 'Fetching the reasoning effort list was cancelled.',
'模型切换已取消。': 'The model switch was cancelled.',
'推理等级切换已取消。': 'The reasoning effort switch was cancelled.',
'暂时无法获取模型列表,请稍后重试。':
'Unable to get the model list right now; please try again later.',
'暂时无法获取推理等级,请稍后重试。':
'Unable to get reasoning efforts right now; please try again later.',
'模型切换失败,请稍后重试。': 'Failed to switch the model; please try again later.',
'推理等级切换失败,请稍后重试。':
'Failed to switch the reasoning effort; please try again later.',
'模型命令仅支持纯文字,请移除图片后重试。':
'Model commands support text only; please remove images and try again.',
'模型和推理等级命令仅支持纯文字,请移除图片后重试。':
'Model and reasoning effort commands support text only; please remove images and try again.',
'当前任务正在等待你的回答或审批。': 'The current task is waiting for your answer or approval.',
'请先处理当前请求,或者发送 /stop 停止任务。':
'Please handle the current request first, or send /stop to stop the task.',
'没有找到模型:{model}': 'Model not found: {model}',
'请发送 /models 查看可用模型。': 'Please send /models to view available models.',
'模型不支持推理等级:{effort}': 'The model does not support reasoning effort: {effort}',
'该模型不提供可切换的推理等级。':
'This model does not provide switchable reasoning efforts.',
'模型已切换为:\n{model}\n\n后续消息将使用该模型。':
'Model switched to:\n{model}\n\nSubsequent messages will use this model.',
'模型已切换为:\n{model}\n推理等级:{effort}\n\n后续消息将使用该模型和推理等级。':
'Model switched to:\n{model}\nReasoning effort: {effort}\n\nSubsequent messages will use this model and reasoning effort.',
'推理等级已切换为:\n{effort}\n\n当前模型:{model}\n后续消息将使用该推理等级。':
'Reasoning effort switched to:\n{effort}\n\nCurrent model: {model}\nSubsequent messages will use this reasoning effort.',
// compact-command.mjs
'用法:/compact(不带参数)': 'Usage: /compact (no arguments)',

View file

@ -5,8 +5,13 @@ import { withSessionBindingLock } from './session-binding-lock.mjs';
const MODEL_COMMAND = /^\/model(?=$|\s)/i;
const MODELS_COMMAND = /^\/models(?=$|\s)/i;
const MODEL_USAGE = '用法:/model <序号> 或 /model <provider>/<model>';
const REASONING_COMMAND = /^\/reasoning(?=$|\s)/i;
const REASONINGS_COMMAND = /^\/reasonings(?=$|\s)/i;
const REASONING_LIST_COMMAND = /^\/reasoninglist(?=$|\s)/i;
const MODEL_USAGE = '用法:/model <序号或 provider/model> [推理等级ID]';
const MODELS_USAGE = '用法:/models(不带参数)';
const REASONING_USAGE = '用法:/reasoning [序号、等级ID或 --default]';
const REASONING_LIST_USAGE = '用法:/reasoninglist 或 /reasonings(不带参数)';
const SESSION_BINDING_CHANGED = 'session-binding-changed';
const MODEL_SELECTION_MISMATCH = 'model-selection-mismatch';
const UNSAFE_DISPLAY_TEXT_GLOBAL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu;
@ -28,6 +33,35 @@ function rpcOptions(signal) {
return signal ? { signal } : {};
}
function normalizeReasoning(value) {
if (value === undefined) return undefined;
if (!value || typeof value !== 'object'
|| !Array.isArray(value.efforts) || value.efforts.length === 0) {
throw new TypeError('Harness returned invalid model reasoning metadata');
}
const efforts = value.efforts.map((effort) => {
if (!effort || typeof effort !== 'object'
|| typeof effort.id !== 'string' || !effort.id
|| typeof effort.name !== 'string' || !effort.name
|| (effort.description !== undefined && typeof effort.description !== 'string')) {
throw new TypeError('Harness returned an invalid reasoning effort');
}
return {
id: effort.id,
name: effort.name,
...(effort.description === undefined ? {} : { description: effort.description }),
};
});
if (value.defaultEffort !== undefined
&& (typeof value.defaultEffort !== 'string' || !value.defaultEffort)) {
throw new TypeError('Harness returned an invalid default reasoning effort');
}
return {
efforts,
...(value.defaultEffort === undefined ? {} : { defaultEffort: value.defaultEffort }),
};
}
function normalizeCatalog(value, { requireCurrent = false } = {}) {
if (!value || typeof value !== 'object'
|| !Array.isArray(value.groups) || !Array.isArray(value.failures)) {
@ -46,10 +80,18 @@ function normalizeCatalog(value, { requireCurrent = false } = {}) {
models: group.models.map((model) => {
if (!model || typeof model !== 'object'
|| typeof model.id !== 'string' || !model.id
|| typeof model.name !== 'string' || !model.name) {
|| typeof model.name !== 'string' || !model.name
|| (model.description !== undefined && typeof model.description !== 'string')) {
throw new TypeError('Harness returned an invalid model');
}
return { id: model.id, name: model.name };
return {
id: model.id,
name: model.name,
...(model.description === undefined ? {} : { description: model.description }),
...(model.reasoning === undefined
? {}
: { reasoning: normalizeReasoning(model.reasoning) }),
};
}),
};
});
@ -65,10 +107,19 @@ function normalizeCatalog(value, { requireCurrent = false } = {}) {
if (value.current !== undefined) {
if (!value.current || typeof value.current !== 'object'
|| typeof value.current.provider !== 'string' || !value.current.provider
|| typeof value.current.model !== 'string' || !value.current.model) {
|| typeof value.current.model !== 'string' || !value.current.model
|| (value.current.reasoningEffort !== undefined
&& (typeof value.current.reasoningEffort !== 'string'
|| !value.current.reasoningEffort))) {
throw new TypeError('Harness returned an invalid current model');
}
current = { provider: value.current.provider, model: value.current.model };
current = {
provider: value.current.provider,
model: value.current.model,
...(value.current.reasoningEffort === undefined
? {}
: { reasoningEffort: value.current.reasoningEffort }),
};
} else if (requireCurrent) {
throw new TypeError('Harness returned no current model');
}
@ -83,6 +134,24 @@ function sameModel(left, right) {
return left?.provider === right?.provider && left?.model === right?.model;
}
function sameSelection(left, right) {
return sameModel(left, right) && left?.reasoningEffort === right?.reasoningEffort;
}
function confirmsSelection(actual, requested) {
return sameModel(actual, requested)
&& (requested.reasoningEffort === undefined
|| actual?.reasoningEffort === requested.reasoningEffort);
}
function selectionText(selection) {
if (!selection?.provider || !selection?.model) return '';
const id = modelId(selection.provider, selection.model);
return selection.reasoningEffort === undefined
? id
: `${id} · reasoningEffort=${safeDisplayText(selection.reasoningEffort)}`;
}
function selectionMismatch(expected, actual, source) {
const error = new Error(`Harness ${source} did not confirm the selected model`);
error.code = MODEL_SELECTION_MISMATCH;
@ -129,12 +198,54 @@ function modelAt(catalog, requestedIndex) {
return null;
}
function modelNumberRequest(requested) {
function positiveNumberRequest(requested) {
if (!/^\d+$/u.test(requested)) return null;
const index = Number(requested);
return { index: Number.isSafeInteger(index) && index > 0 ? index : null };
}
function modelForSelection(catalog, selection) {
if (!selection) return null;
const group = catalog.groups.find(({ id }) => id === selection.provider);
return group?.models.find(({ id }) => id === selection.model) ?? null;
}
function reasoningEffortAt(model, requestedIndex) {
return model?.reasoning?.efforts?.[requestedIndex - 1] ?? null;
}
function reasoningEffortById(model, requestedId) {
return model?.reasoning?.efforts?.find(({ id }) => id === requestedId) ?? null;
}
function effectiveReasoningEffort(current, model) {
return current?.reasoningEffort ?? model?.reasoning?.defaultEffort;
}
function reasoningEffortText(model, effortId) {
if (effortId === undefined) return t('Default(由模型或 Provider 决定)');
const effort = reasoningEffortById(model, effortId);
if (!effort) return safeDisplayText(effortId);
const name = safeDisplayText(effort.name);
const id = safeDisplayText(effort.id);
return name === id ? id : `${name} (${id})`;
}
function currentReasoningEffortText(catalog) {
const model = modelForSelection(catalog, catalog.current);
return reasoningEffortText(
model,
effectiveReasoningEffort(catalog.current, model),
);
}
function reasoningMarker(effortId, currentId, defaultId) {
if (effortId === currentId && effortId === defaultId) return t('(当前、默认)');
if (effortId === currentId) return t('(当前)');
if (effortId === defaultId) return t('(默认)');
return '';
}
function invalidModelNumberMessage(requested) {
return [
t('模型序号无效:{input}', { input: safeDisplayText(requested) }),
@ -143,6 +254,29 @@ function invalidModelNumberMessage(requested) {
].join('\n');
}
function invalidReasoningNumberMessage(requested) {
return [
t('推理等级序号无效:{input}', { input: safeDisplayText(requested) }),
'',
t('请发送 /reasoninglist 查看并输入有效的正整数序号。'),
].join('\n');
}
function unsupportedReasoningMessage(selection, requested, model) {
const lines = [
t('模型不支持推理等级:{effort}', { effort: safeDisplayText(requested) }),
'',
safeDisplayText(selectionText(selection)),
];
const ids = model?.reasoning?.efforts?.map(({ id }) => safeDisplayText(id)) ?? [];
if (ids.length > 0) {
lines.push(t('可用推理等级:{efforts}', { efforts: ids.join(', ') }));
} else {
lines.push(t('该模型不提供可切换的推理等级。'));
}
return lines.join('\n');
}
function formatCatalog(catalog) {
const currentId = catalog.current
? modelId(catalog.current.provider, catalog.current.model)
@ -164,20 +298,72 @@ function formatCatalog(catalog) {
lines.push(`- ${safeDisplayText(failure.name) || safeDisplayText(failure.id)}`);
}
}
if (index > 0) lines.push('', t('切换模型:/model <序号>'));
if (index > 0) lines.push('', t('切换模型:/model <序号> [推理等级ID]'));
return lines.join('\n');
}
function currentModelMessage(current) {
function currentModelMessage(catalog) {
return [
t('当前模型:'),
modelId(current.provider, current.model),
modelId(catalog.current.provider, catalog.current.model),
t('当前推理等级:{effort}', { effort: currentReasoningEffortText(catalog) }),
'',
t('查看全部模型:/models'),
t('切换模型:/model <序号>'),
t('查看可用推理等级:/reasoninglist'),
t('切换模型:/model <序号> [推理等级ID]'),
].join('\n');
}
function currentReasoningMessage(catalog) {
return [
t('当前模型:'),
modelId(catalog.current.provider, catalog.current.model),
t('当前推理等级:{effort}', { effort: currentReasoningEffortText(catalog) }),
'',
t('查看可用推理等级:/reasoninglist'),
t('切换推理等级:/reasoning <序号或等级ID>'),
t('恢复默认等级:/reasoning --default'),
].join('\n');
}
function formatReasoningCatalog(catalog) {
const current = catalog.current;
const model = modelForSelection(catalog, current);
const currentEffort = effectiveReasoningEffort(current, model);
const lines = [
t('当前模型:'),
modelId(current.provider, current.model),
t('当前推理等级:{effort}', { effort: reasoningEffortText(model, currentEffort) }),
'',
t('可用推理等级:'),
];
if (!model?.reasoning) {
lines.push(
t('该模型不提供可切换的推理等级。'),
'',
t('恢复默认等级:/reasoning --default'),
);
return lines.join('\n');
}
for (const [index, effort] of model.reasoning.efforts.entries()) {
const label = reasoningEffortText(model, effort.id);
const marker = reasoningMarker(
effort.id,
currentEffort,
model.reasoning.defaultEffort,
);
lines.push(`${index + 1}. ${label}${marker}`);
const description = safeDisplayText(effort.description);
if (description) lines.push(` ${description}`);
}
lines.push(
'',
t('切换推理等级:/reasoning <序号或等级ID>'),
t('恢复默认等级:/reasoning --default'),
);
return lines.join('\n');
}
function noSessionMessage() {
return [
t('当前聊天还没有会话。'),
@ -187,6 +373,14 @@ function noSessionMessage() {
].join('\n');
}
function noReasoningSessionMessage() {
return [
t('当前聊天还没有会话。'),
'',
t('请先发送一条普通消息创建会话。'),
].join('\n');
}
function errorCode(error) {
return error?.code ?? error?.failure?.code;
}
@ -200,6 +394,9 @@ function modelErrorMessage(error, action) {
return t('当前聊天绑定的会话已不存在,请重试。');
}
if (code === 'model-unavailable') {
if (action === 'reasoning-select') {
return t('无法切换推理等级。当前模型或推理等级不可用。');
}
return t('无法切换到该模型。模型当前不可用,或不支持当前会话中的图片。');
}
if (code === WORKSPACE_SESSION_STALE || code === 'workspace-bot-not-found') {
@ -211,26 +408,32 @@ function modelErrorMessage(error, action) {
if (code === MODEL_SELECTION_MISMATCH) {
const expected = error?.expected;
const actual = error?.actual;
const lines = [t('模型切换失败,请稍后重试。')];
const lines = [action === 'reasoning-select'
? t('推理等级切换失败,请稍后重试。')
: t('模型切换失败,请稍后重试。')];
if (expected?.provider && expected?.model) {
lines.push('', `requested: ${safeDisplayText(modelId(expected.provider, expected.model))}`);
lines.push('', `requested: ${safeDisplayText(selectionText(expected))}`);
}
if (actual?.provider && actual?.model) {
const label = error?.source === 'models.current'
? t('当前模型:')
: 'selectModel.selected:';
lines.push(`${label} ${safeDisplayText(modelId(actual.provider, actual.model))}`);
lines.push(`${label} ${safeDisplayText(selectionText(actual))}`);
} else {
lines.push(`${error?.source ?? 'Harness'}: unconfirmed`);
}
return lines.join('\n');
}
if (code === 'cancelled' || error?.name === 'AbortError') {
return action === 'list' ? t('获取模型列表已取消。') : t('模型切换已取消。');
if (action === 'list') return t('获取模型列表已取消。');
if (action === 'reasoning-list') return t('获取推理等级列表已取消。');
if (action === 'reasoning-select') return t('推理等级切换已取消。');
return t('模型切换已取消。');
}
return action === 'list'
? t('暂时无法获取模型列表,请稍后重试。')
: t('模型切换失败,请稍后重试。');
if (action === 'list') return t('暂时无法获取模型列表,请稍后重试。');
if (action === 'reasoning-list') return t('暂时无法获取推理等级,请稍后重试。');
if (action === 'reasoning-select') return t('推理等级切换失败,请稍后重试。');
return t('模型切换失败,请稍后重试。');
}
async function boundSession(harness, state, key, options) {
@ -279,12 +482,12 @@ async function selectAndVerifyModel(session, selection, options) {
throw new TypeError('Harness session does not support model selection');
}
const selected = (await session.selectModel(selection, options))?.selected;
if (!sameModel(selected, selection)) {
if (!confirmsSelection(selected, selection)) {
throw selectionMismatch(selection, selected, 'selectModel.selected');
}
const current = (await sessionCatalog(session, options)).current;
if (!sameModel(current, selection)) {
throw selectionMismatch(selection, current, 'models.current');
if (!sameSelection(current, selected)) {
throw selectionMismatch(selected, current, 'models.current');
}
return current;
}
@ -293,17 +496,29 @@ function isModelsCommand(command) {
return MODELS_COMMAND.test(command);
}
function isReasoningListCommand(command) {
return REASONING_LIST_COMMAND.test(command) || REASONINGS_COMMAND.test(command);
}
function isReasoningCommand(command) {
return REASONING_COMMAND.test(command);
}
export function isModelCommand(text) {
if (typeof text !== 'string') return false;
const command = text.trim();
return MODELS_COMMAND.test(command) || MODEL_COMMAND.test(command);
return MODELS_COMMAND.test(command)
|| MODEL_COMMAND.test(command)
|| REASONING_LIST_COMMAND.test(command)
|| REASONINGS_COMMAND.test(command)
|| REASONING_COMMAND.test(command);
}
export async function runModelCommand(text, harness, state, key, options = {}) {
if (!isModelCommand(text)) return null;
const command = text.trim();
if (options.hasImages) {
return commandResult(t('模型命令仅支持纯文字,请移除图片后重试。'));
return commandResult(t('模型和推理等级命令仅支持纯文字,请移除图片后重试。'));
}
const requestOptions = rpcOptions(options.signal);
@ -320,20 +535,115 @@ export async function runModelCommand(text, harness, state, key, options = {}) {
}
}
const match = /^\/model(?:[ \t]+([^\s]+))?[ \t]*$/iu.exec(command);
if (isReasoningListCommand(command)) {
if (!/^\/(?:reasoninglist|reasonings)[ \t]*$/iu.test(command)) {
return commandResult(t(REASONING_LIST_USAGE));
}
try {
const bound = await boundSession(harness, state, key, requestOptions);
if (!bound) return commandResult(noReasoningSessionMessage());
return commandResult(formatReasoningCatalog(
await sessionCatalog(bound.session, requestOptions),
));
} catch (error) {
return commandResult(modelErrorMessage(error, 'reasoning-list'));
}
}
if (isReasoningCommand(command)) {
const match = /^\/reasoning(?:[ \t]+([^\s]+))?[ \t]*$/iu.exec(command);
if (!match) return commandResult(t(REASONING_USAGE));
const requested = match[1];
if (!requested) {
try {
const bound = await boundSession(harness, state, key, requestOptions);
if (!bound) return commandResult(noReasoningSessionMessage());
return commandResult(currentReasoningMessage(
await sessionCatalog(bound.session, requestOptions),
));
} catch (error) {
return commandResult(modelErrorMessage(error, 'reasoning-list'));
}
}
if (options.pendingInteraction) {
return commandResult([
t('当前任务正在等待你的回答或审批。'),
'',
t('请先处理当前请求,或者发送 /stop 停止任务。'),
].join('\n'));
}
try {
return await withSessionBindingLock(state, key, async () => {
const bound = await boundSession(harness, state, key, requestOptions);
if (!bound) return commandResult(noReasoningSessionMessage());
if (await sessionIsBusy(bound.session, options.control, requestOptions)) {
return commandResult(t('当前任务正在运行,请等待完成或先发送 /stop。'));
}
const catalog = await sessionCatalog(bound.session, requestOptions);
const current = catalog.current;
const model = modelForSelection(catalog, current);
let effort;
if (requested.toLowerCase() === '--default') {
effort = undefined;
} else {
if (!model?.reasoning) {
return commandResult(unsupportedReasoningMessage(current, requested, model));
}
effort = reasoningEffortById(model, requested);
if (!effort) {
const numberRequest = positiveNumberRequest(requested);
if (numberRequest?.index === null) {
return commandResult(invalidReasoningNumberMessage(requested));
}
if (!numberRequest) {
return commandResult(unsupportedReasoningMessage(current, requested, model));
}
effort = reasoningEffortAt(model, numberRequest.index);
if (!effort) return commandResult(invalidReasoningNumberMessage(requested));
}
effort = effort.id;
}
const selection = {
provider: current.provider,
model: current.model,
...(effort === undefined ? {} : { reasoningEffort: effort }),
};
const applied = await selectAndVerifyModel(bound.session, selection, requestOptions);
assertSessionBinding(state, key, bound.sessionId);
return commandResult(t(`推理等级已切换为:
{effort}
当前模型:{model}
后续消息将使用该推理等级。`, {
model: modelId(applied.provider, applied.model),
effort: reasoningEffortText(
model,
effectiveReasoningEffort(applied, model),
),
}));
});
} catch (error) {
return commandResult(modelErrorMessage(error, 'reasoning-select'));
}
}
const match = /^\/model(?:[ \t]+([^\s]+)(?:[ \t]+([^\s]+))?)?[ \t]*$/iu.exec(command);
if (!match) return commandResult(t(MODEL_USAGE));
const requested = match[1];
const requestedEffort = match[2];
if (!requested) {
try {
const bound = await boundSession(harness, state, key, requestOptions);
if (!bound) return commandResult(noSessionMessage());
const catalog = await sessionCatalog(bound.session, requestOptions);
return commandResult(currentModelMessage(catalog.current));
return commandResult(currentModelMessage(catalog));
} catch (error) {
return commandResult(modelErrorMessage(error, 'select'));
}
}
const numberRequest = modelNumberRequest(requested);
const numberRequest = positiveNumberRequest(requested);
if (numberRequest?.index === null) {
return commandResult(invalidModelNumberMessage(requested));
}
@ -370,6 +680,18 @@ export async function runModelCommand(text, harness, state, key, options = {}) {
t('请发送 /models 查看可用模型。'),
].join('\n'));
}
const targetModel = modelForSelection(catalog, selection);
if (requestedEffort !== undefined) {
const effort = reasoningEffortById(targetModel, requestedEffort);
if (!effort) {
return commandResult(unsupportedReasoningMessage(
selection,
requestedEffort,
targetModel,
));
}
selection.reasoningEffort = effort.id;
}
let applied;
if (bound) {
@ -400,8 +722,15 @@ export async function runModelCommand(text, harness, state, key, options = {}) {
}
return commandResult(t(`模型已切换为:
{model}
推理等级:{effort}
后续消息将使用该模型。`, { model: modelId(applied.provider, applied.model) }));
后续消息将使用该模型和推理等级。`, {
model: modelId(applied.provider, applied.model),
effort: reasoningEffortText(
targetModel,
effectiveReasoningEffort(applied, targetModel),
),
}));
});
} catch (error) {
return commandResult(modelErrorMessage(error, 'select'));

View file

@ -401,8 +401,10 @@ export class TextHarnessBridge {
t('/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题'),
t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),
t('/models 按序号列出所有可用模型'),
t('/model [序号或完整模型ID] 查看或切换当前会话模型'),
t('示例:先发 /models,再发 /model 2'),
t('/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级'),
t('/reasoning [序号、等级ID或 --default] 查看或切换当前推理等级'),
t('/model [序号或完整模型ID] [推理等级ID] 查看或切换当前会话模型'),
t('示例:先发 /models,再发 /model 2 [推理等级ID]'),
t('/presetlist 按序号列出可用 Agent Preset'),
t('/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset'),
t('纯数字 ID:/preset id:<ID>'),

View file

@ -53,8 +53,10 @@ function helpText() {
t('/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题'),
t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),
t('/models 按序号列出所有可用模型'),
t('/model [序号或完整模型ID] 查看或切换当前会话模型'),
t('示例:先发 /models,再发 /model 2'),
t('/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级'),
t('/reasoning [序号、等级ID或 --default] 查看或切换当前推理等级'),
t('/model [序号或完整模型ID] [推理等级ID] 查看或切换当前会话模型'),
t('示例:先发 /models,再发 /model 2 [推理等级ID]'),
t('/presetlist 按序号列出可用 Agent Preset'),
t('/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset'),
t('纯数字 ID:/preset id:<ID>'),

View file

@ -60,8 +60,10 @@ const HELP_TEXT = () => [
t('/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题'),
t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),
t('/models 按序号列出所有可用模型'),
t('/model [序号或完整模型ID] 查看或切换当前会话模型'),
t('示例:先发 /models,再发 /model 2'),
t('/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级'),
t('/reasoning [序号、等级ID或 --default] 查看或切换当前推理等级'),
t('/model [序号或完整模型ID] [推理等级ID] 查看或切换当前会话模型'),
t('示例:先发 /models,再发 /model 2 [推理等级ID]'),
t('/presetlist 按序号列出可用 Agent Preset'),
t('/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset'),
t('纯数字 ID:/preset id:<ID>'),

View file

@ -801,6 +801,12 @@ test('DingTalk lists models and presets without prompting and advertises fast co
assert.equal(creates, 0);
assert.equal(fixture.sessions.size, 0);
await bridge.accept(message('reasoning-dingtalk', '/reasoninglist'));
assert.match(sent.at(-1).text, /还没有会话/);
assert.equal(asks, 0);
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);
@ -832,10 +838,15 @@ test('DingTalk lists models and presets without prompting and advertises fast co
await bridge.accept(message('help-models-dingtalk', '/help'));
const help = sent.at(-1).text;
for (const command of ['/models', '/model', '/presetlist', '/preset', '/preset --default', '/stop', '/steer']) {
for (const command of [
'/models', '/model', '/reasoninglist', '/reasonings', '/reasoning',
'/presetlist', '/preset', '/preset --default', '/stop', '/steer',
]) {
assert.equal(help.includes(command), true, command);
}
assert.match(help, /\/model 2/);
assert.match(help, /\/model .*\[推理等级ID\]/);
assert.match(help, /示例:先发 \/models,再发 \/model 2 \[推理等级ID\]/);
assert.doesNotMatch(help, /\/model 2 high\b/);
assert.match(help, /\/preset id:<ID>/);
});

View file

@ -344,6 +344,13 @@ test('Feishu lists models and presets without prompting and advertises fast comm
assert.equal(creates, 0);
assert.equal(fixture.sessions.size, 0);
await bridge.accept(event('reasoning-feishu', '/reasoninglist'));
await bridge.waitForIdle();
assert.match(sent.at(-1), /还没有会话/);
assert.equal(asks, 0);
assert.equal(creates, 0);
assert.equal(fixture.sessions.size, 0);
const presetReplyStart = sent.length;
await bridge.accept(event('presets-feishu', '/presetlist'));
await bridge.waitForIdle();
@ -380,10 +387,13 @@ test('Feishu lists models and presets without prompting and advertises fast comm
await bridge.accept(event('help-feishu', '/help'));
await bridge.waitForIdle();
const help = sent.at(-1);
for (const command of ['/models', '/model', '/presetlist', '/preset', '/preset --default', '/stop', '/steer']) {
for (const command of [
'/models', '/model', '/reasoninglist', '/reasonings', '/reasoning',
'/presetlist', '/preset', '/preset --default', '/stop', '/steer',
]) {
assert.equal(help.includes(command), true, command);
}
assert.match(help, /\/model 2/);
assert.match(help, /\/model .*\[推理等级ID\]/);
assert.match(help, /\/preset id:<ID>/);
});

View file

@ -64,12 +64,21 @@ test('menu exposes the increased command set and keeps repair number-only', () =
assert.equal(actions.includes('repair'), false);
});
test('menu help advertises Agent Preset commands', () => {
test('menu and card help advertise Agent Preset and reasoning commands', () => {
const help = menuHelpText();
assert.match(help, /\/presetlist/);
assert.match(help, /\/preset \[序号或完整ID\]/);
assert.match(help, /\/preset id:<ID>/);
assert.match(help, /\/preset --default/);
assert.match(help, /\/reasoninglist 或 \/reasonings/);
assert.match(help, /\/reasoning \[序号、等级ID或 --default\]/);
assert.match(help, /\/model \[序号或完整模型ID\] \[推理等级ID\]/);
const card = helpCard();
assert.match(card, /\/reasoninglist/);
assert.match(card, /\/reasonings/);
assert.match(card, /\/reasoning \[序号、等级ID或 --default\]/);
assert.match(card, /\/model \[序号或完整模型ID\] \[推理等级ID\]/);
});
test('card-action probe carries only its action and opaque nonce', () => {

View file

@ -633,6 +633,12 @@ test('QQ lists models and presets without prompting and advertises fast commands
assert.equal(creates, 0);
assert.equal(fixture.sessions.size, 0);
await bridge.accept(message({ messageId: 'reasoning-qq', content: '/reasoninglist' }));
assert.match(sent.at(-1), /还没有会话/);
assert.equal(asks, 0);
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);
@ -664,10 +670,15 @@ test('QQ lists models and presets without prompting and advertises fast commands
await bridge.accept(message({ messageId: 'help-models-qq', content: '/help' }));
const help = sent.at(-1);
for (const command of ['/models', '/model', '/presetlist', '/preset', '/preset --default', '/stop', '/steer']) {
for (const command of [
'/models', '/model', '/reasoninglist', '/reasonings', '/reasoning',
'/presetlist', '/preset', '/preset --default', '/stop', '/steer',
]) {
assert.equal(help.includes(command), true, command);
}
assert.match(help, /\/model 2/);
assert.match(help, /\/model .*\[推理等级ID\]/);
assert.match(help, /示例:先发 \/models,再发 \/model 2 \[推理等级ID\]/);
assert.doesNotMatch(help, /\/model 2 high\b/);
assert.match(help, /\/preset id:<ID>/);
});

View file

@ -93,11 +93,81 @@ test('HarnessClient exposes and validates model and run-state RPCs', async () =>
assert.ok(calls.filter(([type]) => type === 'ensureRunning').every(([, value]) => value === options));
});
test('HarnessClient preserves reasoning metadata and forwards an optional reasoning effort', async () => {
const { calls, client, responses } = modelClient();
const reasoningCatalog = {
groups: [{
id: 'deepseek-official',
name: 'DeepSeek',
models: [{
id: 'deepseek-v4',
name: 'DeepSeek V4',
description: 'Reasoning model',
reasoning: {
efforts: [
{ id: 'off', name: 'Off' },
{ id: 'high', name: 'High', description: 'More thinking' },
],
defaultEffort: 'high',
},
}],
}],
failures: [],
};
responses.set('llm.models', reasoningCatalog);
responses.set('session.models', {
...reasoningCatalog,
current: {
provider: 'deepseek-official',
model: 'deepseek-v4',
reasoningEffort: 'high',
},
routable: true,
});
responses.set('session.selectModel', {
selected: {
provider: 'deepseek-official',
model: 'deepseek-v4',
reasoningEffort: 'off',
},
});
assert.equal((await client.listModels()).groups[0].models[0].reasoning.defaultEffort, 'high');
assert.equal((await client.getSessionModels('session-one')).current.reasoningEffort, 'high');
await client.selectSessionModel('session-one', {
provider: 'deepseek-official',
model: 'deepseek-v4',
reasoningEffort: 'off',
});
assert.deepEqual(
calls.find((entry) => entry[0] === 'rpc' && entry[1] === 'session.selectModel')?.[2],
{
sessionId: 'session-one',
provider: 'deepseek-official',
model: 'deepseek-v4',
reasoningEffort: 'off',
},
);
});
test('HarnessClient rejects malformed model and run-state responses', async () => {
const { client, responses } = modelClient();
responses.set('llm.models', { groups: null, failures: [] });
await assert.rejects(client.listModels(), /invalid response for llm\.models/);
responses.set('llm.models', {
...CATALOG,
groups: [{
...CATALOG.groups[0],
models: [{
...CATALOG.groups[0].models[0],
reasoning: { efforts: [] },
}],
}],
});
await assert.rejects(client.listModels(), /invalid response for llm\.models/);
responses.set('session.models', {
...CATALOG,
current: { provider: 'deepseek-official' },
@ -108,12 +178,35 @@ test('HarnessClient rejects malformed model and run-state responses', async () =
/invalid response for session\.models/,
);
responses.set('session.models', {
...CATALOG,
current: {
provider: 'deepseek-official',
model: 'deepseek-v4',
reasoningEffort: '',
},
routable: true,
});
await assert.rejects(
client.getSessionModels('session-one'),
/invalid response for session\.models/,
);
responses.set('session.selectModel', { selected: { provider: '', model: 'bad' } });
await assert.rejects(
client.selectSessionModel('session-one', { provider: 'p', model: 'm' }),
/invalid response for session\.selectModel/,
);
await assert.rejects(
client.selectSessionModel('session-one', {
provider: 'deepseek-official',
model: 'deepseek-v4',
reasoningEffort: '',
}),
/provider and model are required/,
);
responses.set('session.list', { items: [{ sessionId: 'session-one', running: 'yes' }] });
await assert.rejects(client.isSessionRunning('session-one'), /invalid response for session\.list/);
});

View file

@ -44,6 +44,10 @@ test('t() translates known keys and fills placeholders in English mode', () => {
}
assert.equal(t('未收录的中文'), '未收录的中文');
assert.equal(t('{value} 测试', { value: 'x' }), EN['{value} 测试'] ?? 'x 测试');
assert.equal(
t('示例:先发 /models,再发 /model 2 [推理等级ID]'),
'Example: send /models first, then /model 2 [reasoning effort ID]',
);
assert.equal(t(42), 42);
});

View file

@ -420,6 +420,12 @@ test('all four shared text channels list models and presets locally and advertis
assert.equal(creates, 0, `${name} create`);
assert.equal(fixture.sessions.size, 0, `${name} session binding`);
await bridge.accept(message(`reasoning-${name}`, '/reasoninglist'));
assert.match(sent.at(-1), /还没有会话/, `${name} reasoning command`);
assert.equal(asks, 0, `${name} reasoning ask`);
assert.equal(creates, 0, `${name} reasoning create`);
assert.equal(fixture.sessions.size, 0, `${name} reasoning session binding`);
const presetReplyStart = sent.length;
await bridge.accept(message(`presets-${name}`, '/presetlist'));
const presetReplies = sent.slice(presetReplyStart);
@ -451,10 +457,15 @@ test('all four shared text channels list models and presets locally and advertis
await bridge.accept(message(`help-${name}`, '/help'));
const help = sent.at(-1);
for (const command of ['/models', '/model', '/presetlist', '/preset', '/preset --default', '/stop', '/steer']) {
for (const command of [
'/models', '/model', '/reasoninglist', '/reasonings', '/reasoning',
'/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, /\/model .*\[推理等级ID\]/, `${name} optional model reasoning effort`);
assert.match(help, /示例:先发 \/models,再发 \/model 2 \[推理等级ID\]/, `${name} model effort placeholder`);
assert.doesNotMatch(help, /\/model 2 high\b/, `${name} does not assume a reasoning effort ID`);
assert.match(help, /\/preset id:<ID>/, `${name} numeric preset ID selection`);
}
});

View file

@ -421,6 +421,11 @@ test('Enterprise WeChat lists models and presets without prompting and advertise
assert.equal(asks, 0);
assert.equal(creates, 0);
await bridge.accept(frame({ msgid: 'reasoning-wecom', text: { content: '/reasoninglist' } }));
assert.match(transport.streamed.at(-1).content, /还没有会话/);
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
@ -454,10 +459,15 @@ test('Enterprise WeChat lists models and presets without prompting and advertise
await bridge.accept(frame({ msgid: 'help-models-wecom', text: { content: '/help' } }));
const help = transport.streamed.at(-1).content;
for (const command of ['/models', '/model', '/presetlist', '/preset', '/preset --default', '/stop', '/steer']) {
for (const command of [
'/models', '/model', '/reasoninglist', '/reasonings', '/reasoning',
'/presetlist', '/preset', '/preset --default', '/stop', '/steer',
]) {
assert.equal(help.includes(command), true, command);
}
assert.match(help, /\/model 2/);
assert.match(help, /\/model .*\[推理等级ID\]/);
assert.match(help, /示例:先发 \/models,再发 \/model 2 \[推理等级ID\]/);
assert.doesNotMatch(help, /\/model 2 high\b/);
assert.match(help, /\/preset id:<ID>/);
});

View file

@ -738,6 +738,12 @@ test('Weixin lists models and presets without prompting and advertises fast comm
assert.equal(creates, 0);
assert.equal(fixture.sessions.size, 0);
await bridge.accept(message('reasoning-weixin', '/reasoninglist'));
assert.match(sent.at(-1).text, /还没有会话/);
assert.equal(asks, 0);
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);
@ -769,10 +775,15 @@ test('Weixin lists models and presets without prompting and advertises fast comm
await bridge.accept(message('help-models-weixin', '/help'));
const help = sent.at(-1).text;
for (const command of ['/models', '/model', '/presetlist', '/preset', '/preset --default', '/stop', '/steer']) {
for (const command of [
'/models', '/model', '/reasoninglist', '/reasonings', '/reasoning',
'/presetlist', '/preset', '/preset --default', '/stop', '/steer',
]) {
assert.equal(help.includes(command), true, command);
}
assert.match(help, /\/model 2/);
assert.match(help, /\/model .*\[推理等级ID\]/);
assert.match(help, /示例:先发 \/models,再发 \/model 2 \[推理等级ID\]/);
assert.doesNotMatch(help, /\/model 2 high\b/);
assert.match(help, /\/preset id:<ID>/);
});

View file

@ -25,6 +25,54 @@ const CATALOG = Object.freeze({
failures: [],
});
const REASONING_CATALOG = Object.freeze({
groups: [
{
id: 'deepseek-official',
name: 'DeepSeek',
models: [
{
id: 'deepseek-v4-flash',
name: 'DeepSeek V4 Flash',
reasoning: {
efforts: [
{ id: 'off', name: 'Off' },
{ id: 'high', name: 'High', description: '复杂任务' },
{ id: 'max', name: 'Max' },
],
defaultEffort: 'high',
},
},
{
id: 'deepseek-v4-pro',
name: 'DeepSeek V4 Pro',
reasoning: {
efforts: [
{ id: 'medium', name: 'Medium' },
{ id: 'xhigh', name: 'XHigh' },
],
defaultEffort: 'medium',
},
},
],
},
CATALOG.groups[1],
],
failures: [],
});
function reasoningSessionCatalog(current = {
provider: 'deepseek-official',
model: 'deepseek-v4-flash',
reasoningEffort: 'high',
}) {
return {
...REASONING_CATALOG,
current,
routable: true,
};
}
function deferred() {
let resolve;
const promise = new Promise((settle) => { resolve = settle; });
@ -113,13 +161,17 @@ function fixture({
return { calls, harness, state, boundId: () => boundId };
}
test('isModelCommand recognizes only /models and /model command prefixes', () => {
test('isModelCommand recognizes model and reasoning command prefixes', () => {
for (const command of [
'/models', ' /MODELS ', '/models ignored', '/model', '/MoDeL openai/gpt-5',
'/reasoning', '/REASONING high', '/reasoninglist', '/ReAsOnInGs',
]) {
assert.equal(isModelCommand(command), true, command);
}
for (const value of [null, '', 'model', '/modelx', '/modelsx', 'hello /models']) {
for (const value of [
null, '', 'model', '/modelx', '/modelsx', 'hello /models',
'/reasoningx', '/reasoninglists', 'hello /reasoning',
]) {
assert.equal(isModelCommand(value), false, String(value));
}
});
@ -209,6 +261,310 @@ test('/model reports current state without creating or selecting', async () => {
assert.equal(existingFixture.calls.some(([name]) => name === 'selectModel'), false);
});
test('/reasoninglist and /reasonings are identical read-only aliases', async () => {
const first = fixture({
initialSessionId: 'session-one',
sessionCatalog: reasoningSessionCatalog(),
});
const second = fixture({
initialSessionId: 'session-one',
sessionCatalog: reasoningSessionCatalog(),
});
const listed = await runModelCommand(
'/reasoninglist', first.harness, first.state, 'direct:one',
);
const aliased = await runModelCommand(
'/REASONINGS', second.harness, second.state, 'direct:one',
);
assert.equal(aliased.message, listed.message);
assert.match(listed.message, /deepseek-official\/deepseek-v4-flash/);
assert.match(listed.message, /2\. High \(high\)(当前、默认)/);
assert.match(listed.message, /复杂任务/);
assert.match(listed.message, /\/reasoning --default/);
assert.equal(first.calls.some(([name]) => name === 'selectModel'), false);
assert.equal(second.calls.some(([name]) => name === 'selectModel'), false);
});
test('/reasoninglist keeps default reset available without current-model metadata', async () => {
for (const sessionCatalog of [
{
...CATALOG,
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
routable: true,
},
{
...CATALOG,
current: {
provider: 'private-provider',
model: 'private-model',
reasoningEffort: 'custom-high',
},
routable: true,
},
]) {
const current = fixture({ initialSessionId: 'session-one', sessionCatalog });
const listed = await runModelCommand(
'/reasoninglist', current.harness, current.state, 'direct:one',
);
assert.match(listed.message, /不提供可切换的推理等级/);
assert.match(listed.message, /恢复默认等级:\/reasoning --default/);
assert.equal(current.calls.some(([name]) => name === 'selectModel'), false);
}
});
test('reasoning read commands require an existing Session and validate syntax', async () => {
const missing = fixture();
for (const command of ['/reasoning', '/reasoninglist', '/reasonings']) {
const result = await runModelCommand(command, missing.harness, missing.state, 'direct:one');
assert.match(result.message, /还没有会话/, command);
}
assert.equal(missing.calls.some(([name]) => name === 'createSession'), false);
const existing = fixture({
initialSessionId: 'session-one',
sessionCatalog: reasoningSessionCatalog(),
});
assert.match(
(await runModelCommand(
'/reasoninglist extra', existing.harness, existing.state, 'direct:one',
)).message,
/不带参数/,
);
assert.match(
(await runModelCommand(
'/reasoning high extra', existing.harness, existing.state, 'direct:one',
)).message,
/用法/,
);
});
test('/reasoning reports the explicit or model-default effort without selecting', async () => {
const explicit = fixture({
initialSessionId: 'session-one',
sessionCatalog: reasoningSessionCatalog(),
});
const explicitResult = await runModelCommand(
'/reasoning', explicit.harness, explicit.state, 'direct:one',
);
assert.match(explicitResult.message, /当前推理等级:High \(high\)/);
const inherited = fixture({
initialSessionId: 'session-one',
sessionCatalog: reasoningSessionCatalog({
provider: 'deepseek-official',
model: 'deepseek-v4-flash',
}),
});
const inheritedResult = await runModelCommand(
'/reasoning', inherited.harness, inherited.state, 'direct:one',
);
assert.match(inheritedResult.message, /当前推理等级:High \(high\)/);
assert.equal(explicit.calls.some(([name]) => name === 'selectModel'), false);
assert.equal(inherited.calls.some(([name]) => name === 'selectModel'), false);
});
test('/reasoning preserves an unknown current effort and represents Provider Default', async () => {
const groups = structuredClone(REASONING_CATALOG.groups);
delete groups[0].models[0].reasoning.defaultEffort;
const raw = fixture({
initialSessionId: 'session-one',
sessionCatalog: {
groups,
failures: [],
current: {
provider: 'deepseek-official',
model: 'deepseek-v4-flash',
reasoningEffort: 'gateway-ultra',
},
routable: true,
},
});
const listed = await runModelCommand(
'/reasoninglist', raw.harness, raw.state, 'direct:one',
);
assert.match(listed.message, /当前推理等级:gateway-ultra/);
assert.doesNotMatch(listed.message, /(当前/);
const providerDefault = fixture({
initialSessionId: 'session-one',
sessionCatalog: {
groups,
failures: [],
current: {
provider: 'deepseek-official',
model: 'deepseek-v4-flash',
},
routable: true,
},
});
const current = await runModelCommand(
'/reasoning', providerDefault.harness, providerDefault.state, 'direct:one',
);
assert.match(current.message, /Default(由模型或 Provider 决定)/);
});
test('/reasoning switches the current model effort by index or exact ID', async () => {
for (const [requested, expected] of [['3', 'max'], ['off', 'off']]) {
const { calls, harness, state } = fixture({
initialSessionId: 'session-one',
sessionCatalog: reasoningSessionCatalog(),
});
const result = await runModelCommand(
`/reasoning ${requested}`, harness, state, 'direct:one',
);
assert.match(result.message, new RegExp(`推理等级已切换为:[\\s\\S]*${expected}`));
assert.deepEqual(calls.find(([name]) => name === 'selectModel'), [
'selectModel',
'session-one',
{
provider: 'deepseek-official',
model: 'deepseek-v4-flash',
reasoningEffort: expected,
},
{},
]);
}
});
test('/reasoning prefers an exact opaque numeric effort ID before an index', async () => {
const groups = structuredClone(REASONING_CATALOG.groups);
groups[0].models[0].reasoning = {
efforts: [
{ id: '3', name: 'Literal Three' },
{ id: '0', name: 'Literal Zero' },
{ id: '9007199254740992', name: 'Huge Numeric' },
],
defaultEffort: '3',
};
for (const requested of ['3', '0', '9007199254740992']) {
const { calls, harness, state } = fixture({
initialSessionId: 'session-one',
sessionCatalog: {
groups,
failures: [],
current: {
provider: 'deepseek-official',
model: 'deepseek-v4-flash',
reasoningEffort: '3',
},
routable: true,
},
});
const result = await runModelCommand(
`/reasoning ${requested}`, harness, state, 'direct:one',
);
assert.match(result.message, /推理等级已切换为/);
assert.equal(
calls.find(([name]) => name === 'selectModel')?.[2]?.reasoningEffort,
requested,
);
}
});
test('/reasoning --default restores the current model default effort', async () => {
const { calls, harness, state } = fixture({
initialSessionId: 'session-one',
sessionCatalog: reasoningSessionCatalog({
provider: 'deepseek-official',
model: 'deepseek-v4-flash',
reasoningEffort: 'max',
}),
selectionResult: {
provider: 'deepseek-official',
model: 'deepseek-v4-flash',
reasoningEffort: 'high',
},
selectionReadback: {
provider: 'deepseek-official',
model: 'deepseek-v4-flash',
reasoningEffort: 'high',
},
});
const result = await runModelCommand(
'/reasoning --default', harness, state, 'direct:one',
);
assert.match(result.message, /推理等级已切换为:[\s\S]*High \(high\)/);
assert.deepEqual(calls.find(([name]) => name === 'selectModel')?.[2], {
provider: 'deepseek-official',
model: 'deepseek-v4-flash',
});
});
test('/reasoning --default can reset an advisory-unlisted current model', async () => {
const sessionCatalog = {
...CATALOG,
current: {
provider: 'private-provider',
model: 'private-model',
reasoningEffort: 'custom-high',
},
routable: true,
};
const { calls, harness, state } = fixture({
initialSessionId: 'session-one',
sessionCatalog,
});
const result = await runModelCommand(
'/reasoning --default', harness, state, 'direct:one',
);
assert.match(result.message, /推理等级已切换为/);
assert.match(result.message, /Default/);
assert.deepEqual(calls.find(([name]) => name === 'selectModel')?.[2], {
provider: 'private-provider',
model: 'private-model',
});
});
test('/reasoning rejects unsupported levels without changing the selection', async () => {
for (const requested of ['0', '4', '9007199254740992', 'medium']) {
const { calls, harness, state } = fixture({
initialSessionId: 'session-one',
sessionCatalog: reasoningSessionCatalog(),
});
const result = await runModelCommand(
`/reasoning ${requested}`, harness, state, 'direct:one',
);
assert.match(result.message, /序号无效|不支持推理等级/, requested);
assert.equal(calls.some(([name]) => name === 'selectModel'), false, requested);
}
const unsupported = fixture({ initialSessionId: 'session-one' });
const result = await runModelCommand(
'/reasoning high', unsupported.harness, unsupported.state, 'direct:one',
);
assert.match(result.message, /不提供可切换的推理等级/);
assert.equal(unsupported.calls.some(([name]) => name === 'selectModel'), false);
const noSession = fixture({ globalCatalog: REASONING_CATALOG });
const noSessionResult = await runModelCommand(
'/reasoning high', noSession.harness, noSession.state, 'direct:one',
);
assert.match(noSessionResult.message, /还没有会话/);
assert.equal(noSession.calls.some(([name]) => name === 'createSession'), false);
});
test('reasoning commands reject image-bearing control messages', async () => {
const current = fixture({
initialSessionId: 'session-one',
sessionCatalog: reasoningSessionCatalog(),
});
for (const command of ['/reasoning', '/reasoninglist', '/reasonings', '/reasoning high']) {
const result = await runModelCommand(
command, current.harness, current.state, 'direct:one', { hasImages: true },
);
assert.match(result.message, /仅支持纯文字/, command);
}
assert.equal(current.calls.some(([name]) => name === 'models'), false);
assert.equal(current.calls.some(([name]) => name === 'selectModel'), false);
});
test('/model uses an exact catalog ID and preserves slashes inside the model ID', async () => {
const { calls, harness, state } = fixture({ initialSessionId: 'session-one' });
const control = Object.freeze({ route: 'direct:one' });
@ -242,6 +598,71 @@ test('/model accepts the current catalog\'s global 1-based model number', async
]);
});
test('/model accepts an optional effort and otherwise applies the target model default', async () => {
for (const [command, expectedEffort] of [
['/model 2 xhigh', 'xhigh'],
['/model deepseek-official/deepseek-v4-pro', 'medium'],
]) {
const resolvedDefault = command.includes(' xhigh') ? undefined : {
provider: 'deepseek-official',
model: 'deepseek-v4-pro',
reasoningEffort: 'medium',
};
const { calls, harness, state } = fixture({
initialSessionId: 'session-one',
globalCatalog: REASONING_CATALOG,
sessionCatalog: reasoningSessionCatalog(),
selectionResult: resolvedDefault,
selectionReadback: resolvedDefault,
});
const result = await runModelCommand(command, harness, state, 'direct:one');
assert.match(result.message, /deepseek-official\/deepseek-v4-pro/);
assert.match(result.message, new RegExp(`推理等级:.*${expectedEffort}`));
assert.deepEqual(calls.find(([name]) => name === 'selectModel')?.[2], {
provider: 'deepseek-official',
model: 'deepseek-v4-pro',
...(command.includes(' xhigh') ? { reasoningEffort: expectedEffort } : {}),
});
}
});
test('/model validates an optional effort against the target model atomically', async () => {
const invalid = fixture({ globalCatalog: REASONING_CATALOG });
const failed = await runModelCommand(
'/model 2 high', invalid.harness, invalid.state, 'direct:one',
);
assert.match(failed.message, /不支持推理等级:high/);
assert.match(failed.message, /medium, xhigh/);
assert.equal(invalid.calls.some(([name]) => name === 'createSession'), false);
assert.equal(invalid.calls.some(([name]) => name === 'selectModel'), false);
const valid = fixture({ globalCatalog: REASONING_CATALOG });
const switched = await runModelCommand(
'/model 2 xhigh', valid.harness, valid.state, 'direct:one',
);
assert.match(switched.message, /模型已切换为/);
assert.equal(valid.boundId(), 'session-created');
assert.deepEqual(valid.calls.find(([name]) => name === 'selectModel')?.[2], {
provider: 'deepseek-official',
model: 'deepseek-v4-pro',
reasoningEffort: 'xhigh',
});
});
test('/model reports the current reasoning effort when the Session exposes it', async () => {
const current = fixture({
initialSessionId: 'session-one',
sessionCatalog: reasoningSessionCatalog(),
});
const result = await runModelCommand('/model', current.harness, current.state, 'direct:one');
assert.match(result.message, /当前推理等级:High \(high\)/);
assert.match(result.message, /\/reasoninglist/);
assert.equal(current.calls.some(([name]) => name === 'selectModel'), false);
});
test('/model rejects invalid model numbers without creating or selecting a Session', async () => {
for (const requested of ['0', '4', '9007199254740992']) {
const { calls, harness, state } = fixture();
@ -469,6 +890,78 @@ test('/model refuses pending interactions and active or running Sessions', async
}
});
test('/reasoning mutations refuse pending or busy Sessions while reads remain available', async () => {
const pending = fixture({
initialSessionId: 'session-one',
sessionCatalog: reasoningSessionCatalog(),
});
const pendingResult = await runModelCommand(
'/reasoning off', pending.harness, pending.state, 'direct:one',
{ pendingInteraction: true },
);
assert.match(pendingResult.message, /等待你的回答或审批/);
assert.equal(pending.calls.some(([name]) => name === 'selectModel'), false);
for (const runState of [{ running: true }, { activeTurn: true }]) {
const active = fixture({
initialSessionId: 'session-one',
sessionCatalog: reasoningSessionCatalog(),
...runState,
});
const failed = await runModelCommand(
'/reasoning off', active.harness, active.state, 'direct:one',
{ control: 'owner-one' },
);
assert.match(failed.message, /当前任务正在运行/);
assert.equal(active.calls.some(([name]) => name === 'selectModel'), false);
const listed = await runModelCommand(
'/reasoninglist', active.harness, active.state, 'direct:one',
);
assert.match(listed.message, /可用推理等级/);
}
});
test('/reasoning verifies both the selected effort and authoritative readback', async () => {
const wrongSelection = fixture({
initialSessionId: 'session-one',
sessionCatalog: reasoningSessionCatalog(),
selectionResult: {
provider: 'deepseek-official',
model: 'deepseek-v4-flash',
reasoningEffort: 'high',
},
});
const selectionFailure = await runModelCommand(
'/reasoning max',
wrongSelection.harness,
wrongSelection.state,
'direct:one',
);
assert.match(selectionFailure.message, /推理等级切换失败/);
assert.match(selectionFailure.message, /reasoningEffort=max/);
assert.match(selectionFailure.message, /reasoningEffort=high/);
const wrongReadback = fixture({
initialSessionId: 'session-one',
sessionCatalog: reasoningSessionCatalog(),
selectionReadback: {
provider: 'deepseek-official',
model: 'deepseek-v4-flash',
reasoningEffort: 'off',
},
});
const readbackFailure = await runModelCommand(
'/reasoning max',
wrongReadback.harness,
wrongReadback.state,
'direct:one',
);
assert.match(readbackFailure.message, /推理等级切换失败/);
assert.match(readbackFailure.message, /reasoningEffort=max/);
assert.match(readbackFailure.message, /reasoningEffort=off/);
});
test('a missing bound Session is cleared and /models falls back to the global catalog', async () => {
const { calls, harness, state, boundId } = fixture({
initialSessionId: 'session-missing',