From 7c9be9fd584a0066515ab6f2c7b6c7442d0ffa00 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 2 Sep 2026 20:28:19 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=8F=92=E4=BB=B6=E9=80=82=E9=85=8D?= =?UTF-8?q?=E6=8C=87=E5=8D=97=20+=20=E5=85=B1=E7=94=A8=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E6=8F=90=E5=8F=96=EF=BC=88=E4=B8=BA=E6=8E=A5=E5=85=A5=E6=9B=B4?= =?UTF-8?q?=E5=A4=9A=E5=B9=B3=E5=8F=B0=E5=81=9A=E5=87=86=E5=A4=87=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两次适配(opencode、DeepSeek Harness)里的方法与坑此前散落在提交信息和 代码注释里,接第三个平台时要重新翻。这次固化成文档,并把与平台 SDK 无关的 逻辑提到共用模块。 ## docs/PLUGIN-GUIDE.md 八节:职责边界、必须实现的六件事、会话命名回写、平台会话快照上报、 平台差异对照表、踩过的坑(按排查成本降序)、新平台适配清单、共用模块清单。 三条设计原则贯穿全文,后面每一节都是它们的推论: 1. **平台原生信号才是真相来源**,不要求模型「记得」调工具 —— 因此不提供 request_permission(改挂权限钩子)、不要求模型主动回信(改在「一轮结束」 的平台信号上自动转发) 2. **插件代劳的转发不消耗配额** —— 因此这两类转发带 relay + relay_key 3. **平台命名优先** —— 因此创建会话时不传占位标题(那会掐掉平台自己的命名机制) 「踩过的坑」一节按排查成本排序,头一条是花了一下午的 followup() 参数形状。 ## 共用模块提取 `lib/inbox-format.js`(新):收件箱渲染与已读策略。三条规则各对应一次错误行为, 而它们与平台 SDK 无关: - 附件必须带 attachment_id(只说「有附件」模型无从下载) - 抄送人要显示(不显示模型以为是私信,回信时漏掉其他参与方) - 只标本次列出的、status=all 时不标(limit 之外的还没看过;把历史邮件标成已读 会让下一轮的新邮件混在里面认不出来) 顺带修好两处不一致:DSH 的 read_inbox 此前**完全没有标记已读**(每轮重复捞同一批), 且默认 status=all(同上);附件大小两边一个显示字节数一个显示 KB/MB。 `lib/workspace.js`:提到两侧共用。签名从 (workspace, fallbackKey) 改为 (workspace, fallback) —— 各平台的兜底不同:opencode 有插件启动时的 directory, DSH 只能落到 ~/.dsh/mail-sessions/<会话>(mailSessionFallback)。 opencode 侧此前是内联的三行判断,没有「目录不存在时不创建」与「拒绝相对路径」 这两条保护。 ## deploy/check-shared-libs.sh `lib/` 与 `test/` 下的共用文件必须逐字节相同,纳入 install.sh 门禁。 一侧改了另一侧没改,两个平台的行为就会悄悄分叉:同一封邮件在 opencode 那边 标了已读、在 DSH 那边没标,而两处代码看起来都「对」。这类分叉没有测试能发现, 只能靠 diff。 ## 文档同步 - PLAN.md §7.7 从「待做」改为已完成,补 7.7.1(工作目录归属)与 7.7.2(平台会话快照)两节,记录根因而非只记改法 - API.md 加「心跳与平台会话快照」章节;SSE 章节补 new_mail 与 permission_decision 的 payload 说明(to_workspace 的语义、relay_key 的用途) - PHASE7-REMAINING.md 移除已完成的 7.7,新增「每平台可用模型范围」的进展 (repo 层已就绪,handler/插件/前端待做) - README 文档索引与项目结构 验证:两插件共 136 个测试通过,同源校验通过,Go/前端全绿; 端到端发信 → DSH 用新的 read_inbox 渲染读取 → 自动回信 213 字节。 --- README.md | 9 +- deploy/check-shared-libs.sh | 23 + deploy/install.sh | 5 + docs/API.md | 50 ++- docs/PHASE7-REMAINING.md | 30 +- docs/PLAN.md | 103 ++++- docs/PLUGIN-GUIDE.md | 418 ++++++++++++++++++ plugins/dsh-mail-bridge/lib/inbox-format.d.ts | 7 + plugins/dsh-mail-bridge/lib/inbox-format.js | 90 ++++ plugins/dsh-mail-bridge/lib/workspace.d.ts | 3 +- plugins/dsh-mail-bridge/lib/workspace.js | 30 +- plugins/dsh-mail-bridge/src/index.ts | 46 +- .../test/inbox-format.test.mjs | 176 ++++++++ .../dsh-mail-bridge/test/workspace.test.mjs | 42 +- plugins/opencode-mail-bridge/index.js | 77 ++-- .../opencode-mail-bridge/lib/inbox-format.js | 90 ++++ plugins/opencode-mail-bridge/lib/workspace.js | 77 ++++ plugins/opencode-mail-bridge/package.json | 2 +- .../test/inbox-format.test.mjs | 176 ++++++++ .../test/workspace.test.mjs | 128 ++++++ 20 files changed, 1482 insertions(+), 100 deletions(-) create mode 100755 deploy/check-shared-libs.sh create mode 100644 docs/PLUGIN-GUIDE.md create mode 100644 plugins/dsh-mail-bridge/lib/inbox-format.d.ts create mode 100644 plugins/dsh-mail-bridge/lib/inbox-format.js create mode 100644 plugins/dsh-mail-bridge/test/inbox-format.test.mjs create mode 100644 plugins/opencode-mail-bridge/lib/inbox-format.js create mode 100644 plugins/opencode-mail-bridge/lib/workspace.js create mode 100644 plugins/opencode-mail-bridge/test/inbox-format.test.mjs create mode 100644 plugins/opencode-mail-bridge/test/workspace.test.mjs diff --git a/README.md b/README.md index c702e6e..78a0407 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,8 @@ DATABASE_URL= # 留空 = 内置 SQLit agentmail/ ├── docs/ │ ├── PLAN.md # 分阶段实施计划 -│ └── MVP-SPEC.md # MVP 技术规格书 +│ ├── MVP-SPEC.md # MVP 技术规格书 +│ └── PLUGIN-GUIDE.md # Agent 平台插件适配指南 ├── gateway/ # 后端(Go,单二进制) │ ├── cmd/server/ # 入口与路由表 │ └── internal/ @@ -139,8 +140,9 @@ agentmail/ │ ├── middleware/ # Agent / 用户双认证 │ ├── sse/ # 事件推送(按收件人分流) │ └── static/ # go:embed 的前端产物 -├── plugins/ -│ └── opencode-mail-bridge/ # opencode 桥接插件 +├── plugins/ # 各平台桥接插件(lib/ 下的纯函数模块逐字节共用) +│ ├── opencode-mail-bridge/ # opencode +│ └── dsh-mail-bridge/ # DeepSeek Harness(Cordis) ├── web/ # 前端(React + Vite + Tailwind) └── deploy/ # systemd 单元 + 安装脚本 ``` @@ -250,5 +252,6 @@ Agent 干完活可以在正文里**提议**改成更贴切的名字,但改不 ## 文档 - [WebAPI](docs/API.md) — 接口清单、认证方式、错误约定 +- [插件适配指南](docs/PLUGIN-GUIDE.md) — 接一个新 Agent 平台要实现什么,以及踩过的坑 - [实施计划](docs/PLAN.md) — 分阶段任务与验收标准 - [MVP 技术规格书](docs/MVP-SPEC.md) — 数据模型与接口细节 diff --git a/deploy/check-shared-libs.sh b/deploy/check-shared-libs.sh new file mode 100755 index 0000000..325a218 --- /dev/null +++ b/deploy/check-shared-libs.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# 共用模块必须逐字节相同 —— 见 docs/PLUGIN-GUIDE.md §8。 +# +# 一侧改了另一侧没改,两个平台的行为就会悄悄分叉:同一封邮件在 opencode 那边 +# 标了已读、在 DSH 那边没标,而两处代码看起来都"对"。 +set -euo pipefail +A=plugins/opencode-mail-bridge +B=plugins/dsh-mail-bridge +fail=0 +for f in relay-dedup inbox-format session-snapshot workspace; do + if ! diff -q "$A/lib/$f.js" "$B/lib/$f.js" >/dev/null 2>&1; then + echo "共用模块已分叉:lib/$f.js" >&2 + diff "$A/lib/$f.js" "$B/lib/$f.js" | head -20 >&2 + fail=1 + fi +done +for f in inbox-format session-snapshot workspace; do + if ! diff -q "$A/test/$f.test.mjs" "$B/test/$f.test.mjs" >/dev/null 2>&1; then + echo "共用测试已分叉:test/$f.test.mjs" >&2 + fail=1 + fi +done +[[ $fail -eq 0 ]] && echo " 共用模块两侧同源" || exit 1 diff --git a/deploy/install.sh b/deploy/install.sh index 953c722..dff9fe1 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -18,6 +18,11 @@ echo "==> 构建前端" ( cd "$REPO/web" && npm ci --no-audit --no-fund 2>/dev/null || npm install --no-audit --no-fund ) ( cd "$REPO/web" && npm run typecheck && npm test && npm run build ) +echo "==> 校验插件共用模块同源" +# lib/ 下的纯函数模块在两个插件里逐字节相同(见 docs/PLUGIN-GUIDE.md §8)。 +# 一侧改了另一侧没改,两个平台的行为就会悄悄分叉。 +"$REPO/deploy/check-shared-libs.sh" + # 插件的纯函数测试(自动转发去重等)。 # 插件不参与构建产物,但它的逆行为会直接变成用户收件箱里的重复邮件, # 因此也纳入部署前的门禁。zod 已在 node_modules 里就不重装。 diff --git a/docs/API.md b/docs/API.md index 3be2b81..0f91f54 100644 --- a/docs/API.md +++ b/docs/API.md @@ -298,7 +298,7 @@ PUT /admin/quotas/{name} 设默认预算 {default_rounds} ``` POST /agent/register 注册(Bearer 或 body.secret) -POST /agent/heartbeat 心跳,响应含 pending_mails 与 quota +POST /agent/heartbeat 心跳,响应含 pending_mails 与 stats;可带平台会话快照 POST /mail/send 发信(扣配额) GET /mail/inbox 收件箱(含附件清单) POST /mail/read 批量标记已读(不给 mail_ids = 全部标掉) @@ -312,6 +312,36 @@ POST /sessions/{id}/sync 回写平台侧生成的会话标题/slug 发信与转发扣**本任务(会话)的往返预算**。 只限制主动发信,不限制收信 —— 卡住收信只会让邮件凭空消失。 +### 心跳与平台会话快照 + +```bash +# 最简形式:保活 +curl -X POST {host}/api/v1/agent/heartbeat -H "Authorization: Bearer $AGENT_KEY" + +# 带平台会话快照(插件应当这样做) +curl -X POST {host}/api/v1/agent/heartbeat -H "Authorization: Bearer $AGENT_KEY" \ + -d '{"platform_sessions":[ + {"platform_id":"ses_abc","workspace":"/home/program/agentmail", + "slug":"witty-planet","title":"重构导入路径", + "mail_driven":false,"updated_at":"2026-09-02T11:41:16.744Z"} + ]}' +``` + +**心跳不能省。** Gateway 靠 `last_seen` 判在线,不发心跳的 Agent 会被当成离线。 +间隔 30 秒。 + +`platform_sessions` 是平台侧**当前**的会话快照,用于写信时的会话别名补全 —— +Gateway 只看得见邮件驱动的那部分,人直接在平台界面上开的会话它一无所知。 + +- **整表替换**:平台侧删掉的会话必须从候选里消失(session 位是三态语义, + 指向不存在的会话会直接 404) +- **省略该字段与传空数组语义不同**:拉不到列表时**省略**(保留服务端现有镜像); + 空数组的语义是「平台侧确实一条会话都没有」,会把镜像抹掉 +- 单次上限 200 条,按最近活跃排序后截断 + +字段要求见 [插件适配指南](PLUGIN-GUIDE.md#四平台会话快照上报)(含 subagent 过滤、 +slug 去重等规则)。 + ### 标记已读 ```bash @@ -370,6 +400,24 @@ GET /events/stream 事件类型:`connected`、`new_mail`、`permission_decision`、`session_update`、`session_archived`、`agent_online`。 +`new_mail` 的 payload: + +```json +{ + "mail_id": "...", "session_id": "...", "from_name": "admin", + "subject": "...", "mail_type": "normal", "role": "to", + "to_workspace": "/home/program/agentmail" +} +``` + +`to_workspace` 是**收件方那个地址的 path 位**(抄送方拿到的是自己那个地址的, +不是主收件人的)。插件应当用它作为会话的工作目录 —— 自己拼一个临时目录会让 +平台按 cwd 分组时把所有邮件会话归进「未分组」。 + +`permission_decision` 的 payload 含 `relay_key`(上游权限询问的 id)与 +`session_id`:前者让插件对上平台侧那条待决询问,后者是插件重启丢了内存映射时 +的兜底 —— 那种情况下决策会被当作一封普通通知投进会话。 + 按收件人分流:Agent 凭证订阅 Agent 通道,用户凭证订阅该用户的通道。 不能只报 `X-Agent-Name` 而不给凭证 —— 那等于任何人报个名字就能读走别人的新邮件通知。 diff --git a/docs/PHASE7-REMAINING.md b/docs/PHASE7-REMAINING.md index ced2d1e..aae8d00 100644 --- a/docs/PHASE7-REMAINING.md +++ b/docs/PHASE7-REMAINING.md @@ -1,11 +1,8 @@ # Phase 7 剩余项与已知生产缺陷追踪 -## 无法立即推进(缺 SDK/基础设施) +7.7 DSH 插件已完成(见 `docs/PLUGIN-GUIDE.md` 与 PLAN.md §7.7)。 -### 7.7 DSH 插件(dsh-mail-bridge) -- 基于 DeepSeek Harness SDK(非 opencode),需该 SDK 先装好 -- 与 opencode-mail-bridge 共享同一套 Gateway API -- 利用 DeepSeek Harness 的 PreToolUse / SessionStart 等钩子 +## 无法立即推进(缺基础设施) ### 7.8 跨主机 Agent 发现 - Gateway + Registry 拆分为独立服务 @@ -42,3 +39,26 @@ SSE 连接的 handler 在首次连接时从缓冲区头部开始(客户端传 ### P2 — 深色主题 **现状**:只有浅色主题,深夜使用刺眼。 **范围**:tailwind dark: 前缀覆盖主要组件。 + +### P1 — 每平台可用模型范围(进行中) + +**需求**:配置页面为每个 Agent 平台划定「邮箱调用场景下可用的模型范围」, +端侧插件按范围**逐个降级尝试**,全部失败时把失败原因封装成邮件回复。 +选择而非手打模型名 —— 平台上报目录,管理员勾选。 + +**已完成**: +- `agent_model_catalog`(平台上报的目录)+ `agent_allowed_models`(管理员的选择) + 两张表,两份 schema +- `repo/models_scope.go`:`ReplaceModelCatalog` / `ListModelCatalog` / + `ListAllowedModels` / `SetAllowedModels` + +**为什么分两张表**:模型会从平台目录里消失(换了 provider 配置、上游临时下线), +整行删掉会连带把管理员的选择也删了,模型回来还得重配一遍。分开存之后 +「选了什么」是持久的,目录只决定「这一项现在是否可用」。 + +**待做**: +- [ ] handler + 路由:注册时接收目录、管理员读写选择 +- [ ] 插件在注册时上报目录(opencode 有 `/config/providers`,DSH 有 `llm.listModels`) +- [ ] 插件按 rank 顺序尝试,记录每次失败的原因 +- [ ] 全部失败 → 发一封说明失败原因的邮件(走免配额通道) +- [ ] 前端配置页:复选框 + 拖拽排序(rank 即优先级) diff --git a/docs/PLAN.md b/docs/PLAN.md index 13d8005..5a97b66 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1023,11 +1023,102 @@ execute: { 业务代码不感知 Cookie 与密钥的差异,`src/api/` 可整体抽成 SDK - [x] `docs/API.md`:完整接口清单、三类调用者的认证边界、错误码约定 -### 7.7 DeepSeek Harness 插件 +### 7.7 DeepSeek Harness 插件(已完成) -- [ ] 基于 Cordis 框架开发 `dsh-mail-bridge` -- [ ] 利用 `PreToolUse`、`SessionStart` 钩子 -- [ ] 与 Pi 插件共享相同 Gateway API +`plugins/dsh-mail-bridge/`,Cordis 插件框架 + TypeScript。 +适配方法与踩坑记录已固化为 **[`docs/PLUGIN-GUIDE.md`](PLUGIN-GUIDE.md)**, +后续接入新平台按那份清单走。 + +- [x] Cordis 插件骨架:`export const inject` + `export const name` + `apply(ctx, config)` + - 没有 `inject` 时 `ctx.tools` / `ctx.agents` 根本不存在 + (报 `cannot get property "tools" without inject`) + - 但**可选服务不能写进 `inject`** —— 那是硬依赖,服务没挂载时整个插件不启动。 + `sessionQuery` 用 `ctx.get()` 取:会话上报只是补全体验, + 不该能把邮件投递整体拘死 +- [x] 四个工具经 `defineTool` 注册(`send_mail` / `read_inbox` / + `upload_attachment` / `download_attachment`) + - 直接给 `ctx.tools.register` 原始对象会报 + `parameters must be lossless JSON before schema projection` + - 还必须声明 `output: { schema, render }` +- [x] `ctx.agents.create()` 建会话 + `agent.followup()` 投递消息 + - **`followup()` 要完整的 `UserMessage`(`content` + `source`)**, + 不是 opencode 那种 parts 数组。传错不当场报错,而是在 agent-loop 的 + `preStep` 里抛 `Cannot read properties of undefined (reading 'kind')` —— + 错误落在框架内部,不指向调用点,turn 一 start 就 end、模型请求根本不发出去。 + 这个坑花了一下午,已用 `lib/message.js` + 测试钉住 + - `setup` 留空:base bundle 已注册 agent-loop / llm / tools, + `agentOptions: { provider, model }` 就够了;挂 preset 反而多余 +- [x] `agent/status` → `idle` 时自动转发最后一条 assistant 消息 + (对应 opencode 的 `session.idle`),复用 `lib/relay-dedup.js` 让位于 + 模型的主动回信,带 `relay: 'summary'` 走免配额通道 +- [x] `approval/request` 钩子把权限询问转成邮件问人 + - 与 opencode 的关键差异:那边的 `permission.ask` 是**同步**钩子, + 卡住会挂死整个请求,只能「转出去 + 立即返回 ask」; + DSH 这边是**异步 waterfall**,返回 `Promise`,可以真的等人 + - 拆插件时未决询问一律 fail closed(`unavailable`), + 否则 DSH 侧那些 `await` 永不返回 + - DSH 不给询问发 id,用 `会话:工具:callId` 作幂等键 +- [x] 会话别名由**模型生成的标题**派生(与「别名复用平台命名」的既定决策一致) + - `slugFromTitle` 保留中文(转拼音后既不好读也不好打, + 而三维地址按最后一个 `.` 切分,中文不影响解析) + - 但必须去掉 `.` `@` `/` 等寻址分隔符 —— 留在别名里会让它自己被解析器切开 + - fallback 占位标题不派生别名:DSH 在模型生成真标题前会先落一个 + 内容是「用户第一句话截断」的标题,而那句话是插件自己拼的提示词 +- [x] **补上心跳** —— 之前完全没有,Gateway 靠 `last_seen` 判在线, + 一直靠注册那一次撑着 +- [x] 与 opencode 插件共用 `lib/` 下的纯函数模块(逐字节相同) + +### 7.7.1 工作目录归属(修复) + +**症状**:dsh 指定工作目录完全失效,所有会话落进「未分组」。 + +**根因两层**: + +1. 插件建会话时的 cwd 是自己拼的 `~/.dsh/mail-sessions/mail-` —— + 每封邮件一个全新的空目录。平台按 cwd 给会话分组,于是所有邮件会话 + 既不属于任何项目、彼此也不同组 +2. Gateway 从来没把地址的 path 位发给插件:`notifyRecipients` 的 payload + 只有 `mail_id`/`session_id`/`from_name`/`subject`,`to_workspace` 虽然入库了 + 却不在 SSE 事件里 —— 插件即使想用也拿不到 + +- [x] SSE `new_mail` 事件加 `to_workspace`。**每个收件方拿到自己那个地址的 path**, + 不是主收件人的 —— 抄送给 `opencode@/a` 与主发给 `dsh@/b` 是两个工作区 +- [x] 两个插件的 cwd 都改为取寻址的 path 位(共用 `lib/workspace.js`) +- [x] 不存在的目录**不创建**而是回退到兜底目录:一个笔误 + (`/home/porgram/x`)不该在磁盘上落下真目录,Agent 会在里面一无所获地干活 +- [x] 拒绝相对路径:cwd 的相对基准是 harness 进程的启动目录,systemd 下通常是 `/` + +### 7.7.2 平台会话快照上报(新增) + +**症状**:会话别名列不出工作区下的历史会话,无法选择。 + +人直接在平台界面上开的会话,Gateway 一无所知;而邮件驱动的那些也因为 +`workspace` 没存在会话上(只在 `mails.to_workspace`,且 Agent 回信的 +`from_workspace` 填的是 Agent 名而不是路径)而匹配不上。 + +- [x] `sessions.workspace` 新列,`CreateSession` 从地址的 path 位带入 +- [x] `agent_platform_sessions` 镜像表 + 心跳携带 `platform_sessions` +- [x] **插件上报而非 Gateway 反向拉取**:当前架构是单向的(Agent 持密钥主动连 + Gateway,Gateway 从不外呼),反向拉取需要它保存各平台的地址与凭证, + 那是另一套信任模型 +- [x] 与 `sessions` 表**分开存**:镜像里是别人家的会话,id 属于平台的 id 空间, + 没有本侧的 owner/预算/邮件。混进 `sessions` 会让每一处「按会话鉴权」 + 都要先判断这条到底是不是真的本侧会话 +- [x] **整表替换而非增量合并**:平台侧删掉的会话必须从候选里消失 —— + session 位是三态语义,指向不存在的会话直接 404 +- [x] **`platform_sessions` 省略与传空数组语义不同**:拉不到列表时省略该字段 + (保留镜像),传空数组的语义是「平台侧确实一条会话都没有」 +- [x] **subagent 子会话不上报**:实测 DSH 一次列出 49 条子会话,标题就是派活的 + 提示词前缀(九条都叫 `You are auditing ONE file`),slug 全撞名; + 它们是父 agent 内部的工作单元,人往里发邮件毫无意义 +- [x] **slug 撞名只留最近那条**:服务端只能取其中一条,上报同名项只会让补全里 + 出现几个点哪个都不确定的候选 +- [x] `SuggestSessionCandidates` 取代 `SuggestSessionsFor`:以会话自己的 + `workspace` 为权威,历史会话(该列为空)回退到 mails 反推 —— + 升级后老会话不该从候选列表里消失 +- [x] 补全候选带标题与来源:`suggestions` 保留纯字符串数组(不打破已部署的前端 + 与第三方客户端),新增同序的 `candidates`;过滤时标题也参与匹配 —— + 人记得的是「缓存选型」而不是 `brisk-harbor` 这种随机短名 ### 7.8 跨主机 Agent 发现 @@ -1199,7 +1290,9 @@ MVP 计划(Phase 1-6)已全部落地并在 systemd 部署态实测通过。 - [x] 插件自动转发平台原生权限询问与最终总结(不消耗配额) - [x] 配额下沉到会话:写信时给、对话页里随时改 - [x] 工作列表卡片视图(中间栏,与列表视图切换) -- [ ] DeepSeek Harness 插件(`dsh-mail-bridge`) +- [x] DeepSeek Harness 插件(`dsh-mail-bridge`) +- [x] 平台会话快照同步:工作区下的历史会话可在写信时选中 +- [x] 插件适配方法固化为 `docs/PLUGIN-GUIDE.md` - [ ] 跨主机 Agent 发现(Gateway + Registry 拆分) ### 7.10 窄屏适配(已完成) diff --git a/docs/PLUGIN-GUIDE.md b/docs/PLUGIN-GUIDE.md new file mode 100644 index 0000000..226daef --- /dev/null +++ b/docs/PLUGIN-GUIDE.md @@ -0,0 +1,418 @@ +# Agent 平台插件适配指南 + +把一个新的 Agent 平台接进 AgentMail 需要写一个**桥接插件**。这份文档描述插件的 +职责边界、必须实现的六件事,以及两次真实适配(opencode、DeepSeek Harness)里 +踩过的坑。 + +现有实现可直接对照: + +| 插件 | 平台 | 框架 | 语言 | +|---|---|---|---| +| `plugins/opencode-mail-bridge/` | opencode | `@opencode-ai/plugin` | JavaScript | +| `plugins/dsh-mail-bridge/` | DeepSeek Harness | Cordis | TypeScript | + +--- + +## 一、插件的职责 + +插件是**平台与 Gateway 之间的翻译层**,只做搬运,不做决策。 + +``` + SSE (new_mail / permission_decision) + AgentMail ─────────────────────────────────────────▶ 插件 ──▶ 平台会话 + Gateway ◀───────────────────────────────────────── + HTTP (register / heartbeat / mail.send / …) +``` + +三条设计原则贯穿全文,先说清楚,后面每一节都是它们的推论: + +### 原则一:平台原生信号才是真相来源,不要求模型「记得」调工具 + +模型可能忘了调,也可能在不需要时乱调。真正被平台拦下的那次权限询问、 +模型真正说完的那段话,都是平台自己知道的事实。 + +**推论**:不提供 `request_permission` 工具(改为挂 `permission.ask` / `approval/request` 钩子), +不要求模型主动调 `send_mail` 回信(改为在「一轮结束」的平台信号上自动转发)。 + +### 原则二:插件代劳的转发不消耗配额 + +配额约束的是模型的自主发信。插件把平台原生的权限询问与最终总结搬到邮件里, +对它收费会导致配额用尽时 Agent 连交代都做不了。 + +**推论**:这两类转发带 `relay` + `relay_key`,走服务端的免配额通道。 + +### 原则三:平台命名优先,不另造一套 + +各平台本来就会由模型为会话生成摘要标题与短标识。平台那边叫什么, +AgentMail 这边的 `session_alias` 就叫什么。 + +**推论**:创建会话时**不要**传占位标题(那会掐掉平台自己的命名机制), +标题生成后通过 `POST /sessions/{id}/sync` 回写。 + +--- + +## 二、必须实现的六件事 + +### 1. 注册与心跳 + +``` +POST /api/v1/agent/register { name, platform, workspaces: [] } +POST /api/v1/agent/heartbeat { platform_sessions?: [...] } +``` + +认证用 `Authorization: Bearer `。密钥来源按优先级: + +1. 环境变量(systemd 部署走这条) +2. `~/.agentmail/agent.key` —— 首次启动时**本地生成**并打印到日志 + +**为什么是登记式而不是服务端签发**:密钥全文只从客户端流向服务器一次。 +插件生成后打印出来,管理员在后台「Agent 密钥」页登记即可, +不需要把密钥从服务器反向传给客户端。 + +登记接口的字段名是 **`key_token`**(不是 `key`)。传错服务端会静默生成一个 +随机 token 且全文只回一次 —— 这个坑踩过。 + +**心跳不能省。** Gateway 靠 `last_seen` 判在线,不发心跳的 Agent 会被当成离线 +(DSH 插件最初就漏了心跳,靠注册那一次撑着)。间隔 30 秒。 + +### 2. SSE 订阅 + +``` +GET /api/v1/events/stream +``` + +关心两个事件: + +| 事件 | 处理 | +|---|---| +| `new_mail` | 投递到平台会话(新建或续谈) | +| `permission_decision` | 回答之前挂起的权限询问 | + +`new_mail` 的 payload: + +```json +{ + "mail_id": "...", "session_id": "...", "from_name": "admin", + "subject": "...", "mail_type": "normal", "role": "to", + "to_workspace": "/home/program/agentmail" +} +``` + +`to_workspace` 是**收件方那个地址的 path 位**(抄送方拿到的是自己那个地址的), +见下一节。 + +服务端支持 `Last-Event-ID` 补投:断线重连时带上它,能取回断线期间的事件。 +首次连接不传该头(否则会收到一批已处理过的旧事件)。 + +### 3. 工作目录:必须用寻址里的 path 位 + +三维地址 `name@path.session` 的 `path` 就是「希望它在哪个工作目录干活」。 + +```js +// 正确 +const { cwd, grouped } = resolveWorkspaceCwd(data.to_workspace, sessionId); + +// 错误:每封邮件一个新的临时目录 +const cwd = join(homedir(), '.dsh', 'mail-sessions', sessionId); +``` + +**这是踩过最贵的坑之一。** 平台按 cwd 给会话分组,用自己拼的临时目录会让所有 +邮件会话既不属于任何项目、彼此也不同组 —— 界面上全落进「未分组」。 + +`lib/workspace.js` 是共用实现,三条规则: + +- 目录**已存在**才用,不存在时回退到兜底目录而**不创建** + (一个笔误 `/home/porgram/x` 不该在磁盘上落下真目录,Agent 会在里面一无所获地干活) +- 拒绝相对路径(cwd 的相对基准是 harness 进程的启动目录,systemd 下通常是 `/`) +- `path` 为空(地址写成 `dsh` 而不带 `@/path`)时用兜底目录 + +### 4. 六个工具 + +| 工具 | 说明 | +|---|---| +| `send_mail` | 主动发信。三维地址、抄送、`reply_to`、附件 | +| `read_inbox` | 读收件箱,**顺便标记已读** | +| `forward_mail` | 转发(可选,opencode 有 / DSH 暂无) | +| `upload_attachment` | 本地文件 → `attachment_id` | +| `download_attachment` | `attachment_id` → 本地文件 | +| `connect_to_server` | 登记密钥并注册(可选,方便首次接入) | + +**不提供 `request_permission`** —— 见原则一。 + +`read_inbox` 的渲染与已读策略放在共用的 `lib/inbox-format.js`, +它与平台 SDK 无关,各平台必须一致。三条规则各对应一次错误行为: + +- **附件必须带 `attachment_id`**:只说「有附件」模型就无从下载 +- **抄送人要显示**:不显示的话模型以为这是私信,回信时漏掉其他参与方 +- **只标本次列出的那些**,且 `status=all` 时不标 + (`limit` 之外的还没看过;把历史邮件标成已读会让下一轮的新邮件混在里面认不出来) + +### 5. 自动转发最终总结 + +在平台的「一轮结束」信号上,取最后一条 assistant 消息的**文本块**发回去。 + +| 平台 | 信号 | +|---|---| +| opencode | `session.idle` 事件 | +| DSH | `agent/status` → `idle` | + +三个必须处理的细节: + +- **只取 `type === 'text'` 的块。** reasoning 是思考过程,不该出现在邮件里。 +- **让位于模型的主动发信。** 模型自己调过 `send_mail` 回这条线索时不再自动转发, + 否则同一件事发两封(生产里真实发生过:311 字节 + 342 字节各一封, + 其中只有一封带附件)。共用实现在 `lib/relay-dedup.js`。 +- **带 `relay: 'summary'` + `relay_key`** 走免配额通道。`relay_key` 要是一个 + 平台侧的稳定 id(消息 id / 事件序号),模型伪造不出来 —— 它保证同一条消息 + 不被转两次。 + +去重靠 `explicitSends`(进程内记录本轮模型主动发过的信)**而不是** `relay_key`: +后者保证「同一条消息不转两次」,管不了「模型已经自己发过了」。 + +### 6. 权限询问转邮件 + +挂平台的权限钩子,把询问转成一封邮件问人。 + +| 平台 | 钩子 | 能否等人 | +|---|---|---| +| opencode | `permission.ask(input, output)` | **不能** —— 同步钩子,卡住会挂死整个请求 | +| DSH | `approval/request` waterfall | **能** —— 返回 `Promise` | + +opencode 那边只能「转出去 + 立即返回 `ask`」,人类决策通过 SSE 回来后再用 SDK +回复那条 permission;DSH 这边可以真的 `await` 到人回答。 + +两个共同点: + +- **转不出去就让位**(`return next()` 或保持 `ask`),别让平台挂在那儿等一个 + 永远不会来的回答 —— 本地 UI 还能接管 +- **拆插件时未决询问一律 fail closed**,否则平台侧那些 `await` 永不返回 + +`relay_key` 用平台的权限 id(DSH 不发 id,用 `会话:工具:callId` 拼)。 +服务端会随决策事件把它回传,因此插件重启丢了内存映射也能续上。 + +--- + +## 三、会话命名回写 + +``` +POST /api/v1/sessions/{id}/sync { alias?, title? } +``` + +- **`alias`** 是可寻址的短标识,写入 `session_alias` +- **`title`** 是模型生成的摘要,写入 `subject` + +| 平台 | alias 来源 | +|---|---| +| opencode | `session.slug`(创建时就有,如 `witty-planet`) | +| DSH | 由模型标题派生(`slugFromTitle`) | + +派生 slug 时**必须去掉寻址分隔符**(`.` `@` `/`)—— 留在别名里会让它自己被 +解析器切开,填进去的地址指向一个完全不同的目标。中文可以保留: +三维地址按最后一个 `.` 切分,中文不影响解析,而转拼音后既不好读也不好打。 + +服务端撞名时自动追加 `-2`/`-3`,因此同步永不失败。人工改过的别名 +(`alias_source = 'manual'`)不会被平台同步覆盖。 + +--- + +## 四、平台会话快照上报 + +写信时想续谈某条会话,得先知道那个工作区下有哪些会话可续。Gateway 只看得见 +邮件驱动的那部分 —— 人直接在平台界面上开的会话它一无所知。 + +插件在心跳里带上快照: + +```json +{ + "platform_sessions": [ + { "platform_id": "ses_abc", "workspace": "/home/program/agentmail", + "slug": "witty-planet", "title": "重构导入路径", + "mail_driven": false, "updated_at": "2026-09-02T11:41:16.744Z" } + ] +} +``` + +**为什么是插件上报而不是 Gateway 反向拉取**:当前架构是单向的(Agent 持密钥 +主动连 Gateway,Gateway 从不外呼)。反向拉取需要 Gateway 保存各平台的地址与 +凭证,那是另一套信任模型。代价是插件没运行时同步不了 —— 但插件没运行时邮件 +本来也投不进去。 + +共用实现 `lib/session-snapshot.js`。四条规则: + +- **无 `slug` 的会话不报**:slug 是填进 session 位的值,没有它这一项在补全里 + 点下去只能得到一个空的 session 段 +- **subagent 子会话不报**:它们是父 agent 内部的工作单元,人往里发邮件毫无意义。 + 实测 DSH 一次列出 49 条子会话,标题就是派活的提示词前缀 + (九条都叫 `You are auditing ONE file`),slug 全撞名 +- **slug 撞名只留最近那条**:服务端只能取其中一条,上报同名项只会让补全里出现 + 几个点哪个都不确定的候选 +- **按最近活跃排序并截断到 200 条**:上千个候选对人没有意义 + +**`platform_sessions` 省略与传空数组语义不同。** 拉不到列表时**省略该字段** +(保留服务端现有镜像);传空数组的语义是「平台侧确实一条会话都没有」, +会把镜像抹掉。 + +--- + +## 五、平台差异对照 + +| 关注点 | opencode | DeepSeek Harness | +|---|---|---| +| 插件形态 | `export default async function(input)` | Cordis:`export const inject` + `apply(ctx, config)` | +| 建会话 | `client.session.create({ query: { directory } })` | `ctx.agents.create({ sessionId, meta: { cwd }, agentOptions })` | +| 投递消息 | `client.session.promptAsync({ parts })` | `agent.followup(UserMessage)` | +| 工具定义 | zod schema | `defineTool()` + spec 格式参数 | +| 一轮结束 | `session.idle` 事件 | `agent/status` → `idle` | +| 权限钩子 | `permission.ask`(同步,不能等) | `approval/request`(异步 waterfall,能等) | +| 会话列表 | `client.session.list()` | `ctx.sessionQuery.listSessions()` | +| 别名来源 | `session.slug` | 模型标题派生 | +| 日志可见性 | `console.error` | `console.error`(`ctx.logger` 不进 journalctl) | + +--- + +## 六、踩过的坑 + +按「排查成本」降序。新接平台时先扫一遍这一节。 + +### `followup()` 的参数形状(花了一下午) + +DSH 的 `agent.followup(message)` 要完整的 `UserMessage`: + +```ts +agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } }) +``` + +照抄 opencode 的 parts 数组 `[{ type: 'text', text }]` 不会当场报错 —— +agent-loop 会一路走到 `preStep` 里读 `message.source.kind`,然后抛 +`Cannot read properties of undefined (reading 'kind')`。错误落在框架内部, +既不指向调用点也不说是哪个字段,turn 一 start 就 end、模型请求根本不发出去。 + +**教训**:这类「错了不当场报错、只在深处炸一个无关错误」的约定必须用测试钉住。 +`lib/message.js` + `test/message.test.mjs` 就是为此存在的。 + +### opencode 插件入口只能有 `default` 一个导出 + +opencode 用 `Object.values(mod)` 把**每个导出**都当插件工厂检查 +(反编译确认)。入口多导出一个 Map 就报 `Plugin export is not a function`, +插件静默失效、邮件全投不进去。 + +**因此所有可测试的逻辑必须放 `lib/` 子模块**,入口只 `export default`。 +`test/auto-relay.test.mjs` 里有一条断言钉住这一点。 + +### Cordis 插件必须导出 `inject` + +没有它 `ctx.tools` / `ctx.agents` 根本不存在(报 +`cannot get property "tools" without inject`)。 + +但**不要把可选服务写进 `inject`** —— 那是硬依赖,服务没挂载时整个插件不启动。 +DSH 的 `sessionQuery` 用 `ctx.get('sessionQuery')` 取:会话上报只是补全体验, +不该能把邮件投递整体拘死。 + +### DSH 工具必须经 `defineTool` + +直接给 `ctx.tools.register` 原始对象会报 +`parameters must be lossless JSON before schema projection`。 +`defineTool`(来自 `@deepseek-ai/dsh-tools`,不在 npm,运行时从 DSH 的 +node_modules 解析)负责把 spec 格式的 `parameters` 转成 JSON Schema 并在 +`execute` 前校验。还必须声明 `output: { schema, render }`。 + +### `ctx.logger` 不进 journalctl + +DSH 的 `ctx.logger.info` 在 systemd 下看不到,`console.error` 能看到。 +排查阶段用后者。 + +### 同一时间只能跑一个 DSH 实例 + +`@linxin666/dsh-client-ui-task-board` 有 ledger 文件锁 +(`task-board ledger is already owned by process ...`)。因此邮件桥接是 +**注入现有 `dsh.service`**,而不是另起一个实例。 + +### systemd 不注入 `HOME` + +插件要读 `~/.agentmail/agent.key`,`HOME` 缺失时会落到 `/`。 +service 文件里显式 `Environment=HOME=/root`。 + +opencode 还有个额外问题:**插件是懒加载的**,进程起来了插件还没加载 —— +用 `ExecStartPost` 发一个空请求预热。 + +### `dsh --patch` 对 `dsh web` 无效 + +`--patch` 只在 `dsh --profile ` 形式下有效,`dsh web` 不认这个选项。 +插件配置要写进 profile 的 `cordis.patch.yml`。 + +--- + +## 七、新平台适配清单 + +``` +[ ] 1. 认证与连接 + [ ] 密钥:环境变量 → ~/.agentmail/agent.key(本地生成并打印) + [ ] POST /agent/register + [ ] 心跳 30s(别漏!Gateway 靠 last_seen 判在线) + [ ] SSE 订阅,支持断线重连 + +[ ] 2. 会话投递 + [ ] cwd 取 data.to_workspace(复用 lib/workspace.js) + [ ] 新建会话不传占位标题 + [ ] 维护 mailSessionID ↔ 平台 sessionID 双向映射 + [ ] 续谈:映射命中且会话还活着 → followup,否则新建 + +[ ] 3. 工具(复用 lib/inbox-format.js) + [ ] send_mail / read_inbox / upload_attachment / download_attachment + [ ] read_inbox 顺便标记已读(只标本次列出的,status=all 时不标) + [ ] 不提供 request_permission + +[ ] 4. 自动转发(复用 lib/relay-dedup.js) + [ ] 找到平台的「一轮结束」信号 + [ ] 只取 text 块,丢掉 reasoning + [ ] relay: 'summary' + 稳定的 relay_key + [ ] 模型主动发过就让位 + +[ ] 5. 权限询问 + [ ] 挂平台的权限钩子 + [ ] 转不出去就让位给本地 UI + [ ] 拆插件时未决询问 fail closed + +[ ] 6. 命名与快照 + [ ] alias/title 回写 POST /sessions/{id}/sync + [ ] slug 去掉 . @ / 等寻址分隔符 + [ ] 心跳带 platform_sessions(复用 lib/session-snapshot.js) + [ ] 过滤 subagent、slug 去重 + +[ ] 7. 工程 + [ ] 可测逻辑放 lib/,入口保持最小 + [ ] 纯函数测试纳入 deploy/install.sh 的门禁 + [ ] 端到端:发一封 → 会话建在正确 cwd → 自动回信 → 别名可续谈 +``` + +--- + +## 八、共用模块 + +`lib/` 下的文件在两个插件里**逐字节相同**,接新平台时直接拷。 +它们只依赖 node 内置模块,不碰任何平台 SDK。 + +| 文件 | 职责 | +|---|---| +| `relay-dedup.js` | 自动转发去重:本轮模型是否已亲手回过这条线索 | +| `inbox-format.js` | 收件箱渲染 + 已读策略 | +| `session-snapshot.js` | 平台会话快照整理(含 subagent 过滤、slug 派生) | +| `workspace.js` | 寻址 path 位 → 可用的 cwd | +| `message.js` | DSH 的消息构造与会话日志读取(DSH 专用) | + +`test/` 下对应的测试文件同样逐字节共用。 + +TypeScript 插件另需 `.d.ts`(`lib/` 是 JS,`tsc` 需要类型声明)。 + +**改动共用模块时两侧一起改。** 一侧改了另一侧没改,两个平台的行为就会悄悄分叉: +同一封邮件在 opencode 那边标了已读、在 DSH 那边没标,而两处代码看起来都「对」。 + +`deploy/install.sh` 会跑同源校验,也可以单独执行: + +```bash +./deploy/check-shared-libs.sh +``` + +接新平台时把 `lib/` 与 `test/` 整个拷过去,平台专属逻辑写在入口文件里。 +共用模块只依赖 node 内置模块,不碰任何平台 SDK —— 这是它们能共用的前提, +新增共用函数时也要守住。 diff --git a/plugins/dsh-mail-bridge/lib/inbox-format.d.ts b/plugins/dsh-mail-bridge/lib/inbox-format.d.ts new file mode 100644 index 0000000..2fea3ad --- /dev/null +++ b/plugins/dsh-mail-bridge/lib/inbox-format.d.ts @@ -0,0 +1,7 @@ +export declare const DEFAULT_INBOX_STATUS: string; +export declare const DEFAULT_INBOX_LIMIT: number; + +export function formatSize(n: number | undefined): string; +export function renderMail(mail: any, bodyLimit?: number): string; +export function renderInbox(mails: readonly any[], bodyLimit?: number): string; +export function idsToMarkRead(status: string | undefined, mails: readonly any[]): string[]; diff --git a/plugins/dsh-mail-bridge/lib/inbox-format.js b/plugins/dsh-mail-bridge/lib/inbox-format.js new file mode 100644 index 0000000..974bd54 --- /dev/null +++ b/plugins/dsh-mail-bridge/lib/inbox-format.js @@ -0,0 +1,90 @@ +/** + * 收件箱渲染与已读策略 —— 所有平台插件共用。 + * + * 提到 lib/ 是因为这几条规则每一条都对应过一次真实的错误行为,而它们与 + * 平台 SDK 无关:无论 opencode 的 zod 工具还是 DSH 的 defineTool, + * 渲染出的文本与标记已读的时机都该一致。新接一个平台时直接复用这里。 + */ + +/** 人类可读的字节数,用于附件清单展示。 */ +export function formatSize(n) { + if (typeof n !== 'number' || !Number.isFinite(n)) return '?'; + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / 1024 / 1024).toFixed(1)} MB`; +} + +/** + * 把一封邮件渲染成模型可读的文本块。 + * + * @param {any} m `/mail/inbox` 返回的一封邮件 + * @param {number} bodyLimit 正文截断长度 + * @returns {string} + */ +export function renderMail(m, bodyLimit = 200) { + const lines = [ + `[${m?.status ?? 'unknown'}] ${m?.from_name ?? 'unknown'}: ${m?.subject ?? '(无主题)'}`, + `邮件 ID: ${m?.mail_id ?? 'unknown'}`, + `会话: #${m?.session_alias || '未命名'}`, + ]; + // 抄送要显示:一封邮件为什么同时到了几个人手上,只有抄送能解释。 + // 不显示的话模型会以为这是私下发给它一个人的,回信时漏掉其他参与方。 + if (Array.isArray(m?.cc_list) && m.cc_list.length > 0) { + lines.push('抄送: ' + m.cc_list.map(c => c?.raw || c?.name || '?').join('、')); + } + // **必须给出 attachment_id**:只说「有附件」模型就无从下载。 + if (Array.isArray(m?.attachments) && m.attachments.length > 0) { + lines.push( + '附件: ' + + m.attachments + .map(a => `${a?.filename ?? '?'}(${formatSize(a?.size_bytes)}, id=${a?.attachment_id ?? '?'})`) + .join('、') + ); + lines.push('下载附件请用 download_attachment 工具。'); + } + // 列表接口只给 body_preview(省带宽),单封接口才有 body。两者都兜住。 + const body = m?.body_preview || m?.body || ''; + lines.push(`内容: ${String(body).slice(0, bodyLimit)}`); + return lines.join('\n'); +} + +/** + * 渲染整个收件箱。 + * @param {any[]} mails + * @param {number} bodyLimit + * @returns {string} + */ +export function renderInbox(mails, bodyLimit = 200) { + const list = Array.isArray(mails) ? mails : []; + if (list.length === 0) return '收件箱为空。'; + return list.map(m => renderMail(m, bodyLimit)).join('\n\n'); +} + +/** + * 判断本次读取该标记哪些邮件为已读。 + * + * 两条规则: + * + * 1. **只标本次真正列出来的**,不是全部未读。`limit` 之外的还没看过, + * 一并标掉等于让它们凭空消失。 + * 2. **`status=all` 时不标**。那是「回顾历史」的读法,把历史邮件标成已读 + * 会让下一轮真正的新邮件混在里面认不出来。 + * + * 不标的后果是每次拉收件箱都重复捞同一批,处理过的和新来的混在一起, + * 模型分不清哪封该回。 + * + * @param {string|undefined} status 本次查询用的过滤条件 + * @param {any[]} mails 本次返回的邮件 + * @returns {string[]} 待标记的 mail_id,空数组表示不需要标记 + */ +export function idsToMarkRead(status, mails) { + if (status === 'all') return []; + const list = Array.isArray(mails) ? mails : []; + return list.map(m => m?.mail_id).filter(id => typeof id === 'string' && id); +} + +/** 收件箱默认过滤条件。默认只看未读 —— 默认 all 会让模型每轮重读旧邮件。 */ +export const DEFAULT_INBOX_STATUS = 'unread'; + +/** 收件箱默认返回条数。 */ +export const DEFAULT_INBOX_LIMIT = 5; diff --git a/plugins/dsh-mail-bridge/lib/workspace.d.ts b/plugins/dsh-mail-bridge/lib/workspace.d.ts index ba9ca04..3ba7bc3 100644 --- a/plugins/dsh-mail-bridge/lib/workspace.d.ts +++ b/plugins/dsh-mail-bridge/lib/workspace.d.ts @@ -1,6 +1,7 @@ export function resolveWorkspaceCwd( workspace: string | undefined, - fallbackKey: string + fallback: string ): { cwd: string; grouped: boolean }; +export function mailSessionFallback(sessionKey: string): string; export function ensureCwd(cwd: string, grouped: boolean): void; diff --git a/plugins/dsh-mail-bridge/lib/workspace.js b/plugins/dsh-mail-bridge/lib/workspace.js index 25455b1..a1b8745 100644 --- a/plugins/dsh-mail-bridge/lib/workspace.js +++ b/plugins/dsh-mail-bridge/lib/workspace.js @@ -14,12 +14,12 @@ import { homedir } from 'node:os'; import { isAbsolute, join, resolve } from 'node:path'; /** - * 把 new_mail 事件里的 to_workspace 解析成一个可用的 cwd。 + * 校验寻址里的工作目录,不可用时返回调用方给的兜底。 * * 决策顺序: * 1. path 位是一个已存在的目录 → 直接用它(同 path 的多封邮件天然同组) - * 2. path 位非空但目录不存在 → **不创建**,回退到兜底目录 - * 3. path 位为空(地址写成 `dsh` 而不带 `@/path`)→ 兜底目录 + * 2. path 位非空但目录不存在 → **不创建**,返回兜底 + * 3. path 位为空(地址写成 `dsh` 而不带 `@/path`)→ 兜底 * * 为什么不给不存在的 path 建目录:那等于让一个笔误(`/home/porgram/x`) * 在磁盘上落下一个真目录,而 Agent 会在里面一无所获地干活 —— @@ -28,15 +28,18 @@ import { isAbsolute, join, resolve } from 'node:path'; * 为什么拒绝相对路径:cwd 的相对基准是 harness 进程的启动目录, * 那是个与邮件语义无关的量(systemd 下通常是 `/`)。 * + * 兜底由调用方给,因为各平台的兜底不同:opencode 有插件启动时的 directory + * 可用,DSH 没有、只能落到 `~/.dsh/mail-sessions/<会话>`(见 mailSessionFallback)。 + * * @param {string} workspace 事件里的 to_workspace - * @param {string} fallbackKey 兜底目录名(通常是会话 id) + * @param {string} fallback 不可用时的兜底目录(可为空串 = 交给平台自己决定) * @returns {{cwd: string, grouped: boolean}} grouped 为真表示落在了寻址指定的目录里 */ -export function resolveWorkspaceCwd(workspace, fallbackKey) { +export function resolveWorkspaceCwd(workspace, fallback) { const raw = typeof workspace === 'string' ? workspace.trim() : ''; - const fallback = join(homedir(), '.dsh', 'mail-sessions', String(fallbackKey || 'default')); + const fb = typeof fallback === 'string' ? fallback : ''; - if (!raw || !isAbsolute(raw)) return { cwd: fallback, grouped: false }; + if (!raw || !isAbsolute(raw)) return { cwd: fb, grouped: false }; const abs = resolve(raw); try { @@ -46,7 +49,16 @@ export function resolveWorkspaceCwd(workspace, fallbackKey) { } catch { // 权限不足等:当作不可用 } - return { cwd: fallback, grouped: false }; + return { cwd: fb, grouped: false }; +} + +/** + * 没有天然兜底的平台(DSH)用这个:`~/.dsh/mail-sessions/<会话 id>`。 + * @param {string} sessionKey 会话标识 + * @returns {string} + */ +export function mailSessionFallback(sessionKey) { + return join(homedir(), '.dsh', 'mail-sessions', String(sessionKey || 'default')); } /** @@ -56,7 +68,7 @@ export function resolveWorkspaceCwd(workspace, fallbackKey) { * @param {boolean} grouped 是否落在寻址指定的目录里 */ export function ensureCwd(cwd, grouped) { - if (grouped) return; + if (grouped || !cwd) return; try { mkdirSync(cwd, { recursive: true }); } catch { diff --git a/plugins/dsh-mail-bridge/src/index.ts b/plugins/dsh-mail-bridge/src/index.ts index 3c18b4c..f413709 100644 --- a/plugins/dsh-mail-bridge/src/index.ts +++ b/plugins/dsh-mail-bridge/src/index.ts @@ -28,7 +28,14 @@ import { modelTitle, } from '../lib/message.js'; import { snapshotDshSessions, slugFromTitle } from '../lib/session-snapshot.js'; -import { resolveWorkspaceCwd, ensureCwd } from '../lib/workspace.js'; +import { resolveWorkspaceCwd, ensureCwd, mailSessionFallback } from '../lib/workspace.js'; +import { + renderInbox, + idsToMarkRead, + formatSize, + DEFAULT_INBOX_STATUS, + DEFAULT_INBOX_LIMIT, +} from '../lib/inbox-format.js'; // ─── 凭证管理 ─── @@ -320,7 +327,8 @@ export function apply(ctx: any, config: PluginConfig): void { // 之前这里硬拼 `~/.dsh/mail-sessions/mail-` —— 每封邮件一个全新的空目录。 // DSH 按 cwd 给会话分组,于是所有邮件会话既不属于任何项目、彼此也不同组, // 界面上全落进「未分组」。path 位本来就是「希望它在哪儿干活」。 - const { cwd, grouped } = resolveWorkspaceCwd(data.to_workspace, sessionId); + const { cwd, grouped } = resolveWorkspaceCwd( + data.to_workspace, mailSessionFallback(sessionId)); ensureCwd(cwd, grouped); if (!grouped && data.to_workspace) { ctx.logger.warn( @@ -456,25 +464,35 @@ export function apply(ctx: any, config: PluginConfig): void { // read_inbox ctx.tools.register(defineTool({ name: 'read_inbox', - description: '读取收件箱邮件列表。返回最新的邮件,每封含 mail_id、发件人、主题、正文、附件清单。', + description: '查阅收件箱中的邮件。收到新邮件通知后应立即调用此工具。每封含 mail_id、发件人、主题、正文与附件清单(带 attachment_id)。', parameters: { - status: { type: 'string', description: '过滤状态(all/unread/read)' }, - limit: { type: 'number', description: '返回数量上限' }, + status: { type: 'string', description: '过滤条件 unread|all,默认 unread' }, + limit: { type: 'number', description: '返回数量,默认 5' }, }, output: { schema: { type: 'string' }, render: (_args: any, value: string) => [{ type: 'text', text: value }], }, async execute(args: any): Promise { + const status = args.status || DEFAULT_INBOX_STATUS; const { mails } = await client.get( - `/mail/inbox?status=${args.status || 'all'}&limit=${args.limit || 20}` + `/mail/inbox?status=${status}&limit=${args.limit || DEFAULT_INBOX_LIMIT}` ); - if (!mails?.length) return '收件箱为空。'; - return mails.map((m: any) => { - const att = m.attachments?.length - ? ` [附件: ${m.attachments.map((a: any) => a.filename).join(', ')}]` : ''; - return `- ID: ${m.mail_id} | ${m.from_name} | ${m.subject}${att}\n ${m.body.slice(0, 200)}`; - }).join('\n'); + + // 渲染与已读策略放 lib/inbox-format.js:它们与平台 SDK 无关, + // 各平台插件必须一致(见该文件里每条规则对应的错误行为)。 + const listed = renderInbox(mails); + + // 读过就标掉,否则每次拉收件箱都重复捞同一批, + // 处理过的和新来的混在一起,模型分不清哪封该回。 + const ids = idsToMarkRead(args.status, mails); + if (ids.length) { + // 标记失败不该让 read_inbox 失败:正文已经取到了, + // 代价只是下次重复看到,比丢掉这次读取轻。 + client.post('/mail/read', { mail_ids: ids }).catch((e: any) => + ctx.logger.error(`[dsh-mail-bridge] 标记已读失败: ${e?.message || e}`)); + } + return listed; }, })); @@ -500,7 +518,7 @@ export function apply(ctx: any, config: PluginConfig): void { const json = await res.json() as any; if (!res.ok) throw new Error(json?.error || `HTTP ${res.status}`); const a = json.attachment; - return `已上传 ${a.filename}(${a.size_bytes} 字节)。attachment_id: ${a.attachment_id}`; + return `已上传 ${a.filename}(${formatSize(a.size_bytes)})。attachment_id: ${a.attachment_id}`; }, })); @@ -523,7 +541,7 @@ export function apply(ctx: any, config: PluginConfig): void { if (!res.ok) throw new Error(`下载失败: HTTP ${res.status}`); const buf = Buffer.from(await res.arrayBuffer()); await writeFile(args.save_path, buf); - return `已保存到 ${args.save_path}(${buf.length} 字节)`; + return `已保存到 ${args.save_path}(${formatSize(buf.length)})`; }, })); diff --git a/plugins/dsh-mail-bridge/test/inbox-format.test.mjs b/plugins/dsh-mail-bridge/test/inbox-format.test.mjs new file mode 100644 index 0000000..f32ee5d --- /dev/null +++ b/plugins/dsh-mail-bridge/test/inbox-format.test.mjs @@ -0,0 +1,176 @@ +/** + * 收件箱渲染与已读策略的测试。 + * + * 每条断言都对应一次真实的错误行为(见 lib/inbox-format.js 里的注释): + * 漏掉 attachment_id 模型就无从下载附件;漏掉抄送它会以为这是私信; + * status=all 时标记已读会让下一轮的新邮件混在历史里认不出来。 + * + * node --test 'test/*.test.mjs' + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + formatSize, + renderMail, + renderInbox, + idsToMarkRead, + DEFAULT_INBOX_STATUS, + DEFAULT_INBOX_LIMIT, +} from '../lib/inbox-format.js'; + +const mail = (over = {}) => ({ + mail_id: 'm-1', + from_name: 'admin', + subject: '缓存选型', + status: 'unread', + session_alias: 'brisk-harbor', + body_preview: '我们需要评估一下缓存层', + ...over, +}); + +// ─── formatSize ─── + +test('formatSize 分档', () => { + assert.equal(formatSize(512), '512 B'); + assert.equal(formatSize(2048), '2.0 KB'); + assert.equal(formatSize(3 * 1024 * 1024), '3.0 MB'); +}); + +test('formatSize 容错', () => { + assert.equal(formatSize(undefined), '?'); + assert.equal(formatSize(NaN), '?'); + assert.equal(formatSize('x'), '?'); +}); + +// ─── renderMail ─── + +test('renderMail 带出 mail_id 与会话别名', () => { + const got = renderMail(mail()); + assert.match(got, /邮件 ID: m-1/); + assert.match(got, /#brisk-harbor/); + assert.match(got, /admin: 缓存选型/); +}); + +test('无别名时显示「未命名」而不是空', () => { + const got = renderMail(mail({ session_alias: '' })); + assert.match(got, /#未命名/); +}); + +test('不变量:附件必须带 attachment_id', () => { + // 只说「有附件」模型就无从下载 —— download_attachment 要的正是这个 id。 + const got = renderMail(mail({ + attachments: [{ filename: 'report.md', size_bytes: 2048, attachment_id: 'att-9' }], + })); + assert.match(got, /id=att-9/, `附件行缺 id:${got}`); + assert.match(got, /report\.md/); + assert.match(got, /2\.0 KB/); + assert.match(got, /download_attachment/, '要提示模型用哪个工具下载'); +}); + +test('多个附件都列出来', () => { + const got = renderMail(mail({ + attachments: [ + { filename: 'a.md', size_bytes: 10, attachment_id: 'att-1' }, + { filename: 'b.md', size_bytes: 20, attachment_id: 'att-2' }, + ], + })); + assert.match(got, /att-1/); + assert.match(got, /att-2/); +}); + +test('不变量:抄送人要显示出来', () => { + // 不显示的话模型会以为这是私下发给它一个人的,回信时漏掉其他参与方。 + const got = renderMail(mail({ + cc_list: [{ name: 'opencode', raw: 'opencode@/home.new' }], + })); + assert.match(got, /抄送/); + assert.match(got, /opencode@\/home\.new/, '应优先用 raw(带路径与会话段)'); +}); + +test('无抄送时不出现抄送行', () => { + assert.ok(!renderMail(mail()).includes('抄送')); + assert.ok(!renderMail(mail({ cc_list: [] })).includes('抄送')); +}); + +test('正文优先取 body_preview,缺失时退回 body', () => { + assert.match(renderMail(mail({ body_preview: '预览', body: '全文' })), /内容: 预览/); + assert.match(renderMail(mail({ body_preview: '', body: '全文' })), /内容: 全文/); +}); + +test('正文按 bodyLimit 截断', () => { + const got = renderMail(mail({ body_preview: 'x'.repeat(500) }), 50); + const line = got.split('\n').find(l => l.startsWith('内容: ')); + assert.equal(line.length, '内容: '.length + 50); +}); + +test('renderMail 容错:字段全缺不崩', () => { + const got = renderMail({}); + assert.match(got, /unknown/); + const got2 = renderMail(undefined); + assert.equal(typeof got2, 'string'); +}); + +test('附件字段不是数组时忽略', () => { + const got = renderMail(mail({ attachments: 'oops', cc_list: 'oops' })); + assert.ok(!got.includes('附件:')); + assert.ok(!got.includes('抄送')); +}); + +// ─── renderInbox ─── + +test('renderInbox 空收件箱给明确文案', () => { + assert.equal(renderInbox([]), '收件箱为空。'); + assert.equal(renderInbox(undefined), '收件箱为空。'); + assert.equal(renderInbox(null), '收件箱为空。'); +}); + +test('renderInbox 用空行分隔多封', () => { + const got = renderInbox([mail({ mail_id: 'a' }), mail({ mail_id: 'b' })]); + assert.match(got, /邮件 ID: a[\s\S]*\n\n[\s\S]*邮件 ID: b/); +}); + +// ─── idsToMarkRead ─── + +test('不变量:只标本次列出的那些', () => { + // limit 之外的还没看过,一并标掉等于让它们凭空消失。 + const ids = idsToMarkRead('unread', [mail({ mail_id: 'a' }), mail({ mail_id: 'b' })]); + assert.deepEqual(ids, ['a', 'b']); +}); + +test('不变量:status=all 时不标记', () => { + // 那是「回顾历史」的读法。把历史邮件标成已读会让下一轮真正的新邮件 + // 混在里面认不出来。 + assert.deepEqual(idsToMarkRead('all', [mail({ mail_id: 'a' })]), []); +}); + +test('status 省略时按默认(unread)标记', () => { + assert.deepEqual(idsToMarkRead(undefined, [mail({ mail_id: 'a' })]), ['a']); +}); + +test('idsToMarkRead 过滤掉无 id 的条目', () => { + const ids = idsToMarkRead('unread', [ + mail({ mail_id: 'a' }), + mail({ mail_id: '' }), + mail({ mail_id: undefined }), + { }, + ]); + assert.deepEqual(ids, ['a']); +}); + +test('idsToMarkRead 容错非数组', () => { + assert.deepEqual(idsToMarkRead('unread', undefined), []); + assert.deepEqual(idsToMarkRead('unread', 'oops'), []); +}); + +// ─── 默认值 ─── + +test('默认只看未读', () => { + // 默认 all 会让模型每轮重读旧邮件,把处理过的和新来的混在一起。 + assert.equal(DEFAULT_INBOX_STATUS, 'unread'); +}); + +test('默认条数是个小数字', () => { + // 收件箱一次给几十封会把上下文塞满,而模型一轮通常只处理一两封。 + assert.ok(DEFAULT_INBOX_LIMIT > 0 && DEFAULT_INBOX_LIMIT <= 10); +}); diff --git a/plugins/dsh-mail-bridge/test/workspace.test.mjs b/plugins/dsh-mail-bridge/test/workspace.test.mjs index cd3fdcc..4913747 100644 --- a/plugins/dsh-mail-bridge/test/workspace.test.mjs +++ b/plugins/dsh-mail-bridge/test/workspace.test.mjs @@ -13,14 +13,16 @@ import assert from 'node:assert/strict'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir, homedir } from 'node:os'; import { join } from 'node:path'; -import { resolveWorkspaceCwd, ensureCwd } from '../lib/workspace.js'; +import { resolveWorkspaceCwd, ensureCwd, mailSessionFallback } from '../lib/workspace.js'; -const fallbackOf = key => join(homedir(), '.dsh', 'mail-sessions', key); +// 兜底目录现在由调用方给(各平台不同)。DSH 用 mailSessionFallback, +// opencode 用插件启动时的 directory。 +const fallbackOf = key => mailSessionFallback(key); test('存在的绝对路径直接用作 cwd', () => { const dir = mkdtempSync(join(tmpdir(), 'ws-test-')); try { - const got = resolveWorkspaceCwd(dir, 'mail-1'); + const got = resolveWorkspaceCwd(dir, fallbackOf('mail-1')); assert.equal(got.cwd, dir); assert.equal(got.grouped, true); } finally { @@ -31,8 +33,8 @@ test('存在的绝对路径直接用作 cwd', () => { test('不变量:同一 path 的多封邮件得到同一个 cwd(这才能同组)', () => { const dir = mkdtempSync(join(tmpdir(), 'ws-test-')); try { - const a = resolveWorkspaceCwd(dir, 'mail-aaa'); - const b = resolveWorkspaceCwd(dir, 'mail-bbb'); + const a = resolveWorkspaceCwd(dir, fallbackOf('mail-aaa')); + const b = resolveWorkspaceCwd(dir, fallbackOf('mail-bbb')); assert.equal(a.cwd, b.cwd, 'fallbackKey 不同却应得到同一个 cwd'); } finally { rmSync(dir, { recursive: true, force: true }); @@ -40,14 +42,14 @@ test('不变量:同一 path 的多封邮件得到同一个 cwd(这才能同 }); test('path 为空时回退到兜底目录', () => { - const got = resolveWorkspaceCwd('', 'mail-2'); + const got = resolveWorkspaceCwd('', fallbackOf('mail-2')); assert.equal(got.cwd, fallbackOf('mail-2')); assert.equal(got.grouped, false); }); test('path 缺失/非字符串时回退', () => { for (const v of [undefined, null, 42, {}]) { - const got = resolveWorkspaceCwd(v, 'mail-3'); + const got = resolveWorkspaceCwd(v, fallbackOf('mail-3')); assert.equal(got.grouped, false); assert.equal(got.cwd, fallbackOf('mail-3')); } @@ -56,7 +58,7 @@ test('path 缺失/非字符串时回退', () => { test('不变量:不存在的目录不创建,回退到兜底', () => { // 一个笔误(/home/porgram/x)不该在磁盘上落下真目录 —— // Agent 会在里面一无所获地干活,比明确回退更难排查。 - const got = resolveWorkspaceCwd('/nonexistent/path/xyz-should-not-exist', 'mail-4'); + const got = resolveWorkspaceCwd('/nonexistent/path/xyz-should-not-exist', fallbackOf('mail-4')); assert.equal(got.grouped, false); assert.equal(got.cwd, fallbackOf('mail-4')); }); @@ -65,7 +67,7 @@ test('不变量:相对路径被拒绝', () => { // cwd 的相对基准是 harness 进程的启动目录,systemd 下通常是 /, // 那是个与邮件语义完全无关的量。 for (const rel of ['relative/path', './x', '../y', 'src']) { - const got = resolveWorkspaceCwd(rel, 'mail-5'); + const got = resolveWorkspaceCwd(rel, fallbackOf('mail-5')); assert.equal(got.grouped, false, `${rel} 不该被当作工作目录`); } }); @@ -75,7 +77,7 @@ test('指向文件而非目录时回退', () => { const file = join(dir, 'a-file'); writeFileSync(file, 'x'); try { - const got = resolveWorkspaceCwd(file, 'mail-6'); + const got = resolveWorkspaceCwd(file, fallbackOf('mail-6')); assert.equal(got.grouped, false); } finally { rmSync(dir, { recursive: true, force: true }); @@ -85,7 +87,7 @@ test('指向文件而非目录时回退', () => { test('两端空白被修掉', () => { const dir = mkdtempSync(join(tmpdir(), 'ws-test-')); try { - const got = resolveWorkspaceCwd(` ${dir} `, 'mail-7'); + const got = resolveWorkspaceCwd(` ${dir} `, fallbackOf('mail-7')); assert.equal(got.cwd, dir); assert.equal(got.grouped, true); } finally { @@ -99,14 +101,28 @@ test('ensureCwd 只建兜底目录,不碰寻址指定的目录', () => { const target = join(base, 'made-by-ensure'); ensureCwd(target, false); // 建出来了 - const got = resolveWorkspaceCwd(target, 'x'); + const got = resolveWorkspaceCwd(target, ''); assert.equal(got.grouped, true, 'ensureCwd 应已创建该目录'); // grouped=true 时不该创建(那种目录本来就存在) const never = join(base, 'should-not-exist'); ensureCwd(never, true); - assert.equal(resolveWorkspaceCwd(never, 'x').grouped, false); + assert.equal(resolveWorkspaceCwd(never, '').grouped, false); } finally { rmSync(base, { recursive: true, force: true }); } }); + +test('兜底为空串时返回空 cwd(交给平台自己决定)', () => { + // opencode 没配 directory 时就是这种情况:session.create 不带 query.directory, + // 由平台按自己的默认规则选目录。比硬塞一个我们猜的路径好。 + const got = resolveWorkspaceCwd('', ''); + assert.equal(got.cwd, ''); + assert.equal(got.grouped, false); +}); + +test('mailSessionFallback 同一 key 稳定、不同 key 不同', () => { + assert.equal(mailSessionFallback('a'), mailSessionFallback('a')); + assert.notEqual(mailSessionFallback('a'), mailSessionFallback('b')); + assert.match(mailSessionFallback('a'), /mail-sessions/); +}); diff --git a/plugins/opencode-mail-bridge/index.js b/plugins/opencode-mail-bridge/index.js index e844de6..389c192 100644 --- a/plugins/opencode-mail-bridge/index.js +++ b/plugins/opencode-mail-bridge/index.js @@ -6,6 +6,14 @@ import { join, dirname, basename } from "node:path"; // 自动转发去重的纯逻辑放在 lib/ 里:opencode 会把入口模块的每一个导出 // 都当成插件工厂,入口文件多导出一个东西就会 "Plugin export is not a function"。 import { snapshotOpencodeSessions } from "./lib/session-snapshot.js"; +import { resolveWorkspaceCwd } from "./lib/workspace.js"; +import { + renderInbox, + idsToMarkRead, + formatSize, + DEFAULT_INBOX_STATUS, + DEFAULT_INBOX_LIMIT, +} from "./lib/inbox-format.js"; import { explicitSends, noteExplicitSend, @@ -222,58 +230,26 @@ const readInboxTool = { limit: z.number().optional().describe("返回数量,默认 5"), }, async execute(args) { - const filter = args.filter || "unread"; - const limit = args.limit || 5; + const filter = args.filter || DEFAULT_INBOX_STATUS; + const limit = args.limit || DEFAULT_INBOX_LIMIT; const data = await apiGet(`/mail/inbox?status=${filter}&limit=${limit}`); - if (!data.mails || data.mails.length === 0) return "收件箱为空。"; - const listed = data.mails.map((m) => { - const lines = [ - `[${m.status}] ${m.from_name}: ${m.subject}`, - `邮件 ID: ${m.mail_id}`, - `会话: #${m.session_alias || "未命名"}`, - ]; - // 必须把 attachment_id 一起给出:不然模型知道「有附件」却无从下载 - if (m.attachments?.length) { - lines.push( - "附件: " + - m.attachments - .map(a => `${a.filename}(${formatSize(a.size_bytes)}, id=${a.attachment_id})`) - .join("、") - ); - lines.push("下载附件请用 download_attachment 工具。"); - } - lines.push(`内容: ${(m.body_preview || m.body || "").substring(0, 200)}`); - return lines.join("\n"); - }).join("\n\n"); - // 读过就标掉。不标的话下次拉收件箱还是这一批, - // 处理过的信和新来的信混在一起,模型分不清哪封该回。 - // - // 只标本次真正列出来的(而不是全部未读):limit 之外的还没看过, - // 一并标掉等于让它们凭空消失。 - if (args.filter !== "all") { - const ids = data.mails.map(m => m.mail_id).filter(Boolean); - if (ids.length) { - // 标记失败不该让 read_inbox 失败 —— 正文已经取到了, - // 代价只是下次会重复看到,比丢掉这次读取轻。 - apiPost("/mail/read", { mail_ids: ids }).catch(e => - console.error("[mail-bridge] 标记已读失败:", e?.message || e) - ); - } + // 渲染与已读策略放 lib/inbox-format.js:它们与平台 SDK 无关, + // 各平台插件必须一致(见该文件里每条规则对应的错误行为)。 + const listed = renderInbox(data.mails); + + const ids = idsToMarkRead(args.filter, data.mails); + if (ids.length) { + // 标记失败不该让 read_inbox 失败 —— 正文已经取到了, + // 代价只是下次会重复看到,比丢掉这次读取轻。 + apiPost("/mail/read", { mail_ids: ids }).catch(e => + console.error("[mail-bridge] 标记已读失败:", e?.message || e) + ); } - return listed; }, }; -/** 人类可读的字节数,用于附件清单展示。 */ -function formatSize(n) { - if (typeof n !== "number") return "?"; - if (n < 1024) return `${n} B`; - if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; - return `${(n / 1024 / 1024).toFixed(1)} MB`; -} - const uploadAttachmentTool = { description: "上传本地文件作为邮件附件,返回 attachment_id。" + @@ -480,9 +456,14 @@ async function resolveSessionForMail(client, directory, data, kind) { // 三维地址 name@path.session 的 path 就是「希望它在哪儿干活」。用固定的 // directory 会让所有邮件会话都挤在同一个目录里,与地址写的完全无关; // 而 opencode 按 directory 归属项目,写错了会话就归到别的项目下。 - const wantDir = typeof data.to_workspace === "string" && data.to_workspace.trim() - ? data.to_workspace.trim() - : directory; + // + // 校验逻辑与 DSH 侧共用(lib/workspace.js):目录不存在时不创建、 + // 拒绝相对路径。opencode 的兜底是插件启动时的 directory。 + const { cwd: wantDir, grouped } = resolveWorkspaceCwd(data.to_workspace, directory); + if (!grouped && data.to_workspace) { + console.error( + `[mail-bridge] 工作目录 ${data.to_workspace} 不可用,回退到 ${wantDir || "(平台默认)"}`); + } // 故意不传 title:opencode 只在标题缺省时才让模型按首轮对话生成摘要标题, // 传了占位标题就等于掐掉平台自己的命名机制。标题稍后由 session.updated 事件回写。 diff --git a/plugins/opencode-mail-bridge/lib/inbox-format.js b/plugins/opencode-mail-bridge/lib/inbox-format.js new file mode 100644 index 0000000..974bd54 --- /dev/null +++ b/plugins/opencode-mail-bridge/lib/inbox-format.js @@ -0,0 +1,90 @@ +/** + * 收件箱渲染与已读策略 —— 所有平台插件共用。 + * + * 提到 lib/ 是因为这几条规则每一条都对应过一次真实的错误行为,而它们与 + * 平台 SDK 无关:无论 opencode 的 zod 工具还是 DSH 的 defineTool, + * 渲染出的文本与标记已读的时机都该一致。新接一个平台时直接复用这里。 + */ + +/** 人类可读的字节数,用于附件清单展示。 */ +export function formatSize(n) { + if (typeof n !== 'number' || !Number.isFinite(n)) return '?'; + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / 1024 / 1024).toFixed(1)} MB`; +} + +/** + * 把一封邮件渲染成模型可读的文本块。 + * + * @param {any} m `/mail/inbox` 返回的一封邮件 + * @param {number} bodyLimit 正文截断长度 + * @returns {string} + */ +export function renderMail(m, bodyLimit = 200) { + const lines = [ + `[${m?.status ?? 'unknown'}] ${m?.from_name ?? 'unknown'}: ${m?.subject ?? '(无主题)'}`, + `邮件 ID: ${m?.mail_id ?? 'unknown'}`, + `会话: #${m?.session_alias || '未命名'}`, + ]; + // 抄送要显示:一封邮件为什么同时到了几个人手上,只有抄送能解释。 + // 不显示的话模型会以为这是私下发给它一个人的,回信时漏掉其他参与方。 + if (Array.isArray(m?.cc_list) && m.cc_list.length > 0) { + lines.push('抄送: ' + m.cc_list.map(c => c?.raw || c?.name || '?').join('、')); + } + // **必须给出 attachment_id**:只说「有附件」模型就无从下载。 + if (Array.isArray(m?.attachments) && m.attachments.length > 0) { + lines.push( + '附件: ' + + m.attachments + .map(a => `${a?.filename ?? '?'}(${formatSize(a?.size_bytes)}, id=${a?.attachment_id ?? '?'})`) + .join('、') + ); + lines.push('下载附件请用 download_attachment 工具。'); + } + // 列表接口只给 body_preview(省带宽),单封接口才有 body。两者都兜住。 + const body = m?.body_preview || m?.body || ''; + lines.push(`内容: ${String(body).slice(0, bodyLimit)}`); + return lines.join('\n'); +} + +/** + * 渲染整个收件箱。 + * @param {any[]} mails + * @param {number} bodyLimit + * @returns {string} + */ +export function renderInbox(mails, bodyLimit = 200) { + const list = Array.isArray(mails) ? mails : []; + if (list.length === 0) return '收件箱为空。'; + return list.map(m => renderMail(m, bodyLimit)).join('\n\n'); +} + +/** + * 判断本次读取该标记哪些邮件为已读。 + * + * 两条规则: + * + * 1. **只标本次真正列出来的**,不是全部未读。`limit` 之外的还没看过, + * 一并标掉等于让它们凭空消失。 + * 2. **`status=all` 时不标**。那是「回顾历史」的读法,把历史邮件标成已读 + * 会让下一轮真正的新邮件混在里面认不出来。 + * + * 不标的后果是每次拉收件箱都重复捞同一批,处理过的和新来的混在一起, + * 模型分不清哪封该回。 + * + * @param {string|undefined} status 本次查询用的过滤条件 + * @param {any[]} mails 本次返回的邮件 + * @returns {string[]} 待标记的 mail_id,空数组表示不需要标记 + */ +export function idsToMarkRead(status, mails) { + if (status === 'all') return []; + const list = Array.isArray(mails) ? mails : []; + return list.map(m => m?.mail_id).filter(id => typeof id === 'string' && id); +} + +/** 收件箱默认过滤条件。默认只看未读 —— 默认 all 会让模型每轮重读旧邮件。 */ +export const DEFAULT_INBOX_STATUS = 'unread'; + +/** 收件箱默认返回条数。 */ +export const DEFAULT_INBOX_LIMIT = 5; diff --git a/plugins/opencode-mail-bridge/lib/workspace.js b/plugins/opencode-mail-bridge/lib/workspace.js new file mode 100644 index 0000000..a1b8745 --- /dev/null +++ b/plugins/opencode-mail-bridge/lib/workspace.js @@ -0,0 +1,77 @@ +/** + * 邮件寻址里的工作目录(三维地址 name@path.session 的 path 位)。 + * + * 这个模块存在的理由是一次真实故障:插件建会话时用的 cwd 是自己拼的 + * `~/.dsh/mail-sessions/mail-` —— 每封邮件一个全新的空目录。 + * DSH 与 opencode 都按 cwd 给会话分组,于是所有邮件会话既不属于任何项目、 + * 彼此也不同组,界面上全落进「未分组」。 + * + * path 位本来就是「希望它在哪儿干活」,插件只需照用。 + */ + +import { existsSync, mkdirSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { isAbsolute, join, resolve } from 'node:path'; + +/** + * 校验寻址里的工作目录,不可用时返回调用方给的兜底。 + * + * 决策顺序: + * 1. path 位是一个已存在的目录 → 直接用它(同 path 的多封邮件天然同组) + * 2. path 位非空但目录不存在 → **不创建**,返回兜底 + * 3. path 位为空(地址写成 `dsh` 而不带 `@/path`)→ 兜底 + * + * 为什么不给不存在的 path 建目录:那等于让一个笔误(`/home/porgram/x`) + * 在磁盘上落下一个真目录,而 Agent 会在里面一无所获地干活 —— + * 用户看到会话建起来了却什么都做不了,比明确落到兜底目录更难排查。 + * + * 为什么拒绝相对路径:cwd 的相对基准是 harness 进程的启动目录, + * 那是个与邮件语义无关的量(systemd 下通常是 `/`)。 + * + * 兜底由调用方给,因为各平台的兜底不同:opencode 有插件启动时的 directory + * 可用,DSH 没有、只能落到 `~/.dsh/mail-sessions/<会话>`(见 mailSessionFallback)。 + * + * @param {string} workspace 事件里的 to_workspace + * @param {string} fallback 不可用时的兜底目录(可为空串 = 交给平台自己决定) + * @returns {{cwd: string, grouped: boolean}} grouped 为真表示落在了寻址指定的目录里 + */ +export function resolveWorkspaceCwd(workspace, fallback) { + const raw = typeof workspace === 'string' ? workspace.trim() : ''; + const fb = typeof fallback === 'string' ? fallback : ''; + + if (!raw || !isAbsolute(raw)) return { cwd: fb, grouped: false }; + + const abs = resolve(raw); + try { + if (existsSync(abs) && statSync(abs).isDirectory()) { + return { cwd: abs, grouped: true }; + } + } catch { + // 权限不足等:当作不可用 + } + return { cwd: fb, grouped: false }; +} + +/** + * 没有天然兜底的平台(DSH)用这个:`~/.dsh/mail-sessions/<会话 id>`。 + * @param {string} sessionKey 会话标识 + * @returns {string} + */ +export function mailSessionFallback(sessionKey) { + return join(homedir(), '.dsh', 'mail-sessions', String(sessionKey || 'default')); +} + +/** + * 确保兜底目录存在。寻址指定的目录本来就存在(否则不会被选中), + * 只有兜底目录需要现建。 + * @param {string} cwd resolveWorkspaceCwd 的结果 + * @param {boolean} grouped 是否落在寻址指定的目录里 + */ +export function ensureCwd(cwd, grouped) { + if (grouped || !cwd) return; + try { + mkdirSync(cwd, { recursive: true }); + } catch { + // 建不出来就让 harness 自己报错,这里不该吞掉真实原因 + } +} diff --git a/plugins/opencode-mail-bridge/package.json b/plugins/opencode-mail-bridge/package.json index 99a58f8..4c07681 100644 --- a/plugins/opencode-mail-bridge/package.json +++ b/plugins/opencode-mail-bridge/package.json @@ -11,6 +11,6 @@ "@opencode-ai/plugin": ">=1.15.0" }, "scripts": { - "test": "node test/auto-relay.test.mjs && node --test test/session-snapshot.test.mjs" + "test": "node test/auto-relay.test.mjs && node --test 'test/*.test.mjs'" } } diff --git a/plugins/opencode-mail-bridge/test/inbox-format.test.mjs b/plugins/opencode-mail-bridge/test/inbox-format.test.mjs new file mode 100644 index 0000000..f32ee5d --- /dev/null +++ b/plugins/opencode-mail-bridge/test/inbox-format.test.mjs @@ -0,0 +1,176 @@ +/** + * 收件箱渲染与已读策略的测试。 + * + * 每条断言都对应一次真实的错误行为(见 lib/inbox-format.js 里的注释): + * 漏掉 attachment_id 模型就无从下载附件;漏掉抄送它会以为这是私信; + * status=all 时标记已读会让下一轮的新邮件混在历史里认不出来。 + * + * node --test 'test/*.test.mjs' + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + formatSize, + renderMail, + renderInbox, + idsToMarkRead, + DEFAULT_INBOX_STATUS, + DEFAULT_INBOX_LIMIT, +} from '../lib/inbox-format.js'; + +const mail = (over = {}) => ({ + mail_id: 'm-1', + from_name: 'admin', + subject: '缓存选型', + status: 'unread', + session_alias: 'brisk-harbor', + body_preview: '我们需要评估一下缓存层', + ...over, +}); + +// ─── formatSize ─── + +test('formatSize 分档', () => { + assert.equal(formatSize(512), '512 B'); + assert.equal(formatSize(2048), '2.0 KB'); + assert.equal(formatSize(3 * 1024 * 1024), '3.0 MB'); +}); + +test('formatSize 容错', () => { + assert.equal(formatSize(undefined), '?'); + assert.equal(formatSize(NaN), '?'); + assert.equal(formatSize('x'), '?'); +}); + +// ─── renderMail ─── + +test('renderMail 带出 mail_id 与会话别名', () => { + const got = renderMail(mail()); + assert.match(got, /邮件 ID: m-1/); + assert.match(got, /#brisk-harbor/); + assert.match(got, /admin: 缓存选型/); +}); + +test('无别名时显示「未命名」而不是空', () => { + const got = renderMail(mail({ session_alias: '' })); + assert.match(got, /#未命名/); +}); + +test('不变量:附件必须带 attachment_id', () => { + // 只说「有附件」模型就无从下载 —— download_attachment 要的正是这个 id。 + const got = renderMail(mail({ + attachments: [{ filename: 'report.md', size_bytes: 2048, attachment_id: 'att-9' }], + })); + assert.match(got, /id=att-9/, `附件行缺 id:${got}`); + assert.match(got, /report\.md/); + assert.match(got, /2\.0 KB/); + assert.match(got, /download_attachment/, '要提示模型用哪个工具下载'); +}); + +test('多个附件都列出来', () => { + const got = renderMail(mail({ + attachments: [ + { filename: 'a.md', size_bytes: 10, attachment_id: 'att-1' }, + { filename: 'b.md', size_bytes: 20, attachment_id: 'att-2' }, + ], + })); + assert.match(got, /att-1/); + assert.match(got, /att-2/); +}); + +test('不变量:抄送人要显示出来', () => { + // 不显示的话模型会以为这是私下发给它一个人的,回信时漏掉其他参与方。 + const got = renderMail(mail({ + cc_list: [{ name: 'opencode', raw: 'opencode@/home.new' }], + })); + assert.match(got, /抄送/); + assert.match(got, /opencode@\/home\.new/, '应优先用 raw(带路径与会话段)'); +}); + +test('无抄送时不出现抄送行', () => { + assert.ok(!renderMail(mail()).includes('抄送')); + assert.ok(!renderMail(mail({ cc_list: [] })).includes('抄送')); +}); + +test('正文优先取 body_preview,缺失时退回 body', () => { + assert.match(renderMail(mail({ body_preview: '预览', body: '全文' })), /内容: 预览/); + assert.match(renderMail(mail({ body_preview: '', body: '全文' })), /内容: 全文/); +}); + +test('正文按 bodyLimit 截断', () => { + const got = renderMail(mail({ body_preview: 'x'.repeat(500) }), 50); + const line = got.split('\n').find(l => l.startsWith('内容: ')); + assert.equal(line.length, '内容: '.length + 50); +}); + +test('renderMail 容错:字段全缺不崩', () => { + const got = renderMail({}); + assert.match(got, /unknown/); + const got2 = renderMail(undefined); + assert.equal(typeof got2, 'string'); +}); + +test('附件字段不是数组时忽略', () => { + const got = renderMail(mail({ attachments: 'oops', cc_list: 'oops' })); + assert.ok(!got.includes('附件:')); + assert.ok(!got.includes('抄送')); +}); + +// ─── renderInbox ─── + +test('renderInbox 空收件箱给明确文案', () => { + assert.equal(renderInbox([]), '收件箱为空。'); + assert.equal(renderInbox(undefined), '收件箱为空。'); + assert.equal(renderInbox(null), '收件箱为空。'); +}); + +test('renderInbox 用空行分隔多封', () => { + const got = renderInbox([mail({ mail_id: 'a' }), mail({ mail_id: 'b' })]); + assert.match(got, /邮件 ID: a[\s\S]*\n\n[\s\S]*邮件 ID: b/); +}); + +// ─── idsToMarkRead ─── + +test('不变量:只标本次列出的那些', () => { + // limit 之外的还没看过,一并标掉等于让它们凭空消失。 + const ids = idsToMarkRead('unread', [mail({ mail_id: 'a' }), mail({ mail_id: 'b' })]); + assert.deepEqual(ids, ['a', 'b']); +}); + +test('不变量:status=all 时不标记', () => { + // 那是「回顾历史」的读法。把历史邮件标成已读会让下一轮真正的新邮件 + // 混在里面认不出来。 + assert.deepEqual(idsToMarkRead('all', [mail({ mail_id: 'a' })]), []); +}); + +test('status 省略时按默认(unread)标记', () => { + assert.deepEqual(idsToMarkRead(undefined, [mail({ mail_id: 'a' })]), ['a']); +}); + +test('idsToMarkRead 过滤掉无 id 的条目', () => { + const ids = idsToMarkRead('unread', [ + mail({ mail_id: 'a' }), + mail({ mail_id: '' }), + mail({ mail_id: undefined }), + { }, + ]); + assert.deepEqual(ids, ['a']); +}); + +test('idsToMarkRead 容错非数组', () => { + assert.deepEqual(idsToMarkRead('unread', undefined), []); + assert.deepEqual(idsToMarkRead('unread', 'oops'), []); +}); + +// ─── 默认值 ─── + +test('默认只看未读', () => { + // 默认 all 会让模型每轮重读旧邮件,把处理过的和新来的混在一起。 + assert.equal(DEFAULT_INBOX_STATUS, 'unread'); +}); + +test('默认条数是个小数字', () => { + // 收件箱一次给几十封会把上下文塞满,而模型一轮通常只处理一两封。 + assert.ok(DEFAULT_INBOX_LIMIT > 0 && DEFAULT_INBOX_LIMIT <= 10); +}); diff --git a/plugins/opencode-mail-bridge/test/workspace.test.mjs b/plugins/opencode-mail-bridge/test/workspace.test.mjs new file mode 100644 index 0000000..4913747 --- /dev/null +++ b/plugins/opencode-mail-bridge/test/workspace.test.mjs @@ -0,0 +1,128 @@ +/** + * 工作目录解析的回归测试。 + * + * 这是「dsh 指定工作目录完全失效,所有对话都落在未分组下」那次故障的直接回归: + * 插件曾无视寻址里的 path 位,每封邮件自己拼一个 ~/.dsh/mail-sessions/mail-, + * 而 DSH 按 cwd 分组,于是所有邮件会话既不属于任何项目、彼此也不同组。 + * + * node --test test/ + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir, homedir } from 'node:os'; +import { join } from 'node:path'; +import { resolveWorkspaceCwd, ensureCwd, mailSessionFallback } from '../lib/workspace.js'; + +// 兜底目录现在由调用方给(各平台不同)。DSH 用 mailSessionFallback, +// opencode 用插件启动时的 directory。 +const fallbackOf = key => mailSessionFallback(key); + +test('存在的绝对路径直接用作 cwd', () => { + const dir = mkdtempSync(join(tmpdir(), 'ws-test-')); + try { + const got = resolveWorkspaceCwd(dir, fallbackOf('mail-1')); + assert.equal(got.cwd, dir); + assert.equal(got.grouped, true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('不变量:同一 path 的多封邮件得到同一个 cwd(这才能同组)', () => { + const dir = mkdtempSync(join(tmpdir(), 'ws-test-')); + try { + const a = resolveWorkspaceCwd(dir, fallbackOf('mail-aaa')); + const b = resolveWorkspaceCwd(dir, fallbackOf('mail-bbb')); + assert.equal(a.cwd, b.cwd, 'fallbackKey 不同却应得到同一个 cwd'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('path 为空时回退到兜底目录', () => { + const got = resolveWorkspaceCwd('', fallbackOf('mail-2')); + assert.equal(got.cwd, fallbackOf('mail-2')); + assert.equal(got.grouped, false); +}); + +test('path 缺失/非字符串时回退', () => { + for (const v of [undefined, null, 42, {}]) { + const got = resolveWorkspaceCwd(v, fallbackOf('mail-3')); + assert.equal(got.grouped, false); + assert.equal(got.cwd, fallbackOf('mail-3')); + } +}); + +test('不变量:不存在的目录不创建,回退到兜底', () => { + // 一个笔误(/home/porgram/x)不该在磁盘上落下真目录 —— + // Agent 会在里面一无所获地干活,比明确回退更难排查。 + const got = resolveWorkspaceCwd('/nonexistent/path/xyz-should-not-exist', fallbackOf('mail-4')); + assert.equal(got.grouped, false); + assert.equal(got.cwd, fallbackOf('mail-4')); +}); + +test('不变量:相对路径被拒绝', () => { + // cwd 的相对基准是 harness 进程的启动目录,systemd 下通常是 /, + // 那是个与邮件语义完全无关的量。 + for (const rel of ['relative/path', './x', '../y', 'src']) { + const got = resolveWorkspaceCwd(rel, fallbackOf('mail-5')); + assert.equal(got.grouped, false, `${rel} 不该被当作工作目录`); + } +}); + +test('指向文件而非目录时回退', () => { + const dir = mkdtempSync(join(tmpdir(), 'ws-test-')); + const file = join(dir, 'a-file'); + writeFileSync(file, 'x'); + try { + const got = resolveWorkspaceCwd(file, fallbackOf('mail-6')); + assert.equal(got.grouped, false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('两端空白被修掉', () => { + const dir = mkdtempSync(join(tmpdir(), 'ws-test-')); + try { + const got = resolveWorkspaceCwd(` ${dir} `, fallbackOf('mail-7')); + assert.equal(got.cwd, dir); + assert.equal(got.grouped, true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('ensureCwd 只建兜底目录,不碰寻址指定的目录', () => { + const base = mkdtempSync(join(tmpdir(), 'ws-ensure-')); + try { + const target = join(base, 'made-by-ensure'); + ensureCwd(target, false); + // 建出来了 + const got = resolveWorkspaceCwd(target, ''); + assert.equal(got.grouped, true, 'ensureCwd 应已创建该目录'); + + // grouped=true 时不该创建(那种目录本来就存在) + const never = join(base, 'should-not-exist'); + ensureCwd(never, true); + assert.equal(resolveWorkspaceCwd(never, '').grouped, false); + } finally { + rmSync(base, { recursive: true, force: true }); + } +}); + +test('兜底为空串时返回空 cwd(交给平台自己决定)', () => { + // opencode 没配 directory 时就是这种情况:session.create 不带 query.directory, + // 由平台按自己的默认规则选目录。比硬塞一个我们猜的路径好。 + const got = resolveWorkspaceCwd('', ''); + assert.equal(got.cwd, ''); + assert.equal(got.grouped, false); +}); + +test('mailSessionFallback 同一 key 稳定、不同 key 不同', () => { + assert.equal(mailSessionFallback('a'), mailSessionFallback('a')); + assert.notEqual(mailSessionFallback('a'), mailSessionFallback('b')); + assert.match(mailSessionFallback('a'), /mail-sessions/); +});