mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-08 23:13:17 +08:00
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:
parent
bf0e157cd4
commit
8c6c31a1b6
23 changed files with 1965 additions and 221 deletions
|
|
@ -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 / 修复
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 仅自己模式只接受自聊,指定联系人模式接受自聊和白名单私聊,开放响应模式接受所有私聊、已绑定账号自己发出的群聊消息,以及其他群成员的提及或回复。
|
||||||
|
|
|
||||||
201
docs/方案/Issue-62-九渠道会话历史预览方案.md
Normal file
201
docs/方案/Issue-62-九渠道会话历史预览方案.md
Normal 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 条正常历史消息,不包含查询命令;本轮未复测其他渠道。
|
||||||
422
lib/index.js
422
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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, {
|
||||||
|
|
|
||||||
|
|
@ -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)) },
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
193
src/channels/shared/history-command.mjs
Normal file
193
src/channels/shared/history-command.mjs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
|
|
|
||||||
|
|
@ -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 和标题'),
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
449
test/history-bridge.test.mjs
Normal file
449
test/history-bridge.test.mjs
Normal 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, []);
|
||||||
|
});
|
||||||
601
test/history-command.test.mjs
Normal file
601
test/history-command.test.mjs
Normal 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
110
test/history-i18n.test.mjs
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue