feat: add bounded history previews across nine channels

Add shared /history parsing, read-only session access, message filtering, and bounded replies. Default to three messages, cap at five, and exclude saved history commands.

Include channel integration tests, translations, command help, and the Issue #62 implementation plan.
This commit is contained in:
xmanrui 2026-08-28 03:01:53 +08:00
parent bf0e157cd4
commit 8c6c31a1b6
23 changed files with 1965 additions and 221 deletions

View file

@ -6,6 +6,11 @@ This file records the notable changes in each dsh-im release. Its format follows
## [Unreleased] ## [Unreleased]
### Added / 新增
- 九个聊天渠道统一新增私聊命令 `/history [数量]`,只读预览当前绑定会话的最近对话:默认 3 条、最多 5 条,超出上限自动按 5 条处理;过滤工具、推理和未完成回复,长正文截断,并复用各渠道现有文字回复机制。命令不创建会话或调用模型,绑定成功提示和中英文帮助同步提供入口。
Added `/history [count]` to direct chats on all nine channels to preview the bound Session's recent conversation without creating a Session or prompting the model. It defaults to 3 messages and caps larger counts at 5, omits tools, reasoning, and unfinished replies, truncates long text, and reuses each channel's existing text-reply mechanism. Binding confirmations and bilingual help now point to the command.
## [3.0.8] - 2026-08-28 ## [3.0.8] - 2026-08-28
### Fixed / 修复 ### Fixed / 修复

View file

@ -163,12 +163,13 @@ Each WhatsApp bot also has its own access mode. Existing bots migrate to **Only
| `/workspacelist` | List workspace absolute paths that still exist on the current Harness Host. | | `/workspacelist` | List workspace absolute paths that still exist on the current Harness Host. |
| `/sessionlist [workspace number or absolute path]` | List every registered session ID and title in the selected workspace; omit the argument to use the current workspace. | | `/sessionlist [workspace number or absolute path]` | List every registered session ID and title in the selected workspace; omit the argument to use the current workspace. |
| `/session <Session ID>` | Bind the current chat to an existing Harness session. | | `/session <Session ID>` | Bind the current chat to an existing Harness session. |
| `/history [count]` | Preview recent messages from the bound Session in a direct chat; defaults to 3, capped at 5. |
| Interactive question | Reply with an option number, option label, or custom text; separate multiple choices with commas. | | Interactive question | Reply with an option number, option label, or custom text; separate multiple choices with commas. |
| Remote approval | Reply with `批准` / `拒绝` / `同意` / `不同意` / `yes` / `no`. | | Remote approval | Reply with `批准` / `拒绝` / `同意` / `不同意` / `yes` / `no`. |
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`, `/version`, `/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` 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`, `/version`, `/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`, `/session session-id`, `/history`, or `/history 5`
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. 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`, ` /preset 2`, ` /history`, or ` /history 10`; the plugin command layer trims surrounding whitespace, so it executes exactly like the unspaced form.
### Command details ### Command details
@ -194,6 +195,8 @@ If the Slack desktop app has no native Slash Command registered with the same na
- A numeric `/sessionlist` argument uses the same freshly resolved order as `/workspacelist` at command execution time. An absolute path can also select a workspace directly, and the result echoes the resolved path. - A numeric `/sessionlist` argument uses the same freshly resolved order as `/workspacelist` at command execution time. An absolute path can also select a workspace directly, and the result echoes the resolved path.
- `/sessionlist` includes every session registered to the selected workspace. Archived sessions are marked as archived; blank and subagent sessions are included when they belong to that workspace; sessions without a title are shown as `No title yet`. Any listed ID can be passed directly to `/session Session ID`. - `/sessionlist` includes every session registered to the selected workspace. Archived sessions are marked as archived; blank and subagent sessions are included when they belong to that workspace; sessions without a title are shown as `No title yet`. Any listed ID can be passed directly to `/session Session ID`.
- `/session` accepts exactly one Session ID obtained from `/sessionlist`. It neither creates a session nor immediately prompts the model; later messages in the current chat continue the bound session. Regular archived sessions can be bound without being unarchived, while subagent sessions cannot be bound. - `/session` accepts exactly one Session ID obtained from `/sessionlist`. It neither creates a session nor immediately prompts the model; later messages in the current chat continue the bound session. Regular archived sessions can be bound without being unarchived, while subagent sessions cannot be bound.
- `/history` works identically in direct chats on all nine channels. It only reads the Session already bound to this chat: it never creates a Session, prompts the model, or interrupts running tasks or pending interactions. It returns the latest 3 messages by default. `/history N` accepts a positive integer, caps values above 5 at 5, and returns fewer when fewer are available. Zero, negative, fractional, nonnumeric, and multiple arguments show usage; commands with images or files are rejected. While collecting batch input, use `/send` or `/cancel` first.
- A user message or a final assistant reply counts as one history item, not one turn or day. The latest N items are displayed oldest first. Tool events, reasoning, injected content, and unfinished assistant output are omitted; old attachments are not downloaded or resent. Long text is marked as truncated, with at most 3 text segments per reply and no automatic pagination. After binding a Session, send `/history` manually; binding never replays history automatically. Message text may still contain sensitive information from the original conversation, so expose the bot only to trusted users.
- `/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. - `/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. - 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 admitted by the current channel access policy can run these commands; there is no separate administrator role. Telegram Compatible mode follows the original DM and group mention/reply rules, while Safe mode admits only allowlisted private users. WhatsApp Only me accepts self-chat only, Selected contacts accepts self-chat plus allowlisted direct messages, and Open responses accepts every direct message, group messages from the linked account, and mentions or replies from other group members. - Any user admitted by the current channel access policy can run these commands; there is no separate administrator role. Telegram Compatible mode follows the original DM and group mention/reply rules, while Safe mode admits only allowlisted private users. WhatsApp Only me accepts self-chat only, Selected contacts accepts self-chat plus allowlisted direct messages, and Open responses accepts every direct message, group messages from the linked account, and mentions or replies from other group members.

View file

@ -166,12 +166,13 @@ dsh web
| `/workspacelist` | 列出当前 Harness Host 上仍然存在的工作区绝对路径。 | | `/workspacelist` | 列出当前 Harness Host 上仍然存在的工作区绝对路径。 |
| `/sessionlist [工作区序号或绝对路径]` | 列出指定工作区登记的所有会话 ID 和标题;省略参数时使用当前工作区。 | | `/sessionlist [工作区序号或绝对路径]` | 列出指定工作区登记的所有会话 ID 和标题;省略参数时使用当前工作区。 |
| `/session <Session ID>` | 将当前聊天绑定到指定的已有 Harness 会话。 | | `/session <Session ID>` | 将当前聊天绑定到指定的已有 Harness 会话。 |
| `/history [数量]` | 在私聊中查看当前绑定会话的最近历史消息,默认 3 条,最多 5 条。 |
| 交互式提问 | 回复选项序号、选项文字或自定义文字;多选时用逗号分隔。 | | 交互式提问 | 回复选项序号、选项文字或自定义文字;多选时用逗号分隔。 |
| 远程审批 | 回复 `批准` / `拒绝` / `同意` / `不同意` / `yes` / `no`。 | | 远程审批 | 回复 `批准` / `拒绝` / `同意` / `不同意` / `yes` / `no`。 |
示例:先发送 `/models`,再发送 `/model 2` 切换到列表中的第 2 个模型;先发送 `/reasoninglist`,再发送 `/reasoning 2` 切换到当前模型的第 2 个推理等级;先发送 `/presetlist`,再发送 `/preset 2` 为当前机器人选择第 2 个 Agent Preset。其他命令示例:`/help`、`/new`、`/status`、`/version`、`/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` 示例:先发送 `/models`,再发送 `/model 2` 切换到列表中的第 2 个模型;先发送 `/reasoninglist`,再发送 `/reasoning 2` 切换到当前模型的第 2 个推理等级;先发送 `/presetlist`,再发送 `/preset 2` 为当前机器人选择第 2 个 Agent Preset。其他命令示例:`/help`、`/new`、`/status`、`/version`、`/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`、`/history` 或 `/history 5`
Slack 桌面端若未注册同名的原生 Slash Command,会拦截直接以 `/` 开头的消息。此时请加一个前导空格发送,例如 ` /presetlist` 或 ` /preset 2`;插件命令层会去除首尾空白,执行效果与无空格命令相同。 Slack 桌面端若未注册同名的原生 Slash Command,会拦截直接以 `/` 开头的消息。此时请加一个前导空格发送,例如 ` /presetlist`、` /preset 2`、` /history` 或 ` /history 10`;插件命令层会去除首尾空白,执行效果与无空格命令相同。
### 命令说明 ### 命令说明
@ -197,6 +198,8 @@ Slack 桌面端若未注册同名的原生 Slash Command,会拦截直接以 `/
- `/sessionlist` 的数字参数按命令执行时与 `/workspacelist` 相同的最新顺序解析;也可使用绝对路径直接指定工作区。结果会回显最终选中的路径。 - `/sessionlist` 的数字参数按命令执行时与 `/workspacelist` 相同的最新顺序解析;也可使用绝对路径直接指定工作区。结果会回显最终选中的路径。
- `/sessionlist` 会列出该工作区登记的所有会话。已归档会话会标记为“已归档”;空白会话和子代理会话在它们归属该工作区时也会列出;没有标题的会话显示为“暂无标题”。结果中的 ID 可直接用于 `/session Session ID`。 - `/sessionlist` 会列出该工作区登记的所有会话。已归档会话会标记为“已归档”;空白会话和子代理会话在它们归属该工作区时也会列出;没有标题的会话显示为“暂无标题”。结果中的 ID 可直接用于 `/session Session ID`。
- `/session` 只接受一个由 `/sessionlist` 获得的 Session ID。它不会新建会话或立即向模型发送消息;绑定成功后,当前聊天的后续消息会继续该会话。普通归档会话可以绑定但不会自动取消归档,子代理会话不能绑定。 - `/session` 只接受一个由 `/sessionlist` 获得的 Session ID。它不会新建会话或立即向模型发送消息;绑定成功后,当前聊天的后续消息会继续该会话。普通归档会话可以绑定但不会自动取消归档,子代理会话不能绑定。
- `/history` 在九个渠道的私聊中统一可用,只读取当前聊天已经绑定的会话,不新建会话、不调用模型,也不影响正在运行的任务或待处理交互。默认返回最近 3 条;`/history N` 接受正整数,超过 5 自动按 5 条处理,数量不足时返回实际条数。零、负数、小数、非数字和多个参数会提示用法,附带图片或文件时会拒绝处理;批量输入收集中请先 `/send` 或 `/cancel`。
- 历史预览中,一条用户消息或一条助手最终回复各算一条,不按轮次或天数计数。先取最新 N 条,再按从旧到新的顺序显示;不展示工具、推理、注入内容或尚未完成的助手片段,不下载或重发历史附件。长正文会截断并注明,全部结果最多发送 3 段文字,不自动翻页。绑定会话后可手动发送 `/history`,不会自动重发历史。正文仍可能包含会话原有的敏感信息,请只向可信用户开放机器人。
- `/session` 会自动定位会话唯一所属的工作区。同工作区绑定只替换当前聊天的映射;跨工作区绑定会切换该机器人的工作区、清除该机器人所有聊天的旧会话映射,再绑定当前聊天,因此会影响该机器人的其他聊天。已经开始生成的回复仍可完成。 - `/session` 会自动定位会话唯一所属的工作区。同工作区绑定只替换当前聊天的映射;跨工作区绑定会切换该机器人的工作区、清除该机器人所有聊天的旧会话映射,再绑定当前聊天,因此会影响该机器人的其他聊天。已经开始生成的回复仍可完成。
- 工作区切换和会话绑定只会清除或替换 dsh-im 的聊天映射,不会删除、清空或归档任何旧 Session 内容;旧 Session 仍可再次列出和绑定。 - 工作区切换和会话绑定只会清除或替换 dsh-im 的聊天映射,不会删除、清空或归档任何旧 Session 内容;旧 Session 仍可再次列出和绑定。
- 任何通过当前渠道访问策略的用户都可以执行这些命令,不另行区分管理员和普通用户。Telegram 兼容模式遵循原有私聊及群聊提及/回复规则;安全模式只允许当前机器人白名单中的私聊用户执行。WhatsApp 仅自己模式只接受自聊,指定联系人模式接受自聊和白名单私聊,开放响应模式接受所有私聊、已绑定账号自己发出的群聊消息,以及其他群成员的提及或回复。 - 任何通过当前渠道访问策略的用户都可以执行这些命令,不另行区分管理员和普通用户。Telegram 兼容模式遵循原有私聊及群聊提及/回复规则;安全模式只允许当前机器人白名单中的私聊用户执行。WhatsApp 仅自己模式只接受自聊,指定联系人模式接受自聊和白名单私聊,开放响应模式接受所有私聊、已绑定账号自己发出的群聊消息,以及其他群成员的提及或回复。

View file

@ -0,0 +1,201 @@
# Issue #62:九渠道会话历史预览方案
日期:2026-08-28。代码基线:v3.0.8 / `bf0e157`。状态:已实施。
需求来源:[Issue #62](https://github.com/xmanrui/dsh-im/issues/62)。本方案仅描述实现,不代表功能已经上线。
## 1. 最终命令约定
新增一个共享命令:`/history [数量]`。
| 输入 | 行为 |
| --- | --- |
| `/history` | 返回最近最多 3 条历史消息 |
| `/history 1` | 返回最近最多 1 条 |
| `/history 4` | 返回最近最多 4 条 |
| `/history 5` | 返回最近最多 5 条 |
| `/history 6`、`/history 100` | 均按 5 条处理,不报超限错误 |
| `/history 0`、负数、小数、非数字、多个参数 | 返回用法,不访问模型 |
这里的默认值是 **3 条,不是 3 天**,不提供时间范围查询。
“一条历史消息”指一条用户消息,或一条助手最终回复;不是一轮对话,也不是一个工具事件。例如两次完整问答共有 4 条。数量不足时按实际数量返回,不补齐、不生成摘要。
先取最新 N 条,再按从旧到新的顺序展示。奇数条可能从助手回复开始,这是按消息计数的正常结果,不额外补入第 N+1 条。
## 2. 范围与体验
九渠道统一提供文字命令和文字预览,不新增卡片、按钮、设置项、数据库或历史缓存,不为 QQ、企业微信添加专门的发送逻辑。
本期沿用前一轮建议的私聊范围:微信、飞书、钉钉、企业微信、QQ、Slack、Telegram、Discord、WhatsApp 的私聊均支持;群聊、频道和群内线程只回复“请在与机器人的私聊中使用 /history”。它们不读取历史,也不自动转发到另一个私聊。渠道现有的入站路由行为保持不变。
只查看当前聊天已绑定的 Harness Session,不接受 Session ID 参数。切换成功后只增加一句“发送 /history 查看最近对话”,不自动发送历史。AI Office Connector 不属于本次九个聊天渠道。
预览示意:
```text
会话历史|会话 abc123|最近 3 条
1. 助手
上一次已经完成登录接口,下一步是补充测试。
2. 用户
先补充登录失败场景的测试。
3. 助手
已补充三个失败场景,测试全部通过。
以上为历史记录,不是本次新回复。
```
这是历史文字摘录,不是让模型重新回答或概括。
## 3. 历史读取与筛选
### 3.1 读取流程
1. 渠道先执行现有访问策略、去重和命令识别。
2. 共享命令解析数量:省略为 3,正整数超过 5 时取 5。
3. 确认是私聊、没有图片或文件,并读取当前聊天绑定的 Session ID;未绑定直接提示,不创建会话。
4. 通过共享 HarnessClient 调用现有 `session.history`。本机沿用 v3.0.8 的进程内 API,显式配置远程 Host 时沿用 HTTP,不另建访问链路。
5. 筛选可显示消息,选取最新 N 条,按原顺序排版。
6. 读取结束后再次检查当前绑定和工作区作用域;读取期间切换了会话、工作区或机器人已失效,则丢弃结果并提示重试。
7. 返回现有的 `{ handled, message, messages }` 命令结果,由各桥接器现有发送函数投递到本次请求的原聊天。
发送一旦开始,整份回复始终使用同一份已读取快照和原回复目标,不在分段之间改读新会话。标题或短 ID 用于标明这份历史属于哪个会话。
### 3.2 什么可以显示
| 内容 | 处理 |
| --- | --- |
| 原始用户消息 | 只接受能确认是人类输入的消息来源,提取正文文字 |
| 用户历史中的 `/history` 命令记录 | 排除被 `isHistoryCommand` 识别的命令(含参数和首尾空白),不显示、不计数;普通正文或助手回复中提到 `/history` 的内容保留,不推断关联 Turn |
| 助手最终回复 | 每个成功结束的 Turn 只取最后一条定稿助手消息中的文字 |
| 当前运行中的用户输入 | 已进入历史时可以显示;未提交的排队输入不读取 |
| 正在生成的助手回复 | 不显示 token 分片或过程消息,可附“当前任务仍在进行中” |
| 图片、文件等非文字内容 | 不下载、不重发;有可靠结构化信息时显示类型占位说明 |
| 工具调用、工具结果、推理内容、系统指令、注入上下文、审批内容 | 不展示 |
| 压缩产生的替换副本 | 不当成新对话,不重复计数;保留原始人类对话的含义 |
| 无可显示文字的助手最终消息 | 显示“本条没有可预览的文字”,不拿更早的过程消息冒充最终回复 |
实现时不能简单地过滤 `user/message`、`assistant/message` 后取最后 N 个事件:
- `user/message` 也可能是内部注入内容,必须检查 `data.source.kind === 'user'`。
- 当前协议中,原始对话使用 `surfaceOp: 'append'`;`replace` 是模型侧替换内容,不重复展示。缺少关键字段时不猜测为普通对话。
- 助手消息需要结合 `turn/end` 确认最终状态;忽略 `assistant/chunk`、中断片段以及中间工具步骤的助手消息。
- 用户正文在 `event.data.content`,助手正文在 `event.data.message.content`,两者不能用同一个未经区分的字段路径读取。
这些规则只限制显示哪些事件、哪些内容块,不承诺自动识别并清除用户正文里原本就包含的密码、路径等敏感文字。权限仍依赖机器人已有的可信访问范围,不新增一套管理员或 Session 所有者体系。
### 3.3 有限读取即可,不做历史浏览器
首次读取 `maxMessages: 50`;若筛选后不足 N 条且响应 `hasMore` 为真,使用 `beforeSeq` 向前补读,最多总共 3 次。整次读取设置 10 秒截止时间,不订阅实时事件。
`maxMessages` 是 Harness 的分页计数,不是本命令的返回条数。每页按 `seq` 校验、排序和去重,向前游标必须严格推进。固定首次尾页的快照边界,不在补读期间追赶新消息。
达到上限仍不足时,返回已确认的记录并说明不足;没有可用记录则给出对应提示。字段不合法、游标不推进或记录冲突时返回安全错误,不把原始响应作为文字发出去。
## 4. 防刷屏统一规则
防刷屏只在共享命令层处理,不按渠道另写策略。
- 最多选择 5 条历史消息。
- 每条正文最多展示 500 个字符,超出注明“已截断”;切分不得拆开 Unicode 代理对。
- 合并成一份带角色标签的文字回复,总长度控制在 3,000 个字符以内,标题、标签和截断提示都计入预算。
- 复用现有 `splitWorkspaceCommandMessage()` 按 1,800 字符分段;最多发送 3 段,超出先缩短正文并标注截断。
- 逻辑历史消息数与 IM 分段数分开计算:`/history 5` 最多包含 5 条历史记录,不是固定发送 5 个气泡。
- 命令结果一次生成完再发送,不流式推送历史,不引导用户发送“继续”,不自动翻页。
上述 500 / 3,000 / 3 段是建议的统一显示预算,可在共享常量中调整;默认 3 条、上限 5 条则按本次需求固定。
## 5. 九渠道接入
| 渠道 | 接入位置 | 回复方式 |
| --- | --- | --- |
| 微信 | `src/channels/weixin/weixin-bridge.mjs` | 现有命令回复流程 |
| 飞书 | `src/channels/feishu/bridge.mjs` | 现有命令文字回复流程,不新增卡片 |
| 钉钉 | `src/channels/dingtalk/dingtalk-bridge.mjs` | 现有命令回复流程 |
| 企业微信 | `src/channels/wecom/wecom-bridge.mjs` | 现有命令回复流程,不单独处理 |
| QQ | `src/channels/qq/qq-bridge.mjs` | 现有命令回复流程,不单独处理 |
| Slack | `src/channels/shared/text-harness-bridge.mjs` | 继承共享桥接器,复用现有发送函数 |
| Telegram | `src/channels/shared/text-harness-bridge.mjs` | 同上 |
| Discord | `src/channels/shared/text-harness-bridge.mjs` | 同上 |
| WhatsApp | `src/channels/shared/text-harness-bridge.mjs` | 同上 |
因此实际只需要接入六处桥接入口,不复制九份查询或格式化代码。各渠道现有回复目标、鉴权、入站去重、发送失败处理和机器人回声过滤都保留。
不修改 QQ Markdown 回复机制、企业微信即时回复机制、Discord Thread 路由、Telegram 富消息机制或任何平台 SDK。这里承诺的是复用现有行为,不额外承诺本次解决底层发送链路已有的重试或重复投递问题。
## 6. 状态和异常
| 情况 | 结果 |
| --- | --- |
| 没有绑定 Session | 提示先开始或绑定会话,不自动创建 |
| Session 为空 | “当前会话暂无可预览的历史消息” |
| 请求 N 条但只有更少 | 返回实际数量 |
| 最近记录包含失败或中断的 Turn | 用户输入仍可显示,不把未完成的助手片段当成最终回复 |
| 任务正在运行 | 可查历史,不停止、不等待任务结束 |
| 正在等待提问或审批 | 命令不被当作交互答案,不提交或取消原交互 |
| 批量输入正在收集 | 沿用现有规则,提示先 `/send` 或 `/cancel`;不收录、不查询 |
| `/history` 带图片或文件 | 提示仅支持文字命令,不转给模型 |
| 数量格式错误 | 返回用法;大于 5 的合法正整数不是错误 |
| Session 不存在 | 提示重新绑定,不静默新建或切换 |
| 读取期间发生切换 | 丢弃旧结果,提示重新执行 |
| Harness 不支持、超时或返回异常 | 返回简短安全提示,不泄露原始异常和历史数据 |
| 平台发送失败 | 走该渠道现有错误路径,不新增历史专用重试 |
`/history` 应接到现有快速命令分支:在批量输入规则之后、待回答问题和审批识别之前处理。无效参数同样在本地消费,不能落入普通消息流程。新命令的识别不能因为携带附件而绕过校验、转发给模型。
## 7. 最小改动清单
1. 新增 `src/channels/shared/history-command.mjs`:命令解析、数量上限、历史筛选、输出预算、提示文案;返回现有命令结果结构。
2. 修改 `src/channels/shared/harness-client.mjs`:增加 `readSessionHistory(sessionId, options)`,复用已有 RPC 和错误类型。
3. 修改 `src/channels/shared/bot-workspace-store.mjs`:在 `workspaceSession()` 句柄增加历史读取方法,复用工作区代际校验;命令另检查当前聊天绑定是否仍相同。
4. 六处桥接器接入共享命令,并传递私聊类型及文字/附件信息。
5. 更新九渠道帮助文案、共享 `/session` 成功提示及飞书自己的绑定成功提示。只增加命令提示,不重放历史或新增按钮。
6. 更新现有中英文文案、`README.md`、`README.en.md` 和 `CHANGELOG.md`。
7. 增加共享测试及九渠道接入测试。不新增持久化状态、依赖包或配置迁移。
读取链路不得调用 `session.create`、`session.prompt`、`session.cancel`、`executeCommand` 或会话恢复/接管方法;`session.history` 本身能够读取未激活会话,不需要先创建 Agent。
## 8. 验收与实施顺序
先完成共享命令和历史读取测试,再一次性接入六处入口覆盖九渠道,最后补帮助文案并执行现有 `npm run check`。
共享验收:
- 默认 3 条;参数 1、3、5 正确;6、100 和很大的正整数均按 5 条;0、负数、小数及多个参数返回用法。
- 最新 N 条、旧到新显示、数量不足及空会话正确;不能把“一轮”误当“一条”。
- 用户历史中的 `/history` 命令记录在计数和截取前排除,数量不足时沿用有限分页补取;普通正文和助手回复中的提及不删。
- 多工具步骤、内部注入、推理内容、压缩替换、运行中回复、失败/中断及重复事件的筛选正确。
- 单条超长、中文、emoji、代码文字均遵守预算,最多 5 条记录和 3 段回复。
- 补读有上限且游标推进;API 不支持、超时、坏数据和读取期间切换均安全退出。
- 进程内 API 与显式 HTTP 模式都覆盖;查询全程不触发模型、会话创建、恢复或交互响应。
九渠道都执行同一组接入验收,不以“继承共享类”代替覆盖:
- 私聊发送 `/history` 和 `/history 10`,分别得到最多 3 条和 5 条,且回到原聊天。
- 未授权来源按原策略拒绝;已接收的群聊命令不读历史,提示去私聊。
- 正在运行、待回答/审批、批量输入三种状态都符合上表。
- 重复平台事件不重复处理;发送失败按原渠道处理;历史回声不触发新模型请求。
- `/help`、绑定成功提示和英文提示包含正确用法。
发布前使用可用的真实机器人做九渠道私聊冒烟。未完成的实机项明确记录,不能将 mock 测试通过写成九渠道已经实测通过。发布回退可撤回命令入口和帮助提示,无需回滚会话数据。
## 9. 核对依据
- 本仓库 `workspace-command.mjs` 的命令结果与 1,800 字符分段器,以及六处桥接器现有的 `result.messages` 发送循环。
- 本仓库 `harness-client.mjs`、`bot-workspace-store.mjs` 和 v3.0.8 的 `harness-connection.mjs`。
- [Harness 历史 API 契约](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/host/apiproxy/src/api/sessions.ts):`session.history`、反向分页及只读行为。
- [Harness 事件定义](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/core/session/src/types.ts):用户来源、Turn 状态与定稿助手消息。
- [Harness 原始对话与替换内容的区别](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/core/session/src/surface.ts):人类对话预览不能照搬压缩后的模型上下文。
上游仍在演进,实施时以实际支持的 Host 协议和测试样本为准,不额外假设新的 RPC 或字段。
## 10. 验收记录
2026-08-28:
- 用户确认:九个渠道实测均 OK,并要求停止进一步客户端测试。此项为用户确认,不代表我们独立完成了全部原生客户端实测。
- 自动验证:`npm run check` 的构建、1,554 条全量测试及包产物校验通过;随后新增的 Slack 前导空格联合回归通过,`history-bridge` 测试文件 112/112 通过。
- 追加优化:在计数前排除 `/history` 命令记录,普通正文中的命令引用不受影响;`history-command` 34/34、重新构建及包产物校验通过。重启加载后,仅向飞书机器人“今天是牢梁”发送一次 `/history`,返回 3 条正常历史消息,不包含查询命令;本轮未复测其他渠道。

File diff suppressed because one or more lines are too long

View file

@ -13,6 +13,7 @@ import {
} from '../shared/harness-question.mjs'; } from '../shared/harness-question.mjs';
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs'; import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
import { runCompactCommand } from '../shared/compact-command.mjs'; import { runCompactCommand } from '../shared/compact-command.mjs';
import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
import { import {
isControlCommand, isControlCommand,
runControlCommand, runControlCommand,
@ -67,6 +68,7 @@ const HELP_TEXT_LINES = [
'直接发送文字、图片或文件即可继续当前会话。', '直接发送文字、图片或文件即可继续当前会话。',
'/new 开启一个全新会话', '/new 开启一个全新会话',
'/compact 压缩当前会话的较早上下文', '/compact 压缩当前会话的较早上下文',
'/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)',
'/workspace 工作区绝对路径 切换工作区', '/workspace 工作区绝对路径 切换工作区',
'/workspacelist 列出工作区绝对路径', '/workspacelist 列出工作区绝对路径',
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题', '/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
@ -522,7 +524,8 @@ export class DingtalkHarnessBridge {
)); ));
} }
} }
const commandRunner = hasInboundFiles(promptMessage) ? null : isControlCommand(commandText) const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
: hasInboundFiles(promptMessage) ? null : isControlCommand(commandText)
? runControlCommand ? runControlCommand
: (isModelCommand(commandText) : (isModelCommand(commandText)
? runModelCommand ? runModelCommand
@ -789,6 +792,7 @@ export class DingtalkHarnessBridge {
key, key,
{ {
signal: this.#signal, signal: this.#signal,
isDirect: String(message.conversationType) === '1',
hasImages: hasInboundImages(prompt), hasImages: hasInboundImages(prompt),
hasFiles: hasInboundFiles(prompt), hasFiles: hasInboundFiles(prompt),
pendingInteraction: this.#pendingInteractions.has(key) pendingInteraction: this.#pendingInteractions.has(key)

View file

@ -30,6 +30,7 @@ import {
isBatchInputCommand, isBatchInputCommand,
} from '../shared/batch-input.mjs'; } from '../shared/batch-input.mjs';
import { runCompactCommand } from '../shared/compact-command.mjs'; import { runCompactCommand } from '../shared/compact-command.mjs';
import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
import { import {
isControlCommand, isControlCommand,
runControlCommand, runControlCommand,
@ -620,12 +621,15 @@ export class FeishuHarnessBridge {
.finally(() => this.#acceptedMessageIds.delete(messageId)); .finally(() => this.#acceptedMessageIds.delete(messageId));
return processing; return processing;
} }
const commandRunner = hasInboundFiles(commandMessage) ? null : isControlCommand(commandText) const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
: hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
? runControlCommand ? runControlCommand
: (isModelCommand(commandText) : (isModelCommand(commandText)
? runModelCommand ? runModelCommand
: (isPresetCommand(commandText) ? runPresetCommand : null)); : (isPresetCommand(commandText) ? runPresetCommand : null));
if (commandRunner && addressed) { // In all-message group mode, history must still be refused locally rather
// than becoming a normal prompt when no mention is present.
if (commandRunner && (addressed || commandRunner === runHistoryCommand)) {
const processing = this.#processFastCommand( const processing = this.#processFastCommand(
event, event,
messageId, messageId,
@ -913,6 +917,7 @@ export class FeishuHarnessBridge {
key, key,
{ {
signal: this.#signal, signal: this.#signal,
isDirect: event.message.chat_type === 'p2p',
hasImages: hasInboundImages(message), hasImages: hasInboundImages(message),
hasFiles: hasInboundFiles(message), hasFiles: hasInboundFiles(message),
pendingInteraction: this.#hasPendingInteraction(key), pendingInteraction: this.#hasPendingInteraction(key),
@ -2083,7 +2088,10 @@ export class FeishuHarnessBridge {
try { try {
const bound = await this.#harness.bindWorkspaceSession(key, sessionId); const bound = await this.#harness.bindWorkspaceSession(key, sessionId);
const title = String(bound?.title ?? '').replace(/\s+/gu, ' ').trim() || t('暂无标题'); const title = String(bound?.title ?? '').replace(/\s+/gu, ' ').trim() || t('暂无标题');
await this.#send(chatId, t('已绑定会话「{title}」\nID:{id}', { title, id: bound?.sessionId ?? sessionId })); await this.#send(chatId, [
t('已绑定会话「{title}」\nID:{id}', { title, id: bound?.sessionId ?? sessionId }),
t('发送 /history 查看最近对话。'),
].join('\n'));
await this.#sendMenuCard(key, chatId, { updateMessageId }); await this.#sendMenuCard(key, chatId, { updateMessageId });
} catch (error) { } catch (error) {
await this.#sendFailure(chatId, error, { await this.#sendFailure(chatId, error, {

View file

@ -497,6 +497,7 @@ export function menuHelpText() {
'/status 连接状态', '/status 连接状态',
'/version 查看插件版本', '/version 查看插件版本',
'/compact 压缩当前会话上下文', '/compact 压缩当前会话上下文',
'/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)',
'/archived on/off 会话列表显示/隐藏归档', '/archived on/off 会话列表显示/隐藏归档',
'', '',
'👁 关注', '👁 关注',
@ -593,6 +594,7 @@ export function helpCard(extraTextLines = []) {
{ tag: 'div', text: markdown([ { tag: 'div', text: markdown([
t(HELP_TEXT_COMMANDS), t(HELP_TEXT_COMMANDS),
t('`/version` — 查看插件版本'), t('`/version` — 查看插件版本'),
t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
].join('\n') + extraText) }, ].join('\n') + extraText) },
{ tag: 'hr' }, { tag: 'hr' },
{ tag: 'div', text: markdown(t(HELP_NUMBER_FALLBACK)) }, { tag: 'div', text: markdown(t(HELP_NUMBER_FALLBACK)) },

View file

@ -1,5 +1,6 @@
import { runWorkspaceCommand } from '../shared/workspace-command.mjs'; import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
import { runCompactCommand } from '../shared/compact-command.mjs'; import { runCompactCommand } from '../shared/compact-command.mjs';
import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
import { import {
isControlCommand, isControlCommand,
runControlCommand, runControlCommand,
@ -78,6 +79,7 @@ function helpText() {
t('直接发送文字、图片或文件即可继续当前会话。'), t('直接发送文字、图片或文件即可继续当前会话。'),
t('/new 开启一个全新会话'), t('/new 开启一个全新会话'),
t('/compact 压缩当前会话的较早上下文'), t('/compact 压缩当前会话的较早上下文'),
t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
t('/workspace 工作区绝对路径 切换工作区'), t('/workspace 工作区绝对路径 切换工作区'),
t('/workspacelist 列出工作区绝对路径'), t('/workspacelist 列出工作区绝对路径'),
t('/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题'), t('/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题'),
@ -468,7 +470,8 @@ export class QqHarnessBridge {
return this.#finishBatchResult(message, messageId, result); return this.#finishBatchResult(message, messageId, result);
} }
} }
const commandRunner = hasQqFileAttachments(message) ? null : isControlCommand(commandText) const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
: hasQqFileAttachments(message) ? null : isControlCommand(commandText)
? runControlCommand ? runControlCommand
: (isModelCommand(commandText) : (isModelCommand(commandText)
? runModelCommand ? runModelCommand
@ -602,6 +605,7 @@ export class QqHarnessBridge {
this.#status.lastMessageAt = new Date().toISOString(); this.#status.lastMessageAt = new Date().toISOString();
const result = await runner(text, this.#harness, this.#state, key, { const result = await runner(text, this.#harness, this.#state, key, {
signal: this.#signal, signal: this.#signal,
isDirect: message.kind === 'c2c',
hasImages: hasQqImageAttachments(message), hasImages: hasQqImageAttachments(message),
hasFiles: hasQqFileAttachments(message), hasFiles: hasQqFileAttachments(message),
pendingInteraction: this.#pendingInteractions.has(key) pendingInteraction: this.#pendingInteractions.has(key)

View file

@ -834,6 +834,9 @@ export function createBotWorkspaceScope(
models(...args) { models(...args) {
return invokeCurrentSession('getSessionModels', args, 'model listing'); return invokeCurrentSession('getSessionModels', args, 'model listing');
}, },
readHistory(...args) {
return invokeCurrentSession('readSessionHistory', args, 'history read');
},
selectModel(...args) { selectModel(...args) {
return invokeCurrentSession('selectSessionModel', args, 'model selection'); return invokeCurrentSession('selectSessionModel', args, 'model selection');
}, },

View file

@ -911,6 +911,19 @@ export class HarnessClient {
} }
} }
async readSessionHistory(sessionId, { maxMessages = 50, beforeSeq, timeoutMs = 10_000, ...options } = {}) {
if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
if (!Number.isSafeInteger(maxMessages) || maxMessages < 1
|| (beforeSeq !== undefined && (!Number.isSafeInteger(beforeSeq) || beforeSeq < 0))) {
throw new TypeError('Invalid history pagination');
}
return this.rpc('session.history', {
sessionId,
maxMessages,
...(beforeSeq === undefined ? {} : { beforeSeq }),
}, timeoutMs, options);
}
async sessionExists(sessionId, options = {}) { async sessionExists(sessionId, options = {}) {
try { try {
await this.rpc('session.history', { sessionId, maxMessages: 1 }, 30_000, options); await this.rpc('session.history', { sessionId, maxMessages: 1 }, 30_000, options);

View file

@ -0,0 +1,193 @@
import { isDeepStrictEqual } from 'node:util';
import { t } from './i18n.mjs';
import { splitWorkspaceCommandMessage } from './workspace-command.mjs';
import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
const HISTORY_COMMAND = /^\/history(?=$|\s)([\s\S]*)$/iu;
const HISTORY_USAGE = '用法:/history [数量](默认 3 条,最多 5 条)';
const MAX_MESSAGES = 5;
const PAGE_SIZE = 50;
const MAX_PAGES = 3;
const READ_TIMEOUT_MS = 10_000;
function commandResult(message) {
return { handled: true, message, messages: splitWorkspaceCommandMessage(message) };
}
function previewText(content) {
if (!Array.isArray(content)) throw new TypeError('Invalid history message content');
return content.flatMap((block) => {
if (block?.type === 'text' && typeof block.text === 'string') return [block.text];
if (block?.type === 'image') return [t('[图片]')];
if (block?.type === 'file') return [t('[文件]')];
return [];
}).join('\n').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, '').trim()
|| t('本条没有可预览的文字。');
}
function visibleMessages(events) {
const messages = [];
const assistants = new Map();
for (const event of events) {
const data = event.data;
if (event.type === 'user/message' && event.surfaceOp === 'append'
&& data?.source?.kind === 'user') {
// Older sessions may contain commands saved before the local fast path
// existed. Exclude them before counting, so normal messages fill the limit.
if (isHistoryCommand(previewText(data.content))) continue;
messages.push({ seq: event.seq, role: 'user', content: data.content });
} else if (event.type === 'assistant/message' && event.surfaceOp === 'append'
&& Number.isSafeInteger(data?.turn) && data.turn >= 0) {
// Remember even an empty/interrupted final message: never substitute an
// earlier tool-step explanation for the turn's final answer.
assistants.set(data.turn, event);
} else if (event.type === 'turn/end') {
const assistant = assistants.get(data?.turn);
assistants.delete(data?.turn);
if ((data?.reason?.kind ?? data?.reason) !== 'completed'
|| !assistant || assistant.data.interrupted === true) continue;
messages.push({
seq: assistant.seq,
role: 'assistant',
content: assistant.data.message?.content,
});
}
}
return messages.sort((left, right) => left.seq - right.seq);
}
function truncateText(text, limit) {
if (text.length <= limit) return text;
const suffix = t('(已截断)');
let end = Math.max(0, limit - suffix.length);
const last = text.charCodeAt(end - 1);
if (last >= 0xd800 && last <= 0xdbff) end -= 1;
return `${text.slice(0, Math.max(0, end))}${suffix}`;
}
function formatHistory(sessionId, records, requested, hasMore) {
const shortId = sessionId.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '').slice(0, 12);
const header = t('会话历史|{session}|最近 {count} 条', { session: shortId, count: records.length });
const footer = [t('以上为历史记录,不是本次新回复。')];
if (records.length < requested) {
footer.unshift(hasMore
? t('本次有限读取中仅找到 {count} 条可预览消息。', { count: records.length })
: t('当前会话仅有 {count} 条可预览消息。', { count: records.length }));
}
const entries = records.map((record) => ({ ...record, text: previewText(record.content) }));
let bodyLimit = 500;
let result;
do {
const sections = entries.map((entry, index) => (
`${index + 1}. ${entry.role === 'user' ? t('用户') : t('助手')}\n${truncateText(entry.text, bodyLimit)}`
));
result = commandResult([header, ...sections, footer.join('\n')].join('\n\n'));
bodyLimit = Math.floor(bodyLimit / 2);
} while (bodyLimit > 0 && (result.message.length > 3_000 || result.messages.length > 3));
return result;
}
function historyErrorMessage(error) {
const code = error?.code ?? error?.failure?.code;
if (code === 'session-not-found') return t('当前聊天绑定的会话已不存在,请重新绑定会话。');
if (code === WORKSPACE_SESSION_STALE || code === 'workspace-bot-not-found'
|| code === 'session-binding-changed') return t('会话、工作区或机器人状态已发生变化,请重新执行 /history。');
if (code === 'harness-api-not-found') return t('当前 Harness 暂不支持读取会话历史。');
if (error?.name === 'AbortError') return t('历史读取已取消。');
if (error?.name === 'TimeoutError' || code === 'harness-timeout') return t('读取历史超时,请稍后重试。');
return t('暂时无法读取会话历史,请稍后重试。');
}
export function isHistoryCommand(text) {
return typeof text === 'string' && HISTORY_COMMAND.test(text.trim());
}
/** Read the current binding only; never create/resume a session or prompt the model. */
export async function runHistoryCommand(text, harness, state, key, {
signal: callerSignal,
isDirect = false,
hasImages = false,
hasFiles = false,
} = {}) {
if (!isHistoryCommand(text)) return null;
const argument = HISTORY_COMMAND.exec(text.trim())[1].trim();
if (argument && (!/^\d+$/u.test(argument) || /^0+$/u.test(argument))) {
return commandResult(t(HISTORY_USAGE));
}
const count = argument ? Math.min(Number(argument), MAX_MESSAGES) : 3;
if (!isDirect) return commandResult(t('请在与机器人的私聊中使用 /history。'));
if (hasImages || hasFiles) return commandResult(t('/history 仅支持文字命令,请移除图片或文件后重试。'));
const sessionId = state?.sessionFor?.(key);
if (typeof sessionId !== 'string' || !sessionId) {
return commandResult(t('当前聊天尚未绑定会话,请先发送消息或使用 /session 绑定会话。'));
}
try {
const session = harness?.workspaceSession?.(sessionId);
if (typeof session?.readHistory !== 'function') {
return commandResult(t('当前 Harness 暂不支持读取会话历史。'));
}
const timeout = AbortSignal.timeout(READ_TIMEOUT_MS);
const signal = callerSignal ? AbortSignal.any([callerSignal, timeout]) : timeout;
const deadline = Date.now() + READ_TIMEOUT_MS;
const events = new Map();
let beforeSeq;
let snapshotEnd;
let records = [];
let hasMore = false;
for (let pageIndex = 0; pageIndex < MAX_PAGES; pageIndex += 1) {
signal.throwIfAborted();
if (Date.now() >= deadline) throw new DOMException('History read timed out', 'TimeoutError');
const page = await session.readHistory({
maxMessages: PAGE_SIZE,
...(beforeSeq === undefined ? {} : { beforeSeq }),
timeoutMs: Math.max(1, deadline - Date.now()),
signal,
});
signal.throwIfAborted();
if (Date.now() >= deadline) throw new DOMException('History read timed out', 'TimeoutError');
if (state.sessionFor(key) !== sessionId) {
const error = new Error('Session binding changed during history read');
error.code = 'session-binding-changed';
throw error;
}
if (!page || !Array.isArray(page.events) || typeof page.hasMore !== 'boolean') {
throw new TypeError('Invalid history page');
}
let oldestSeq = beforeSeq ?? Infinity;
for (const entry of page.events) {
const event = entry?.event;
if (!event || !Number.isSafeInteger(event.seq) || event.seq < 0
|| typeof event.type !== 'string' || !event.type) throw new TypeError('Invalid history event');
// Subsequent pages must not extend the snapshot taken by the first read.
if (snapshotEnd !== undefined && event.seq > snapshotEnd) continue;
const previous = events.get(event.seq);
if (previous) {
if (!isDeepStrictEqual(previous, event)) throw new TypeError('Conflicting history events');
} else {
if (beforeSeq !== undefined && event.seq >= beforeSeq) throw new TypeError('Invalid history cursor');
events.set(event.seq, event);
}
oldestSeq = Math.min(oldestSeq, event.seq);
}
const ordered = [...events.values()].sort((left, right) => left.seq - right.seq);
snapshotEnd ??= ordered.at(-1)?.seq;
hasMore = page.hasMore;
if (hasMore && (!Number.isFinite(oldestSeq) || oldestSeq === beforeSeq)) {
throw new TypeError('History cursor did not advance');
}
records = visibleMessages(ordered).slice(-count);
if (records.length >= count || !hasMore) break;
beforeSeq = oldestSeq;
}
if (records.length === 0) {
return commandResult(hasMore
? t('本次有限读取中未找到可预览的历史消息。')
: t('当前会话暂无可预览的历史消息。'));
}
return formatHistory(sessionId, records, count, hasMore);
} catch (error) {
return commandResult(historyErrorMessage(error));
}
}

View file

@ -75,6 +75,7 @@ export default {
'当前消息缺少可绑定的会话上下文。': '当前消息缺少可绑定的会话上下文。':
'The current message lacks a conversation context to bind to.', 'The current message lacks a conversation context to bind to.',
'当前聊天已绑定会话:': 'This chat is now bound to the Session:', '当前聊天已绑定会话:': 'This chat is now bound to the Session:',
'发送 /history 查看最近对话。': 'Send /history to preview recent conversation messages.',
'标题:{title}': 'Title: {title}', '标题:{title}': 'Title: {title}',
'归档:{archived}': 'Archived: {archived}', '归档:{archived}': 'Archived: {archived}',
'是': 'Yes', '是': 'Yes',

View file

@ -1,5 +1,45 @@
// English translations (shared-c area). Keys are exact Chinese literals passed to t(). // English translations (shared-c area). Keys are exact Chinese literals passed to t().
export default { export default {
// history-command.mjs / command help
'/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)':
'/history [count] Preview recent messages (default 3, maximum 5)',
'用法:/history [数量](默认 3 条,最多 5 条)':
'Usage: /history [count] (default 3, maximum 5)',
'[图片]': '[Image]',
'[文件]': '[File]',
'本条没有可预览的文字。': 'This message has no text to preview.',
'(已截断)': ' (truncated)',
'会话历史|{session}|最近 {count} 条':
'Session history | {session} | Recent messages: {count}',
'以上为历史记录,不是本次新回复。':
'These are history records, not a new reply.',
'本次有限读取中仅找到 {count} 条可预览消息。':
'Messages available to preview within this bounded read: {count}.',
'当前会话仅有 {count} 条可预览消息。':
'Messages available to preview in this Session: {count}.',
'用户': 'User',
'助手': 'Assistant',
'当前聊天绑定的会话已不存在,请重新绑定会话。':
'The Session bound to this chat no longer exists. Please bind a Session again.',
'会话、工作区或机器人状态已发生变化,请重新执行 /history。':
'The Session, workspace, or bot state has changed. Please run /history again.',
'当前 Harness 暂不支持读取会话历史。':
'This Harness does not support reading Session history.',
'历史读取已取消。': 'History reading was cancelled.',
'读取历史超时,请稍后重试。': 'Reading history timed out. Please try again later.',
'暂时无法读取会话历史,请稍后重试。':
'Unable to read Session history right now. Please try again later.',
'请在与机器人的私聊中使用 /history。':
'Please use /history in a direct chat with the bot.',
'/history 仅支持文字命令,请移除图片或文件后重试。':
'/history supports text commands only. Remove images or files and try again.',
'当前聊天尚未绑定会话,请先发送消息或使用 /session 绑定会话。':
'This chat has no bound Session. Send a message or use /session to bind one first.',
'本次有限读取中未找到可预览的历史消息。':
'No history messages were available to preview within this bounded read.',
'当前会话暂无可预览的历史消息。':
'This Session has no history messages available to preview yet.',
// harness-approval.mjs // harness-approval.mjs
'请精准回复「批准」或「拒绝」(也支持:同意 / 不同意 / yes / no)。': '请精准回复「批准」或「拒绝」(也支持:同意 / 不同意 / yes / no)。':
'Please reply exactly with 「批准」 (approve) or 「拒绝」 (reject). Also accepted: 同意 / 不同意 / yes / no.', 'Please reply exactly with 「批准」 (approve) or 「拒绝」 (reject). Also accepted: 同意 / 不同意 / yes / no.',

View file

@ -1,6 +1,7 @@
import { t } from './i18n.mjs'; import { t } from './i18n.mjs';
import { runWorkspaceCommand } from './workspace-command.mjs'; import { runWorkspaceCommand } from './workspace-command.mjs';
import { runCompactCommand } from './compact-command.mjs'; import { runCompactCommand } from './compact-command.mjs';
import { isHistoryCommand, runHistoryCommand } from './history-command.mjs';
import { import {
isControlCommand, isControlCommand,
runControlCommand, runControlCommand,
@ -260,7 +261,8 @@ export class TextHarnessBridge {
} }
const collectingBatch = normalized.kind === 'direct' const collectingBatch = normalized.kind === 'direct'
&& this.#batches.status(key).phase === 'collecting'; && this.#batches.status(key).phase === 'collecting';
const commandRunner = collectingBatch || hasInboundFiles(normalized) ? null : isControlCommand(text) const commandRunner = collectingBatch ? null : isHistoryCommand(text) ? runHistoryCommand
: hasInboundFiles(normalized) ? null : isControlCommand(text)
? runControlCommand ? runControlCommand
: (isModelCommand(text) : (isModelCommand(text)
? runModelCommand ? runModelCommand
@ -417,6 +419,7 @@ export class TextHarnessBridge {
key, key,
{ {
signal: this.#signal, signal: this.#signal,
isDirect: message.kind === 'direct',
hasImages: hasInboundImages(message), hasImages: hasInboundImages(message),
hasFiles: hasInboundFiles(message), hasFiles: hasInboundFiles(message),
pendingInteraction: this.#pendingInteractions.has(key) pendingInteraction: this.#pendingInteractions.has(key)
@ -529,6 +532,7 @@ export class TextHarnessBridge {
t('直接发送文字、图片或文件即可继续当前会话。'), t('直接发送文字、图片或文件即可继续当前会话。'),
t('/new 开启一个全新会话'), t('/new 开启一个全新会话'),
t('/compact 压缩当前会话的较早上下文'), t('/compact 压缩当前会话的较早上下文'),
t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
t('/workspace 工作区绝对路径 切换工作区'), t('/workspace 工作区绝对路径 切换工作区'),
t('/workspacelist 列出工作区绝对路径'), t('/workspacelist 列出工作区绝对路径'),
t('/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题'), t('/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题'),

View file

@ -344,6 +344,7 @@ async function runSessionBindCommand(command, harness, conversationKey) {
t('标题:{title}', { title }), t('标题:{title}', { title }),
`ID:${boundSessionId}`, `ID:${boundSessionId}`,
t('归档:{archived}', { archived: bound?.archived === true ? t('是') : t('否') }), t('归档:{archived}', { archived: bound?.archived === true ? t('是') : t('否') }),
t('发送 /history 查看最近对话。'),
].join('\n'); ].join('\n');
return commandResult(message, splitWorkspaceCommandMessage(message)); return commandResult(message, splitWorkspaceCommandMessage(message));
} catch (error) { } catch (error) {

View file

@ -12,6 +12,7 @@ import {
isBatchInputCommand, isBatchInputCommand,
} from '../shared/batch-input.mjs'; } from '../shared/batch-input.mjs';
import { runCompactCommand } from '../shared/compact-command.mjs'; import { runCompactCommand } from '../shared/compact-command.mjs';
import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
import { import {
isControlCommand, isControlCommand,
runControlCommand, runControlCommand,
@ -61,6 +62,7 @@ function helpText() {
t('直接发送文字、图片或文件即可继续当前会话。'), t('直接发送文字、图片或文件即可继续当前会话。'),
t('/new 开启一个全新会话'), t('/new 开启一个全新会话'),
t('/compact 压缩当前会话的较早上下文'), t('/compact 压缩当前会话的较早上下文'),
t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
t('/workspace 工作区绝对路径 切换工作区'), t('/workspace 工作区绝对路径 切换工作区'),
t('/workspacelist 列出工作区绝对路径'), t('/workspacelist 列出工作区绝对路径'),
t('/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题'), t('/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题'),
@ -591,7 +593,8 @@ export class WecomHarnessBridge {
return this.#finishBatchResult(frame, messageId, chatId, result); return this.#finishBatchResult(frame, messageId, chatId, result);
} }
} }
const commandRunner = hasInboundFiles(commandMessage) ? null : isControlCommand(commandText) const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
: hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
? runControlCommand ? runControlCommand
: (isModelCommand(commandText) : (isModelCommand(commandText)
? runModelCommand ? runModelCommand
@ -768,6 +771,7 @@ export class WecomHarnessBridge {
this.#status.lastMessageAt = new Date().toISOString(); this.#status.lastMessageAt = new Date().toISOString();
const result = await runner(message.content, this.#harness, this.#state, key, { const result = await runner(message.content, this.#harness, this.#state, key, {
signal: this.#signal, signal: this.#signal,
isDirect: bodyOf(frame).chattype === 'single',
hasImages: hasInboundImages(message), hasImages: hasInboundImages(message),
hasFiles: hasInboundFiles(message), hasFiles: hasInboundFiles(message),
pendingInteraction: this.#pendingInteractions.has(key) pendingInteraction: this.#pendingInteractions.has(key)

View file

@ -18,6 +18,7 @@ import {
isBatchInputCommand, isBatchInputCommand,
} from '../shared/batch-input.mjs'; } from '../shared/batch-input.mjs';
import { runCompactCommand } from '../shared/compact-command.mjs'; import { runCompactCommand } from '../shared/compact-command.mjs';
import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
import { import {
isControlCommand, isControlCommand,
runControlCommand, runControlCommand,
@ -68,6 +69,7 @@ const HELP_TEXT = () => [
t('直接发送文字、图片、文件或带文字识别结果的语音即可继续当前会话。'), t('直接发送文字、图片、文件或带文字识别结果的语音即可继续当前会话。'),
t('/new 开启一个全新会话'), t('/new 开启一个全新会话'),
t('/compact 压缩当前会话的较早上下文'), t('/compact 压缩当前会话的较早上下文'),
t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
t('/workspace 工作区绝对路径 切换工作区'), t('/workspace 工作区绝对路径 切换工作区'),
t('/workspacelist 列出工作区绝对路径'), t('/workspacelist 列出工作区绝对路径'),
t('/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题'), t('/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题'),
@ -364,7 +366,8 @@ export class WeixinHarnessBridge {
); );
} }
} }
const commandRunner = hasWeixinFileItems(message) ? null : isControlCommand(commandText) const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
: hasWeixinFileItems(message) ? null : isControlCommand(commandText)
? runControlCommand ? runControlCommand
: (isModelCommand(commandText) : (isModelCommand(commandText)
? runModelCommand ? runModelCommand
@ -541,6 +544,7 @@ export class WeixinHarnessBridge {
this.#status.lastMessageAt = new Date().toISOString(); this.#status.lastMessageAt = new Date().toISOString();
const result = await runner(text, this.#harness, this.#state, key, { const result = await runner(text, this.#harness, this.#state, key, {
signal: this.#signal, signal: this.#signal,
isDirect: true,
hasImages: hasWeixinImageItems(message), hasImages: hasWeixinImageItems(message),
hasFiles: hasWeixinFileItems(message), hasFiles: hasWeixinFileItems(message),
pendingInteraction: this.#pendingInteractions.has(key) pendingInteraction: this.#pendingInteractions.has(key)

View file

@ -191,6 +191,62 @@ test('explicit baseUrl still selects the existing HTTP transport', async () => {
assert.equal(requests, 1); assert.equal(requests, 1);
}); });
test('history reading uses only the existing read RPC in both Host connection modes', async () => {
const page = { events: [], hasMore: false };
for (const mode of ['local', 'http']) {
const calls = [];
const client = localClient({
sessions: {
history({ rpcId, payload }, signal) {
assert.equal(mode, 'local');
assert.ok(signal instanceof AbortSignal);
calls.push(payload);
return { rpcId, result: { ok: true, value: page } };
},
},
}, mode === 'http' ? {
baseUrl: 'http://127.0.0.1:1234',
fetchImpl: async (url, options) => {
assert.equal(url.pathname, '/api/session.history');
const request = JSON.parse(options.body);
calls.push(request.payload);
return { ok: true, json: async () => ({
type: 'server-response', rpcId: request.rpcId, result: { ok: true, value: page },
}) };
},
} : {});
assert.deepEqual(await client.readSessionHistory('cold-session'), page);
assert.deepEqual(await client.readSessionHistory('cold-session', { beforeSeq: 40, maxMessages: 10 }), page);
assert.deepEqual(calls, [
{ sessionId: 'cold-session', maxMessages: 50 },
{ sessionId: 'cold-session', beforeSeq: 40, maxMessages: 10 },
]);
await assert.rejects(client.readSessionHistory('', {}), TypeError);
await assert.rejects(client.readSessionHistory('cold-session', { maxMessages: 0 }), TypeError);
await assert.rejects(client.readSessionHistory('cold-session', { beforeSeq: -1 }), TypeError);
assert.equal(calls.length, 2, 'invalid input must not make another RPC');
}
});
test('history reads preserve cancellation, timeout and missing-session errors without retrying', async () => {
let calls = 0;
const client = localClient({ sessions: {
async history({ rpcId, payload }) {
calls += 1;
if (payload.sessionId === 'missing') {
return { rpcId, result: { ok: false, error: { code: 'session-not-found', message: 'missing' } } };
}
await delay(30);
return { rpcId, result: { ok: true, value: { events: [], hasMore: false } } };
},
} });
await assert.rejects(client.readSessionHistory('missing'), { code: 'session-not-found' });
await assert.rejects(client.readSessionHistory('slow', { timeoutMs: 2 }), { code: 'harness-timeout' });
const signal = AbortSignal.abort(new DOMException('Cancelled', 'AbortError'));
await assert.rejects(client.readSessionHistory('cancelled', { signal }), { name: 'AbortError' });
assert.equal(calls, 2);
});
test('in-process interaction responses preserve the full envelope and rejection receipts', async () => { test('in-process interaction responses preserve the full envelope and rejection receipts', async () => {
let received; let received;
let receipt = { accepted: true }; let receipt = { accepted: true };

View file

@ -0,0 +1,449 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DingtalkHarnessBridge } from '../src/channels/dingtalk/dingtalk-bridge.mjs';
import { DiscordHarnessBridge } from '../src/channels/discord/discord-bridge.mjs';
import { FeishuHarnessBridge } from '../src/channels/feishu/bridge.mjs';
import { QqHarnessBridge } from '../src/channels/qq/qq-bridge.mjs';
import { setImHostLanguage } from '../src/channels/shared/i18n.mjs';
import { SlackHarnessBridge } from '../src/channels/slack/slack-bridge.mjs';
import { normalizeSlackEvent } from '../src/channels/slack/slack-runtime.mjs';
import { TelegramHarnessBridge } from '../src/channels/telegram/telegram-bridge.mjs';
import { WecomHarnessBridge } from '../src/channels/wecom/wecom-bridge.mjs';
import { WeixinHarnessBridge } from '../src/channels/weixin/weixin-bridge.mjs';
import { WhatsappHarnessBridge } from '../src/channels/whatsapp/whatsapp-bridge.mjs';
const CHANNELS = ['weixin', 'feishu', 'dingtalk', 'wecom', 'qq', 'slack', 'telegram', 'discord', 'whatsapp'];
const TEXT_BRIDGES = {
slack: SlackHarnessBridge,
telegram: TelegramHarnessBridge,
discord: DiscordHarnessBridge,
whatsapp: WhatsappHarnessBridge,
};
const SESSION_ID = 'history-session';
const MARKERS = [
'history-user-1', 'history-assistant-1',
'history-user-2', 'history-assistant-2',
'history-user-3', 'history-assistant-3',
];
function history() {
let seq = 0;
const entry = (type, data, surfaceOp) => ({
event: { seq: ++seq, time: seq, type, data, ...(surfaceOp ? { surfaceOp } : {}) },
});
return {
hasMore: false,
events: [1, 2, 3].flatMap((turn) => [
entry('turn/start', { turn }),
entry('user/message', {
turn,
source: { kind: 'user' },
content: [{ type: 'text', text: `history-user-${turn}` }],
}, 'append'),
entry('assistant/message', {
turn,
step: 1,
message: { role: 'assistant', content: [{ type: 'text', text: `history-assistant-${turn}` }] },
}, 'append'),
entry('turn/end', { turn, reason: 'completed' }),
]),
};
}
function deferred() {
let resolve;
const promise = new Promise((settle) => { resolve = settle; });
return { promise, resolve };
}
async function within(promise, description) {
let timer;
try {
return await Promise.race([
promise,
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(description)), 1_500);
}),
]);
} finally {
clearTimeout(timer);
}
}
/** Real bridge entrypoints with only the platform transport and Host replaced. */
function fixture(channel, { sessionId = SESSION_ID, ask, readHistory, send } = {}) {
const sent = [];
const seen = new Set();
const calls = { reads: [], asks: [], mutations: [], downloads: [] };
const key = ['weixin', 'feishu', 'dingtalk'].includes(channel) ? 'p2p:owner'
: channel === 'qq' ? 'c2c:owner'
: channel === 'wecom' ? 'direct:owner' : 'direct:chat';
const sessions = new Map(sessionId ? [[key, sessionId]] : []);
const forbidden = (name) => async (...args) => {
calls.mutations.push({ name, args });
throw new Error(`unexpected ${name}`);
};
const state = {
hasSeen: (id) => seen.has(id),
markSeen: async (id) => seen.add(id),
sessionFor: (requestedKey) => sessions.get(requestedKey) ?? null,
setSession: forbidden('setSession'),
clearSession: forbidden('clearSession'),
};
const harness = {
sessionExists: async () => true,
readSessionHistory: async (id, options) => {
calls.reads.push({ id, options });
return readHistory ? readHistory(id, options) : history();
},
ask: async (id, text, options) => {
calls.asks.push({ id, text });
if (!ask) throw new Error('history must not reach the model');
return ask(id, text, options);
},
createSession: forbidden('createSession'),
ensureRunning: forbidden('ensureRunning'),
executeCommand: forbidden('executeCommand'),
stopActiveTurn: forbidden('stopActiveTurn'),
steerActiveTurn: forbidden('steerActiveTurn'),
workspaceSession(id) {
return {
readHistory: (options) => harness.readSessionHistory(id, options),
sessionExists: () => harness.sessionExists(id),
ask: (text, options) => harness.ask(id, text, options),
};
},
};
const record = async (target, text) => {
sent.push({ target, text });
if (send) await send(target, text);
return { messageId: `out-${sent.length}` };
};
const download = async () => {
calls.downloads.push('download');
throw new Error('history must not download attachments');
};
const options = { harness, state, logger: { warn() {}, error() {} } };
let bridge;
let message;
if (channel === 'weixin') {
bridge = new WeixinHarnessBridge({
...options,
baseUrl: 'https://ilinkai.weixin.qq.com/', token: 'test-token', ownerUserId: 'owner',
api: { sendText: ({ toUserId, contextToken, runId, text }) => record({ toUserId, contextToken, runId }, text) },
});
message = (id, text, { image = false, file = false, sender = 'owner', echo = false } = {}) => ({
message_id: id, message_type: echo ? 2 : 1, from_user_id: sender,
context_token: `context-${id}`, run_id: `run-${id}`,
item_list: [
{ type: 1, text_item: { text } },
...(image ? [{ type: 2, image_item: { media: {} } }] : []),
...(file ? [{ type: 4, file_item: { file_name: 'test.txt', media: {} } }] : []),
],
});
} else if (channel === 'feishu') {
bridge = new FeishuHarnessBridge({
...options,
channel: {}, status: {}, allowedSenderOpenIds: new Set(['owner']),
client: { im: { v1: { message: { create: async ({ data }) => {
await record(data.receive_id, data.msg_type === 'text' ? JSON.parse(data.content).text : data.content);
return { code: 0, data: { message_id: `out-${sent.length}` } };
} }, messageResource: { get: download } } } },
});
message = (id, text, { image = false, group = false, sender = 'owner', echo = false, post = false, addressed = true } = {}) => ({
sender: { sender_type: echo ? 'bot' : 'user', sender_id: { open_id: sender } },
message: {
message_id: id, message_type: image || post ? 'post' : 'text',
chat_type: group ? 'group' : 'p2p', chat_id: group ? 'group-chat' : 'chat',
mentions: group && addressed ? [{ key: '@bot', id: { open_id: 'bot' } }] : [],
content: JSON.stringify(image || post ? {
content: [[{ tag: 'text', text }, ...(image ? [{ tag: 'img', image_key: 'image' }] : [])]],
} : { text }),
},
});
} else if (channel === 'dingtalk') {
bridge = new DingtalkHarnessBridge({
...options, clientId: 'test-client', clientSecret: 'test-secret',
api: { sendText: ({ sessionWebhook, text }) => record(sessionWebhook, text), downloadImage: download },
});
message = (id, text, { image = false, group = false, addressed = true } = {}) => ({
msgId: id, msgtype: image ? 'richText' : 'text', text: { content: text },
...(image ? { content: { richText: [{ type: 'text', text }, { type: 'picture', downloadCode: 'image' }] } } : {}),
conversationType: group ? '2' : '1', conversationId: group ? 'group-chat' : 'chat',
senderStaffId: 'owner', isInAtList: addressed,
sessionWebhook: `https://oapi.dingtalk.com/robot/reply?ticket=${id}`,
});
} else if (channel === 'wecom') {
bridge = new WecomHarnessBridge({
...options,
client: {
replyStream: async (frame, _streamId, text, finish) => {
if (finish) return record(frame.body.chattype === 'group' ? frame.body.chatid : frame.body.from.userid, text);
return undefined;
},
replyStreamNonBlocking: async () => {},
sendMessage: (target, body) => record(target, body.markdown?.content ?? body.text?.content),
downloadFile: download,
},
});
message = (id, text, { image = false, group = false } = {}) => ({
headers: { req_id: id },
body: {
msgid: id, chattype: group ? 'group' : 'single', chatid: group ? 'group-chat' : 'chat',
from: { userid: 'owner' }, msgtype: image ? 'mixed' : 'text', text: { content: text },
...(image ? { mixed: { msg_item: [
{ msgtype: 'text', text: { content: text } },
{ msgtype: 'image', image: { url: 'https://example.invalid/image' } },
] } } : {}),
},
});
} else if (channel === 'qq') {
bridge = new QqHarnessBridge({ ...options, ownerUserOpenid: 'owner', bot: { sendText: record }, fetchImpl: download });
message = (id, text, { image = false, file = false, group = false, sender = 'owner', echo = false } = {}) => ({
messageId: id, senderId: sender, senderIsBot: echo, content: text,
kind: group ? 'group' : 'c2c', groupOpenid: group ? 'group-chat' : undefined,
rawEventType: group ? 'GROUP_AT_MESSAGE_CREATE' : 'C2C_MESSAGE_CREATE',
replyTarget: { scope: group ? 'group' : 'c2c', targetId: group ? 'group-chat' : sender, msgId: id },
attachments: image || file ? [{ content_type: image ? 'image/png' : 'text/plain', filename: image ? 'test.png' : 'test.txt', url: 'https://example.invalid/attachment' }] : [],
});
} else {
bridge = new TEXT_BRIDGES[channel]({ ...options, bot: { sendText: record } });
message = (id, text, { image = false, file = false, group = false, echo = false, addressed = true } = {}) => ({
messageId: id, senderId: 'owner', senderIsBot: echo, content: text,
kind: group ? 'group' : 'direct', conversationId: group ? 'group-chat' : 'chat', addressed,
replyTarget: { channel, chat: group ? 'group-chat' : 'chat', messageId: id },
images: image ? [{ load: download }] : [],
files: file ? [{ name: 'test.txt', load: download }] : [],
});
}
return { bridge, message, sent, calls, sessions, key, seen };
}
function previewMarkers(sent) {
return sent.map(({ text }) => text).join('\n').match(/history-(?:user|assistant)-[123]/g) ?? [];
}
for (const channel of CHANNELS) {
test(`${channel}: /history defaults to 3 and /history 10 clamps to 5 on its existing reply route`, async () => {
const f = fixture(channel);
for (const [text, count] of [['/history', 3], ['/history 10', 5]]) {
f.sent.length = 0;
const id = `count-${count}`;
await f.bridge.accept(f.message(id, text));
assert.deepEqual(previewMarkers(f.sent), MARKERS.slice(-count));
assert.ok(f.sent.length > 0 && f.sent.length <= 3);
assert.ok(f.sent.every(({ text: reply }) => reply.length <= 1_800));
if (channel === 'weixin') {
assert.deepEqual(f.sent[0].target, { toUserId: 'owner', contextToken: `context-${id}`, runId: `run-${id}` });
} else if (channel === 'dingtalk') {
assert.equal(f.sent[0].target, `https://oapi.dingtalk.com/robot/reply?ticket=${id}`);
} else if (channel === 'feishu') {
assert.equal(f.sent[0].target, 'chat');
} else if (channel === 'wecom') {
assert.equal(f.sent[0].target, 'owner');
} else {
assert.deepEqual(f.sent[0].target, f.message(id, text).replyTarget);
}
}
assert.equal(f.calls.reads.length, 2);
assert.ok(f.calls.reads.every(({ id }) => id === SESSION_ID));
assert.deepEqual(f.calls.asks, []);
assert.deepEqual(f.calls.mutations, []);
});
test(`${channel}: invalid counts, attachments and missing bindings stay local`, async () => {
const f = fixture(channel);
for (const [index, text] of ['/history 0', '/history -1', '/history 1.5', '/history foo', '/history 1 2'].entries()) {
await f.bridge.accept(f.message(`invalid-${index}`, text));
assert.match(f.sent.at(-1).text, /用法/);
}
await f.bridge.accept(f.message('with-image', '/history', { image: true }));
assert.match(f.sent.at(-1).text, /文字/);
// These native envelopes can carry both text and ordinary files. The other
// adapters have standalone file messages, not a text command plus a file.
if (['weixin', 'qq', ...Object.keys(TEXT_BRIDGES)].includes(channel)) {
await f.bridge.accept(f.message('with-file', '/history', { file: true }));
assert.match(f.sent.at(-1).text, /文字/);
}
f.sessions.clear();
await f.bridge.accept(f.message('unbound', '/history'));
assert.match(f.sent.at(-1).text, /会话/);
assert.deepEqual(f.calls, { reads: [], asks: [], mutations: [], downloads: [] });
});
test(`${channel}: duplicate history delivery is processed once`, async () => {
const f = fixture(channel);
const event = f.message('duplicate', '/history');
await Promise.all([f.bridge.accept(event), f.bridge.accept(event)]);
await f.bridge.accept(event);
assert.equal(f.calls.reads.length, 1);
assert.deepEqual(previewMarkers(f.sent), MARKERS.slice(-3));
assert.deepEqual(f.calls.asks, []);
});
test(`${channel}: a history read failure is safe and never becomes a model prompt`, async () => {
const f = fixture(channel, { readHistory: async () => { throw new Error('private-host-history-detail'); } });
await f.bridge.accept(f.message('failed-read', '/history'));
assert.equal(f.calls.reads.length, 1);
assert.match(f.sent.at(-1).text, /无法读取/);
assert.doesNotMatch(f.sent.at(-1).text, /private-host-history-detail/);
assert.deepEqual(f.calls.asks, []);
assert.deepEqual(f.calls.mutations, []);
});
test(`${channel}: existing transport failure handling never re-reads history or prompts`, async () => {
let attempts = 0;
const f = fixture(channel, { send: async () => {
attempts += 1;
if (attempts === 1) throw new Error('private-channel-failure-detail');
} });
await f.bridge.accept(f.message('failed-delivery', '/history'));
assert.equal(f.calls.reads.length, 1);
assert.ok(attempts >= 1);
assert.doesNotMatch(f.sent.map(({ text }) => text).join('\n'), /private-channel-failure-detail/);
assert.deepEqual(f.calls.asks, []);
assert.deepEqual(f.calls.mutations, []);
});
test(`${channel}: collecting /batch blocks history without recording it as input`, async () => {
const f = fixture(channel, { ask: async () => 'batch complete' });
await f.bridge.accept(f.message('batch', '/batch'));
await f.bridge.accept(f.message('batch-text', 'the only batch input'));
await f.bridge.accept(f.message('batch-history', '/history 10'));
assert.match(f.sent.at(-1).text, /\/send.*\/cancel/);
assert.deepEqual(f.calls.reads, []);
await f.bridge.accept(f.message('batch-send', '/send'));
assert.equal(f.calls.asks.length, 1);
assert.match(f.calls.asks[0].text, /the only batch input/);
assert.doesNotMatch(f.calls.asks[0].text, /\/history/);
assert.deepEqual(f.calls.mutations, []);
});
for (const kind of ['running', 'question', 'approval']) {
test(`${channel}: history bypasses a ${kind} task without answering or stopping it`, async (t) => {
const ready = deferred();
const release = deferred();
const responses = [];
const f = fixture(channel, { ask: async (sessionId, _text, options) => {
if (kind !== 'running') {
await options.onInteraction({
kind,
interactionId: `${kind}-history`, rpcId: `${kind}-history`, sessionId,
payload: kind === 'question'
? { type: 'question/requested', sessionId, questions: [{ id: 'answer', question: 'choose an answer' }] }
: { type: 'approval/requested', sessionId, approvalId: 'approval-history', toolName: 'bash', callId: 'call-history' },
...(kind === 'approval' ? { toolCall: { name: 'bash', callId: 'call-history', arguments: '{}' } } : {}),
respond: async (value) => { responses.push(value); return { accepted: true }; },
});
}
ready.resolve();
await release.promise;
return 'original task complete';
} });
const processing = f.bridge.accept(f.message('active-task', 'original task'));
t.after(async () => { release.resolve(); await processing; });
await within(ready.promise, `${channel} did not begin the ${kind} task`);
const before = f.sent.length;
await within(f.bridge.accept(f.message('during-task', '/history 10')), `${channel} history waited for the active task`);
assert.deepEqual(previewMarkers(f.sent.slice(before)), MARKERS.slice(-5));
assert.equal(f.calls.reads.length, 1);
assert.deepEqual(responses, []);
assert.deepEqual(f.calls.asks.map(({ text }) => text), ['original task']);
assert.deepEqual(f.calls.mutations, []);
release.resolve();
await processing;
});
}
test(`${channel}: /help advertises the same history defaults in both languages`, async () => {
for (const language of ['zh', 'en']) {
setImHostLanguage(language);
try {
const f = fixture(channel);
await f.bridge.accept(f.message(`help-${language}`, '/help'));
const help = f.sent.map(({ text }) => text).join('\n');
assert.match(help, /\/history.*3.*5/);
if (language === 'en') assert.doesNotMatch(help, /[\u3400-\u9fff]/u);
assert.deepEqual(f.calls.reads, []);
assert.deepEqual(f.calls.asks, []);
} finally {
setImHostLanguage('zh');
}
}
});
if (channel !== 'weixin') {
test(`${channel}: an accepted group history command refuses before reading any Session`, async () => {
const f = fixture(channel);
f.sessions.set('group:group-chat', SESSION_ID);
await f.bridge.accept(f.message('group-history', '/history', { group: true }));
assert.match(f.sent.at(-1).text, /私聊/);
assert.deepEqual(f.calls, { reads: [], asks: [], mutations: [], downloads: [] });
});
}
if (['weixin', 'feishu', 'qq', ...Object.keys(TEXT_BRIDGES)].includes(channel)) {
test(`${channel}: a bot echo cannot trigger history or a new model request`, async () => {
const f = fixture(channel);
await f.bridge.accept(f.message('history-echo', '/history', { echo: true }));
assert.deepEqual(f.sent, []);
assert.deepEqual(f.calls, { reads: [], asks: [], mutations: [], downloads: [] });
});
}
}
for (const channel of ['weixin', 'feishu', 'qq']) {
test(`${channel}: the existing owner/allowlist guard still blocks history`, async () => {
const f = fixture(channel);
await f.bridge.accept(f.message('denied-history', '/history', { sender: 'untrusted' }));
assert.deepEqual(f.sent, []);
assert.deepEqual(f.calls, { reads: [], asks: [], mutations: [], downloads: [] });
});
}
test('Feishu accepts its existing plain post normalization for /history', async () => {
const f = fixture('feishu');
await f.bridge.accept(f.message('post-history', '/history 1', { post: true }));
assert.deepEqual(previewMarkers(f.sent), MARKERS.slice(-1));
assert.deepEqual(f.calls.asks, []);
});
test('Feishu all-message group mode refuses unmentioned history without prompting', async () => {
const f = fixture('feishu');
f.sessions.set('group:group-chat', SESSION_ID);
await f.bridge.accept(f.message('group-unmentioned-history', '/history', { group: true, addressed: false }));
assert.match(f.sent.at(-1).text, /私聊/);
assert.deepEqual(f.calls, { reads: [], asks: [], mutations: [], downloads: [] });
});
test('Feishu mention-only group mode still ignores an unmentioned history command', async () => {
const f = fixture('feishu');
f.bridge.setGroupResponseMode('mention');
await f.bridge.accept(f.message('group-unmentioned-ignored', '/history', { group: true, addressed: false }));
assert.deepEqual(f.sent, []);
assert.deepEqual(f.calls, { reads: [], asks: [], mutations: [], downloads: [] });
});
test('Slack leading-space history messages normalize into the shared command without prompting', async () => {
const f = fixture('slack');
for (const [text, count] of [[' /history', 3], [' /history 10', 5]]) {
f.sent.length = 0;
const message = normalizeSlackEvent({
event_id: `history-leading-space-${count}`,
team_id: 'team',
event: {
type: 'message', channel_type: 'im', channel: 'chat', user: 'owner',
ts: `1700000000.00${count}`, text,
},
}, 'bot');
assert.equal(message.content, text.trim());
await f.bridge.accept(message);
assert.deepEqual(previewMarkers(f.sent), MARKERS.slice(-count));
assert.deepEqual(f.sent[0].target, message.replyTarget);
}
assert.equal(f.calls.reads.length, 2);
assert.ok(f.calls.reads.every(({ id }) => id === SESSION_ID));
assert.deepEqual(f.calls.asks, []);
assert.deepEqual(f.calls.mutations, []);
});

View file

@ -0,0 +1,601 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
isHistoryCommand,
runHistoryCommand,
} from '../src/channels/shared/history-command.mjs';
import {
getImHostLanguage,
setImHostLanguage,
} from '../src/channels/shared/i18n.mjs';
const KEY = 'direct:history-test';
const PRIVATE_DETAIL = 'DO_NOT_DISPLAY_PRIVATE_HISTORY_DETAIL';
const SAFE_FAILURE = /失败|无法|暂不支持|重试|不存在|变化|取消|unavailable|retry|failed|not exist|changed|cancel/iu;
function entry(seq, type, data, extra = {}) {
return { event: { seq, type, data, ...extra } };
}
function user(seq, text, extra = {}) {
return entry(seq, 'user/message', {
role: 'user',
source: { kind: 'user' },
content: [{ type: 'text', text }],
...extra,
}, { surfaceOp: 'append' });
}
function assistant(seq, turn, text, extra = {}) {
return entry(seq, 'assistant/message', {
turn,
step: 1,
message: { role: 'assistant', content: [{ type: 'text', text }] },
...extra,
}, { surfaceOp: 'append' });
}
function end(seq, turn, reason = { kind: 'completed' }) {
return entry(seq, 'turn/end', { turn, reason });
}
function dialogue(turns = 4) {
return Array.from({ length: turns }, (_, index) => {
const turn = index + 1;
return [
user(turn * 10, `history_user_${turn}`),
assistant(turn * 10 + 1, turn, `history_assistant_${turn}`),
end(turn * 10 + 2, turn),
];
}).flat();
}
function fixture({ sessionId = 'hist42', events = dialogue(), read } = {}) {
let binding = sessionId;
const calls = [];
const state = { sessionFor: () => binding };
const forbidden = (method) => () => assert.fail(`/history called mutating method ${method}`);
const session = {
async readHistory(options) {
calls.push({ sessionId: binding, options });
return read ? read(options, calls.length) : { events, hasMore: false };
},
ask: forbidden('ask'),
stopActiveTurn: forbidden('stopActiveTurn'),
steerActiveTurn: forbidden('steerActiveTurn'),
};
const harness = {
workspaceSession(id) {
assert.equal(id, sessionId, 'only the originally bound Session may be read');
return session;
},
createSession: forbidden('createSession'),
ensureRunning: forbidden('ensureRunning'),
executeCommand: forbidden('executeCommand'),
rpc: forbidden('raw RPC outside the scoped history handle'),
};
return {
calls,
harness,
session,
state,
bind(nextSessionId) { binding = nextSessionId; },
run(text = '/history', options = {}) {
return runHistoryCommand(text, harness, state, KEY, { isDirect: true, ...options });
},
};
}
function reply(result) {
assert.equal(result?.handled, true);
assert.equal(typeof result.message, 'string');
assert.ok(result.message.length > 0);
assert.ok(Array.isArray(result.messages));
assert.ok(result.messages.length > 0 && result.messages.length <= 3);
assert.equal(result.messages.join(''), result.message);
assert.ok(result.messages.every((part) => typeof part === 'string' && part.length <= 1_800));
assert.ok(result.message.length <= 3_000);
return result.message;
}
function selectedMarkers(message) {
return message.match(/history_(?:user|assistant)_\d+/gu) ?? [];
}
test('history reserves its command token, including malformed arguments, but not other text', async () => {
for (const text of [
'/history', ' /HiStOrY ', '/history 3', '/history 0', '/history 1 2',
'/history -1', '/history\nnot-a-number',
]) {
assert.equal(isHistoryCommand(text), true, text);
}
for (const text of [null, undefined, 42, '', 'history', '/history2', '/histories', 'hi /history']) {
assert.equal(isHistoryCommand(text), false, String(text));
assert.equal(await runHistoryCommand(text, {}, {}, KEY), null);
}
});
test('/history defaults to three individual messages, newest selected then oldest displayed', async () => {
const current = fixture();
const text = reply(await current.run());
assert.deepEqual(selectedMarkers(text), [
'history_assistant_3', 'history_user_4', 'history_assistant_4',
]);
assert.match(text, /hist42/);
assert.equal(current.calls.length, 1);
assert.equal(current.calls[0].options.maxMessages, 50);
assert.equal(current.calls[0].options.beforeSeq, undefined);
});
test('/history accepts positive integer counts and clamps every count above five', async () => {
const all = Array.from({ length: 4 }, (_, index) => [
`history_user_${index + 1}`, `history_assistant_${index + 1}`,
]).flat();
for (const [argument, count] of [
['1', 1], ['2', 2], ['3', 3], ['4', 4], ['5', 5], ['6', 5], ['100', 5],
['9'.repeat(400), 5], ['0003', 3],
]) {
const current = fixture();
const text = reply(await current.run(`/history ${argument}`));
assert.deepEqual(selectedMarkers(text), all.slice(-count), argument);
assert.equal(current.calls.length, 1, argument);
}
});
test('history omits stored history commands before applying the default and maximum counts', async () => {
const oldCommand = user(1, '/history');
oldCommand.event.time = Date.UTC(2020, 0, 1);
const currentCommand = user(40, ' /HiStOrY 3 ');
currentCommand.event.time = Date.now();
const events = [
oldCommand,
...dialogue(3),
user(33, '/history'),
user(34, '/history 10'),
currentCommand,
];
const normalMessages = Array.from({ length: 3 }, (_, index) => [
`history_user_${index + 1}`, `history_assistant_${index + 1}`,
]).flat();
for (const [command, count] of [['/history', 3], ['/history 10', 5]]) {
const current = fixture({ events });
const text = reply(await current.run(command));
assert.deepEqual(selectedMarkers(text), normalMessages.slice(-count));
assert.doesNotMatch(text, /\/history(?:\s|$)/iu);
}
});
test('ordinary history text can mention /history or start with a different command token', async () => {
const bodies = ['/history2', '请解释 /history 这个命令', '文档里也有 /history 10 的示例'];
const current = fixture({ events: bodies.map((body, index) => user(index + 1, body)) });
const text = reply(await current.run());
for (const body of bodies) assert.ok(text.includes(body), body);
});
test('filtered history backfills older pages to reach three or five normal messages', async () => {
const pages = [
{ events: [user(60, '/history'), user(61, '/history 10'), user(62, '/history 3')], hasMore: true },
{ events: [
user(50, 'history_user_3'), user(51, 'history_user_4'),
user(52, 'history_user_5'), user(53, 'history_user_6'), user(54, '/history 2'),
], hasMore: true },
{ events: [
user(10, 'history_user_1'), user(11, 'history_user_2'), user(12, '/history 10'),
], hasMore: false },
];
for (const [command, count, reads] of [['/history', 3, 2], ['/history 10', 5, 3]]) {
const current = fixture({ read: (_options, call) => pages[call - 1] });
const text = reply(await current.run(command));
assert.deepEqual(selectedMarkers(text), Array.from({ length: count }, (_, index) => (
`history_user_${7 - count + index}`
)));
assert.equal(current.calls.length, reads);
assert.deepEqual(current.calls.map(({ options }) => options.beforeSeq), [undefined, 60, 50].slice(0, reads));
assert.doesNotMatch(text, /\/history(?:\s|$)/iu);
}
});
test('only stored history commands produce the empty-history response', async () => {
const current = fixture({ events: [
user(1, '/history'), user(2, '/history 10'), user(3, ' /HISTORY 5 '),
] });
const text = reply(await current.run());
assert.match(text, /暂无|没有|no .*history|no .*message/iu);
assert.doesNotMatch(text, /\/history(?:\s|$)/iu);
});
test('invalid history counts are consumed as usage errors without querying the Session', async () => {
for (const argument of ['0', '000', '-1', '+3', '1.5', '1e3', 'no', '1 2', '3\n4', '3']) {
const current = fixture();
const text = reply(await current.run(`/history ${argument}`));
assert.match(text, /用法|usage/iu, argument);
assert.match(text, /\/history/u, argument);
assert.equal(current.calls.length, 0, argument);
}
});
test('history is private and text-only, including when private status is omitted', async () => {
for (const options of [
{ isDirect: false }, { isDirect: true, hasImages: true }, { isDirect: true, hasFiles: true },
]) {
const current = fixture();
const text = reply(await current.run('/history', options));
assert.match(text, /私聊|文字|图片|文件|direct|text|image|file/iu);
assert.equal(current.calls.length, 0);
}
const current = fixture();
reply(await runHistoryCommand('/history', current.harness, current.state, KEY));
assert.equal(current.calls.length, 0);
});
test('history never creates a Session and returns only the available records', async () => {
const unbound = fixture({ sessionId: null });
assert.match(reply(await unbound.run()), /绑定|会话|bound|session/iu);
assert.equal(unbound.calls.length, 0);
const empty = fixture({ events: [] });
assert.match(reply(await empty.run()), /暂无|没有|no .*history|no .*message/iu);
const short = fixture({ events: [user(1, 'history_user_1')] });
assert.deepEqual(selectedMarkers(reply(await short.run('/history 5'))), ['history_user_1']);
});
test('history accepts completed reasons from both current and older Host protocols', async () => {
for (const reason of [{ kind: 'completed' }, 'completed']) {
const current = fixture({ events: [
user(1, 'history_user_1'),
assistant(2, 1, 'history_assistant_1'),
end(3, 1, reason),
] });
assert.deepEqual(selectedMarkers(reply(await current.run())), [
'history_user_1', 'history_assistant_1',
]);
}
});
test('history filters injections, replacements, tools, reasoning, and intermediate replies', async () => {
const replacedUser = user(8, PRIVATE_DETAIL);
replacedUser.event.surfaceOp = 'replace';
const replacedAssistant = assistant(9, 1, PRIVATE_DETAIL);
replacedAssistant.event.surfaceOp = 'replace';
const unknownSource = user(10, PRIVATE_DETAIL, { source: undefined });
const missingOperation = user(11, PRIVATE_DETAIL);
delete missingOperation.event.surfaceOp;
const current = fixture({ events: [
user(1, 'history_user_1'),
user(2, PRIVATE_DETAIL, { source: { kind: 'plugin', pluginId: 'time-context' } }),
user(3, PRIVATE_DETAIL, { source: { kind: 'goal' } }),
assistant(4, 1, PRIVATE_DETAIL),
entry(5, 'tool/call', { name: 'bash', arguments: PRIVATE_DETAIL, turn: 1 }),
entry(6, 'tool/result', { text: PRIVATE_DETAIL, turn: 1 }, { surfaceOp: 'append' }),
entry(7, 'assistant/chunk', { turn: 1, chunk: { type: 'text-delta', text: PRIVATE_DETAIL } }),
replacedUser,
replacedAssistant,
unknownSource,
missingOperation,
entry(12, 'approval/requested', { text: PRIVATE_DETAIL }),
assistant(13, 1, '', {
step: 2,
message: { role: 'assistant', content: [
{ type: 'reasoning', text: PRIVATE_DETAIL },
{ type: 'text', text: 'history_assistant_1' },
{ type: 'tool-call', name: PRIVATE_DETAIL, arguments: PRIVATE_DETAIL },
] },
}),
end(14, 1),
] });
const text = reply(await current.run('/history 5'));
assert.deepEqual(selectedMarkers(text), ['history_user_1', 'history_assistant_1']);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
});
test('history keeps a running user input but never shows its unfinished assistant output', async () => {
const current = fixture({ events: [
...dialogue(1),
user(20, 'history_user_2'),
assistant(21, 2, PRIVATE_DETAIL),
entry(22, 'assistant/chunk', { turn: 2, chunk: { type: 'text-delta', text: PRIVATE_DETAIL } }),
] });
const text = reply(await current.run());
assert.deepEqual(selectedMarkers(text), [
'history_user_1', 'history_assistant_1', 'history_user_2',
]);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
});
test('history excludes assistant replies from every unsuccessful terminal state', async () => {
for (const reason of ['error', 'aborted', 'blocked', 'max-tokens', 'interrupted']) {
for (const terminal of [reason, { kind: reason }]) {
const current = fixture({ events: [
user(1, 'history_user_1'),
assistant(2, 1, PRIVATE_DETAIL),
end(3, 1, terminal),
] });
const text = reply(await current.run());
assert.deepEqual(selectedMarkers(text), ['history_user_1'], reason);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'), reason);
}
}
});
test('history does not substitute an intermediate answer for an interrupted final message', async () => {
const current = fixture({ events: [
user(1, 'history_user_1'),
assistant(2, 1, PRIVATE_DETAIL),
assistant(3, 1, PRIVATE_DETAIL, { step: 2, interrupted: true }),
end(4, 1),
] });
const text = reply(await current.run());
assert.deepEqual(selectedMarkers(text), ['history_user_1']);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
});
test('a final reply with no text gets a placeholder, not an earlier step or hidden blocks', async () => {
const current = fixture({ events: [
user(1, 'history_user_1'),
assistant(2, 1, PRIVATE_DETAIL),
assistant(3, 1, '', {
step: 2,
message: { role: 'assistant', content: [{ type: 'reasoning', text: PRIVATE_DETAIL }] },
}),
end(4, 1),
] });
const text = reply(await current.run('/history 1'));
assert.deepEqual(selectedMarkers(text), []);
assert.match(text, /文字|text/iu);
assert.match(text, /助手|assistant/iu);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
});
test('structured attachments are represented without exposing their payload or fetching files', async () => {
const current = fixture({ events: [user(1, '', {
content: [
{ type: 'text', text: 'history_user_1' },
{ type: 'image', data: PRIVATE_DETAIL, mediaType: 'image/png' },
],
})] });
const text = reply(await current.run());
assert.deepEqual(selectedMarkers(text), ['history_user_1']);
assert.match(text, /图片|image/iu);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
});
test('history sorts and deduplicates sequence numbers before selecting messages', async () => {
const events = dialogue(2);
const current = fixture({ events: [
events[5], events[3], events[0], events[2], events[4], events[1],
structuredClone(events[4]),
] });
assert.deepEqual(selectedMarkers(reply(await current.run('/history 5'))), [
'history_user_1', 'history_assistant_1', 'history_user_2', 'history_assistant_2',
]);
});
test('history combines older pages with later terminal events and stops once enough is known', async () => {
const pages = [
{ events: [end(32, 3)], hasMore: true },
{ events: [
user(20, 'history_user_2'),
assistant(21, 2, 'history_assistant_2'),
end(22, 2),
user(30, 'history_user_3'),
assistant(31, 3, 'history_assistant_3'),
], hasMore: true },
];
const current = fixture({ read: (_options, call) => {
assert.ok(call <= pages.length, 'must not fetch beyond the requested messages');
return pages[call - 1];
} });
const text = reply(await current.run());
assert.deepEqual(selectedMarkers(text), [
'history_assistant_2', 'history_user_3', 'history_assistant_3',
]);
assert.equal(current.calls.length, 2);
assert.deepEqual(current.calls.map(({ options }) => options.beforeSeq), [undefined, 32]);
assert.ok(current.calls.every(({ options }) => options.maxMessages === 50));
});
test('a multi-page tool turn contributes only its final reply beside the next running input', async () => {
const pages = [
{ events: [
assistant(90, 1, 'history_assistant_1', { step: 50 }),
end(91, 1),
user(100, 'history_user_2'),
assistant(101, 2, PRIVATE_DETAIL),
], hasMore: true },
{ events: [
assistant(60, 1, PRIVATE_DETAIL, { step: 30 }),
assistant(70, 1, PRIVATE_DETAIL, { step: 40 }),
], hasMore: true },
{ events: [
user(1, 'history_user_1'),
assistant(2, 1, PRIVATE_DETAIL),
], hasMore: false },
];
for (const count of [1, 3, 5]) {
const current = fixture({ read: (_options, call) => pages[call - 1] });
const text = reply(await current.run(`/history ${count}`));
assert.deepEqual(selectedMarkers(text), [
'history_user_1', 'history_assistant_1', 'history_user_2',
].slice(-count));
assert.equal(current.calls.length, count === 1 ? 1 : 3);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
}
});
test('history makes at most three backward reads and returns the confirmed records it has', async () => {
const current = fixture({ read: (_options, call) => ({
events: [user(100 - call, `history_user_${4 - call}`)], hasMore: true,
}) });
const text = reply(await current.run('/history 5'));
assert.deepEqual(selectedMarkers(text), ['history_user_1', 'history_user_2', 'history_user_3']);
assert.equal(current.calls.length, 3);
assert.deepEqual(current.calls.map(({ options }) => options.beforeSeq), [undefined, 99, 98]);
});
test('history rejects conflicting or invalid event sequences without echoing any response data', async () => {
for (const events of [
[user(1, PRIVATE_DETAIL), user(1, `${PRIVATE_DETAIL}_conflict`)],
[user(-1, PRIVATE_DETAIL)],
[user(1.5, PRIVATE_DETAIL)],
[user('1', PRIVATE_DETAIL)],
[{ event: { type: 'user/message', data: { text: PRIVATE_DETAIL } } }],
]) {
const current = fixture({ events });
const text = reply(await current.run());
assert.match(text, SAFE_FAILURE);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
}
});
test('history refuses a non-advancing cursor or a later event in a backward page', async () => {
for (const nextSeq of [50, 51]) {
const current = fixture({ read: (_options, call) => ({
events: [user(call === 1 ? 50 : nextSeq, PRIVATE_DETAIL)], hasMore: true,
}) });
const text = reply(await current.run('/history 5'));
assert.match(text, SAFE_FAILURE);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
assert.ok(current.calls.length <= 2);
}
});
test('backfill keeps the first tail snapshot even if the Host includes newly appended events', async () => {
const current = fixture({ read: (_options, call) => call === 1
? { events: [user(50, 'history_user_2')], hasMore: true }
: { events: [user(40, 'history_user_1'), user(60, PRIVATE_DETAIL)], hasMore: false }
});
const text = reply(await current.run());
assert.deepEqual(selectedMarkers(text), ['history_user_1', 'history_user_2']);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
assert.equal(current.calls.length, 2);
});
test('history returns a safe error for malformed pages and unsupported history handles', async () => {
for (const page of [null, {}, { events: PRIVATE_DETAIL }, { events: [null], hasMore: false }]) {
const current = fixture({ read: () => page });
const text = reply(await current.run());
assert.match(text, SAFE_FAILURE);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
}
const current = fixture();
delete current.session.readHistory;
assert.match(reply(await current.run()), SAFE_FAILURE);
});
test('history discards a fetched snapshot if the chat is rebound during the read', async () => {
const current = fixture({ read: () => {
current.bind('another-session');
return { events: [user(1, PRIVATE_DETAIL)], hasMore: false };
} });
const text = reply(await current.run());
assert.match(text, SAFE_FAILURE);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
});
test('workspace invalidation, missing Sessions and Host errors never reveal internal details', async () => {
for (const code of [
'workspace-session-stale', 'workspace-bot-not-found', 'session-not-found',
'harness-api-not-found', 'harness-timeout', 'unrecognized-host-error',
]) {
const current = fixture({ read: () => { throw Object.assign(new Error(PRIVATE_DETAIL), { code }); } });
const text = reply(await current.run());
assert.match(text, SAFE_FAILURE, code);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'), code);
}
});
test('a stale backfill discards every earlier page instead of returning partial private data', async () => {
const current = fixture({ read: (_options, call) => {
if (call === 1) return { events: [user(30, PRIVATE_DETAIL)], hasMore: true };
throw Object.assign(new Error(PRIVATE_DETAIL), { code: 'workspace-session-stale' });
} });
const text = reply(await current.run('/history 5'));
assert.equal(current.calls.length, 2);
assert.match(text, SAFE_FAILURE);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
});
test('history uses bounded read options and honors cancellation without exposing old data', async () => {
const controller = new AbortController();
const current = fixture({ read: (options) => {
assert.ok(options.signal instanceof AbortSignal);
if (options.timeoutMs !== undefined) {
assert.ok(options.timeoutMs > 0 && options.timeoutMs <= 10_000);
}
controller.abort(new Error(PRIVATE_DETAIL));
return { events: [user(1, PRIVATE_DETAIL)], hasMore: false };
} });
const text = reply(await current.run('/history', { signal: controller.signal }));
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
assert.match(text, SAFE_FAILURE);
});
test('an already cancelled history request does not query the Host', async () => {
const controller = new AbortController();
controller.abort(new Error(PRIVATE_DETAIL));
const current = fixture();
const text = reply(await current.run('/history', { signal: controller.signal }));
assert.equal(current.calls.length, 0);
assert.match(text, SAFE_FAILURE);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
});
test('history applies one ten-second deadline across backfill requests', async (t) => {
let now = 1_000;
t.mock.method(Date, 'now', () => now);
const current = fixture({ read: () => {
now += 10_001;
return { events: [user(1, PRIVATE_DETAIL)], hasMore: true };
} });
const text = reply(await current.run('/history 5'));
assert.equal(current.calls.length, 1);
assert.match(text, SAFE_FAILURE);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
});
test('history truncates long Unicode and code text within shared message and chunk budgets', async () => {
const events = Array.from({ length: 6 }, (_, index) => user(index + 1,
`history_user_${index + 1}\n\`\`\`js\n${'中文🙂\n'.repeat(600)}\n\`\`\``,
));
const current = fixture({ events });
const result = await current.run('/history 100');
const text = reply(result);
assert.deepEqual(selectedMarkers(text), [
'history_user_2', 'history_user_3', 'history_user_4', 'history_user_5', 'history_user_6',
]);
assert.match(text, /截断|truncat/iu);
assert.equal(text.isWellFormed(), true);
assert.ok(result.messages.every((part) => part.isWellFormed()));
assert.ok(text.length <= 3_000);
assert.ok(result.messages.length <= 3);
assert.ok([...text.matchAll(/中文/gu)].length <= 500,
'body text should be cut to the shared per-record budget, not merely split into more messages');
});
test('each history body has its own five-hundred-character bound', async () => {
const current = fixture({ events: [user(1, `${'Z'.repeat(499)}🙂${'Z'.repeat(2_000)}`)] });
const text = reply(await current.run('/history 1'));
assert.ok((text.match(/Z/gu) ?? []).length <= 500);
assert.match(text, /截断|truncat/iu);
assert.equal(text.isWellFormed(), true);
});
test('history English messages translate the command envelope without translating saved content', async () => {
const previousLanguage = getImHostLanguage();
setImHostLanguage('en');
try {
const current = fixture({ events: [user(1, 'Saved user text.')] });
const text = reply(await current.run());
assert.match(text, /history/iu);
assert.match(text, /user/iu);
assert.match(text, /Saved user text\./u);
assert.doesNotMatch(text, /[一-鿿]/u);
const invalid = reply(await current.run('/history 0'));
assert.match(invalid, /usage/iu);
assert.match(invalid, /\/history/u);
assert.doesNotMatch(invalid, /[一-鿿]/u);
} finally {
setImHostLanguage(previousLanguage);
}
});

110
test/history-i18n.test.mjs Normal file
View file

@ -0,0 +1,110 @@
import assert from 'node:assert/strict';
import { afterEach, beforeEach, test } from 'node:test';
import { runHistoryCommand } from '../src/channels/shared/history-command.mjs';
import { getImHostLanguage, setImHostLanguage, t } from '../src/channels/shared/i18n.mjs';
let previousLanguage;
beforeEach(() => {
previousLanguage = getImHostLanguage();
setImHostLanguage('en');
});
afterEach(() => setImHostLanguage(previousLanguage));
function entry(seq, type, data) {
return { event: { seq, type, data, surfaceOp: 'append' } };
}
function user(seq, content) {
return entry(seq, 'user/message', { source: { kind: 'user' }, content });
}
function assistant(seq, turn, content) {
return entry(seq, 'assistant/message', { turn, message: { content } });
}
function end(seq, turn) {
return entry(seq, 'turn/end', { turn, reason: { kind: 'completed' } });
}
function textBlock(text) {
return { type: 'text', text };
}
function run(text, { events = [], error, sessionId = 'history-test', ...options } = {}) {
return runHistoryCommand(text, {
workspaceSession() {
return {
async readHistory() {
if (error) throw error;
return { events, hasMore: false };
},
};
},
}, { sessionFor: () => sessionId }, 'direct:test', { isDirect: true, ...options });
}
test('/history English preview translates labels, truncation, and media placeholders', async () => {
const result = await run('/history', {
events: [
user(1, [textBlock('Earlier user message')]),
assistant(2, 1, [textBlock('Earlier assistant answer')]),
end(3, 1),
user(4, [textBlock('Recent user message'), { type: 'image' }, { type: 'file' }]),
assistant(5, 2, [textBlock('Long final answer. '.repeat(100))]),
end(6, 2),
],
});
assert.match(result.message, /^Session history \| history-test \| Recent messages: 3/);
assert.match(result.message, /1\. Assistant\nEarlier assistant answer/);
assert.match(result.message, /2\. User\nRecent user message\n\[Image\]\n\[File\]/);
assert.match(result.message, /3\. Assistant\nLong final answer\./);
assert.match(result.message, / \(truncated\)/);
assert.ok(result.message.endsWith('These are history records, not a new reply.'));
assert.doesNotMatch(result.message, /Earlier user message|[一-鿿]/);
assert.ok(result.messages.length <= 3);
});
test('/history English usage covers the indirect HISTORY_USAGE translation key', async () => {
for (const command of ['/history 0', '/history -1', '/history 1.5', '/history abc', '/history 1 2']) {
assert.equal((await run(command)).message, 'Usage: /history [count] (default 3, maximum 5)');
}
assert.equal(
t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
'/history [count] Preview recent messages (default 3, maximum 5)',
);
assert.equal(t('发送 /history 查看最近对话。'), 'Send /history to preview recent conversation messages.');
});
test('/history English validation and empty-history messages never fall back to Chinese', async () => {
assert.equal((await run('/history', { isDirect: false })).message, 'Please use /history in a direct chat with the bot.');
for (const options of [{ hasImages: true }, { hasFiles: true }]) {
assert.equal((await run('/history', options)).message, '/history supports text commands only. Remove images or files and try again.');
}
assert.equal((await run('/history', { sessionId: null })).message, 'This chat has no bound Session. Send a message or use /session to bind one first.');
assert.equal((await run('/history')).message, 'This Session has no history messages available to preview yet.');
const result = await run('/history', {
events: [assistant(1, 1, []), end(2, 1)],
});
assert.match(result.message, /1\. Assistant\nThis message has no text to preview\./);
assert.match(result.message, /Messages available to preview in this Session: 1\./);
assert.doesNotMatch(result.message, /[一-鿿]/);
});
test('/history English errors preserve safe messages and hide raw details', async () => {
const cases = [
[{ code: 'session-not-found' }, 'The Session bound to this chat no longer exists. Please bind a Session again.'],
[{ code: 'workspace-session-stale' }, 'The Session, workspace, or bot state has changed. Please run /history again.'],
[{ code: 'workspace-bot-not-found' }, 'The Session, workspace, or bot state has changed. Please run /history again.'],
[{ code: 'session-binding-changed' }, 'The Session, workspace, or bot state has changed. Please run /history again.'],
[{ code: 'harness-api-not-found' }, 'This Harness does not support reading Session history.'],
[{ name: 'AbortError' }, 'History reading was cancelled.'],
[{ name: 'TimeoutError' }, 'Reading history timed out. Please try again later.'],
[{ code: 'harness-timeout' }, 'Reading history timed out. Please try again later.'],
[{}, 'Unable to read Session history right now. Please try again later.'],
];
for (const [properties, expected] of cases) {
const error = Object.assign(new Error('private upstream history details'), properties);
assert.equal((await run('/history', { error })).message, expected);
}
});

View file

@ -238,12 +238,13 @@ test('an old session cannot be written back while RPC switches the bot workspace
assert.equal(existenceChecks, 0, 'stale sessions are rejected before asking Harness'); assert.equal(existenceChecks, 0, 'stale sessions are rejected before asking Harness');
}); });
test('an old workspace session handle cannot list, select, stop, or steer after a switch', async (t) => { test('an old workspace session handle cannot read history, list, select, stop, or steer after a switch', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t); const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load(); const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_session_controls'); await workspaces.ensure('bot_session_controls');
const targetCalls = []; const targetCalls = [];
const harness = { const harness = {
async readSessionHistory(...args) { targetCalls.push(['history', ...args]); },
async getSessionModels(...args) { targetCalls.push(['models', ...args]); }, async getSessionModels(...args) { targetCalls.push(['models', ...args]); },
async selectSessionModel(...args) { targetCalls.push(['select', ...args]); }, async selectSessionModel(...args) { targetCalls.push(['select', ...args]); },
async stopActiveTurn(...args) { targetCalls.push(['stop', ...args]); }, async stopActiveTurn(...args) { targetCalls.push(['stop', ...args]); },
@ -261,6 +262,7 @@ test('an old workspace session handle cannot list, select, stop, or steer after
await controller.updateWorkspace('bot_session_controls', alternateWorkspace); await controller.updateWorkspace('bot_session_controls', alternateWorkspace);
const control = { owner: {}, key: 'direct:one' }; const control = { owner: {}, key: 'direct:one' };
for (const operation of [ for (const operation of [
() => oldSession.readHistory(),
() => oldSession.models(), () => oldSession.models(),
() => oldSession.selectModel({ provider: 'provider', model: 'model' }), () => oldSession.selectModel({ provider: 'provider', model: 'model' }),
() => oldSession.stopActiveTurn(control), () => oldSession.stopActiveTurn(control),
@ -271,6 +273,33 @@ test('an old workspace session handle cannot list, select, stop, or steer after
assert.deepEqual(targetCalls, []); assert.deepEqual(targetCalls, []);
}); });
test('history results cannot escape a workspace change that happens while reading', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_history');
let finishRead;
const calls = [];
const harness = {
readSessionHistory(sessionId, options) {
calls.push({ sessionId, options });
return new Promise((resolve) => { finishRead = resolve; });
},
};
const state = { async clearSessions() {} };
const scope = createBotWorkspaceScope(harness, { botId: 'bot_history', workspaces, state });
const session = scope.harness.workspaceSession('history-session');
const options = { maxMessages: 50 };
const pending = session.readHistory(options);
const rejected = assert.rejects(pending, { code: WORKSPACE_SESSION_STALE });
const controller = createWorkspaceAwareController({
status() { return { bots: [{ botId: 'bot_history' }] }; },
}, { workspaces, stateFor: async () => state });
await controller.updateWorkspace('bot_history', alternateWorkspace);
finishRead({ events: [], hasMore: false });
await rejected;
assert.deepEqual(calls, [{ sessionId: 'history-session', options }]);
});
test('a control mutation that already started keeps its result across a workspace switch', async (t) => { test('a control mutation that already started keeps its result across a workspace switch', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t); const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load(); const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();