From e6fd2fafdc04f0dbfaa28e3231c0d00880df67ab Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 3 Sep 2026 12:09:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20agent=20=E9=82=AE=E4=BB=B6=E5=AF=BB?= =?UTF-8?q?=E5=9D=80=E8=83=BD=E5=8A=9B=E5=85=A8=E9=9D=A2=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=20+=20.new=20=E5=88=AB=E5=90=8D=E6=9B=BF=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 别名替换(让 .new 邮件可寻址) repo/autoalias.go: AutoAliasFor + EnsureSessionAlias - .new 建完会话立刻给别名(形如 dsh-重构导入路径) - 名字与主题都要:只用主题跨 Agent 撞名,只用名字看不出聊什么 - sanitizeAliasPart 只留 unicode.IsLetter/IsDigit,其余折 - - 撞名追加 -2/-3,全占用退 session- - 不复用 SyncSessionAlias:那个假定已存在且跳过 manual - 条件写入 WHERE alias IS NULL OR '',并发安全 - resolveTarget 的 .new 与默认会话两条路径都调 notifyRecipients 加三个字段(每个收件方拿到自己那个地址的版本): - session_alias / reply_address / self_address - 别名为空时退回省略 session 位,绝不写 new FormatAddress(name,path,session) 空 path 也必须留 @ 与 . ## Agent 侧寻址发现(五个只读端点) handler/agent_discovery.go: - /agent/contacts + /agent/contacts/suggest(三段式补全) - /agent/mail/{id} + /agent/mail/{id}/thread - /agent/sessions/{id}/participants - 不复用人类路由:scope 不同、审计需求不同 - 一律只读:归档/改名/权限决策仍只有人能做 repo/participants.go: SessionParticipants 逐封扫 from/to/cc - Roles 用集合、MailCount 只数发信(0=还没开口的人) - 发件人 path 不取 from_workspace(那列存的是 Agent 名) repo.SuggestPaths 重写:mails.to_workspace(按 MAX(created_at) 倒序) + agents.workspaces 并集。原只读 workspaces,官方插件传 [] 永远空 ## 共用模块(三插件逐字节相同) lib/addressing.js: formatAddress/roleOf/replyAddressFor/selfAddressFor/participantsOfMail lib/discovery.js: renderNameSuggestions/renderPathSuggestions/renderSessionSuggestions/ renderParticipants/renderContacts/renderThread lib/inbox-format.js: renderMail 新增收件人/身份/可投递地址三段 - selfName 参数(兼容旧调用不传的情况) check-shared-libs.sh 纳入 addressing + discovery ## 插件侧 opencode: suggest_address + list_contacts + session_participants + read_thread + read_mail dsh: 同上 + forward_mail(此前只有 opencode 有)+ upload_attachment 改真 multipart pi: 同上(createMailTools 加 agentName 参数) dsh: ctx.agents.create id collision 改为 readSession 探测后 resume dsh: 关键路径日志改 console.error(ctx.logger 不进 journalctl) ## 测试 repo: autoalias_test.go 11 + participants_test.go 7 = 18 例 plugins: addressing.test 17 + discovery.test 23 + inbox-format.test 31 = 71 例 go test ./... + npm test(opencode 155 + dsh 173 + pi 199)全绿 端到端验证:admin 发 dsh@....new 抄送 opencode@....new → dsh 用 session_participants 取到地址 → send_mail 给 opencode → 地址取自工具返回值(.crisp-planet),未手工拼写 --- README.md | 37 +- deploy/check-shared-libs.sh | 51 +- deploy/install.sh | 53 +- deploy/pi-mail-bridge.service | 37 + docs/PLUGIN-CONTRACT.md | 173 ++- gateway/cmd/server/main.go | 20 + gateway/internal/handler/agent_discovery.go | 330 +++++ gateway/internal/handler/agents.go | 66 + gateway/internal/handler/forward.go | 2 +- gateway/internal/handler/mail.go | 50 +- gateway/internal/handler/me.go | 2 +- gateway/internal/handler/thread.go | 17 +- gateway/internal/models/address.go | 30 + gateway/internal/repo/agent_disable_test.go | 253 ++++ gateway/internal/repo/autoalias.go | 177 +++ gateway/internal/repo/autoalias_test.go | 283 ++++ gateway/internal/repo/participants.go | 135 ++ gateway/internal/repo/participants_test.go | 170 +++ gateway/internal/repo/quota.go | 11 +- gateway/internal/repo/repo.go | 184 ++- plugins/dsh-mail-bridge/lib/addressing.d.ts | 13 + plugins/dsh-mail-bridge/lib/addressing.js | 141 ++ plugins/dsh-mail-bridge/lib/discovery.d.ts | 6 + plugins/dsh-mail-bridge/lib/discovery.js | 237 ++++ plugins/dsh-mail-bridge/lib/inbox-format.d.ts | 4 +- plugins/dsh-mail-bridge/lib/inbox-format.js | 62 +- plugins/dsh-mail-bridge/lib/model-scope.d.ts | 1 + plugins/dsh-mail-bridge/lib/model-scope.js | 31 + .../dsh-mail-bridge/lib/session-snapshot.d.ts | 7 + .../dsh-mail-bridge/lib/session-snapshot.js | 85 +- plugins/dsh-mail-bridge/src/index.ts | 307 +++- .../dsh-mail-bridge/test/addressing.test.mjs | 145 ++ .../dsh-mail-bridge/test/discovery.test.mjs | 218 +++ .../test/inbox-format.test.mjs | 90 ++ .../dsh-mail-bridge/test/model-scope.test.mjs | 41 + .../test/session-snapshot.test.mjs | 98 ++ plugins/opencode-mail-bridge/index.js | 144 +- .../opencode-mail-bridge/lib/addressing.js | 141 ++ plugins/opencode-mail-bridge/lib/discovery.js | 237 ++++ .../opencode-mail-bridge/lib/inbox-format.js | 62 +- .../opencode-mail-bridge/lib/model-scope.js | 31 + .../lib/session-snapshot.js | 85 +- .../test/addressing.test.mjs | 145 ++ .../test/discovery.test.mjs | 218 +++ .../test/inbox-format.test.mjs | 90 ++ .../test/model-scope.test.mjs | 41 + .../test/session-snapshot.test.mjs | 98 ++ plugins/pi-mail-bridge/lib/addressing.js | 141 ++ plugins/pi-mail-bridge/lib/catchup.js | 74 + plugins/pi-mail-bridge/lib/discovery.js | 237 ++++ plugins/pi-mail-bridge/lib/inbox-format.js | 144 ++ plugins/pi-mail-bridge/lib/model-scope.js | 169 +++ plugins/pi-mail-bridge/lib/relay-dedup.js | 58 + .../pi-mail-bridge/lib/session-snapshot.js | 234 ++++ plugins/pi-mail-bridge/lib/workspace.js | 77 + plugins/pi-mail-bridge/package.json | 17 + plugins/pi-mail-bridge/src/gateway.mjs | 235 ++++ plugins/pi-mail-bridge/src/index.mjs | 693 +++++++++ plugins/pi-mail-bridge/src/naming.mjs | 115 ++ plugins/pi-mail-bridge/src/session-pool.mjs | 137 ++ plugins/pi-mail-bridge/src/tools.mjs | 355 +++++ plugins/pi-mail-bridge/src/turn.mjs | 187 +++ .../pi-mail-bridge/test/addressing.test.mjs | 145 ++ plugins/pi-mail-bridge/test/catchup.test.mjs | 81 ++ .../pi-mail-bridge/test/discovery.test.mjs | 218 +++ .../pi-mail-bridge/test/inbox-format.test.mjs | 266 ++++ .../pi-mail-bridge/test/model-scope.test.mjs | 248 ++++ plugins/pi-mail-bridge/test/naming.test.mjs | 185 +++ .../test/session-snapshot.test.mjs | 326 +++++ plugins/pi-mail-bridge/test/turn.test.mjs | 254 ++++ .../pi-mail-bridge/test/workspace.test.mjs | 128 ++ web/package-lock.json | 1240 ++++++++++++++++- web/package.json | 14 +- web/src/components/MailView.tsx | 9 +- web/src/components/WorkCard.tsx | 4 +- web/test/components/AddressInput.test.tsx | 264 ++++ web/test/components/BudgetChip.test.tsx | 80 ++ web/test/components/PermissionPanel.test.tsx | 203 +++ web/test/components/setup.ts | 45 + web/tsconfig.json | 8 +- web/vitest.config.ts | 27 + 81 files changed, 11355 insertions(+), 122 deletions(-) create mode 100644 deploy/pi-mail-bridge.service create mode 100644 gateway/internal/handler/agent_discovery.go create mode 100644 gateway/internal/repo/agent_disable_test.go create mode 100644 gateway/internal/repo/autoalias.go create mode 100644 gateway/internal/repo/autoalias_test.go create mode 100644 gateway/internal/repo/participants.go create mode 100644 gateway/internal/repo/participants_test.go create mode 100644 plugins/dsh-mail-bridge/lib/addressing.d.ts create mode 100644 plugins/dsh-mail-bridge/lib/addressing.js create mode 100644 plugins/dsh-mail-bridge/lib/discovery.d.ts create mode 100644 plugins/dsh-mail-bridge/lib/discovery.js create mode 100644 plugins/dsh-mail-bridge/test/addressing.test.mjs create mode 100644 plugins/dsh-mail-bridge/test/discovery.test.mjs create mode 100644 plugins/opencode-mail-bridge/lib/addressing.js create mode 100644 plugins/opencode-mail-bridge/lib/discovery.js create mode 100644 plugins/opencode-mail-bridge/test/addressing.test.mjs create mode 100644 plugins/opencode-mail-bridge/test/discovery.test.mjs create mode 100644 plugins/pi-mail-bridge/lib/addressing.js create mode 100644 plugins/pi-mail-bridge/lib/catchup.js create mode 100644 plugins/pi-mail-bridge/lib/discovery.js create mode 100644 plugins/pi-mail-bridge/lib/inbox-format.js create mode 100644 plugins/pi-mail-bridge/lib/model-scope.js create mode 100644 plugins/pi-mail-bridge/lib/relay-dedup.js create mode 100644 plugins/pi-mail-bridge/lib/session-snapshot.js create mode 100644 plugins/pi-mail-bridge/lib/workspace.js create mode 100644 plugins/pi-mail-bridge/package.json create mode 100644 plugins/pi-mail-bridge/src/gateway.mjs create mode 100644 plugins/pi-mail-bridge/src/index.mjs create mode 100644 plugins/pi-mail-bridge/src/naming.mjs create mode 100644 plugins/pi-mail-bridge/src/session-pool.mjs create mode 100644 plugins/pi-mail-bridge/src/tools.mjs create mode 100644 plugins/pi-mail-bridge/src/turn.mjs create mode 100644 plugins/pi-mail-bridge/test/addressing.test.mjs create mode 100644 plugins/pi-mail-bridge/test/catchup.test.mjs create mode 100644 plugins/pi-mail-bridge/test/discovery.test.mjs create mode 100644 plugins/pi-mail-bridge/test/inbox-format.test.mjs create mode 100644 plugins/pi-mail-bridge/test/model-scope.test.mjs create mode 100644 plugins/pi-mail-bridge/test/naming.test.mjs create mode 100644 plugins/pi-mail-bridge/test/session-snapshot.test.mjs create mode 100644 plugins/pi-mail-bridge/test/turn.test.mjs create mode 100644 plugins/pi-mail-bridge/test/workspace.test.mjs create mode 100644 web/test/components/AddressInput.test.tsx create mode 100644 web/test/components/BudgetChip.test.tsx create mode 100644 web/test/components/PermissionPanel.test.tsx create mode 100644 web/test/components/setup.ts create mode 100644 web/vitest.config.ts diff --git a/README.md b/README.md index 60d7488..ec1e8a1 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,11 @@ 会话别名负责寻址,因此全局唯一。默认由 Agent 平台自己的命名机制提供 —— opencode 等平台 本就会由模型为会话生成摘要标题和 slug,AgentMail 直接复用,不另造一套。 +平台没有可用名字时(例如 pi 用 SDK 起的会话),桥退一级用邮件主题派生别名, +再把服务端**定稿**的那个值写回平台。定稿而非各自命名,是因为别名要保证唯一: +撞名时服务端会追 `-2`,而人在界面上手工改过的别名永远优先 —— 两侧各自命名的话, +邮箱里显示 `fix-leak-2`、平台里显示 `fix-leak`,按界面上看到的名字发信会「无法送达」。 + ## 快速开始 ### 开发 @@ -83,6 +88,14 @@ DATABASE_URL= # 留空 = 内置 SQLit ### 接入 Agent +已有三个平台的桥接实现: + +| 平台 | 形态 | 接入方式 | +|---|---|---| +| opencode | 插件 | 配置的 `plugin` 列表里加本地路径 | +| DeepSeek Harness | Cordis 插件 | profile 的 `cordis.patch.yml` | +| pi | **常驻守护进程** | `systemctl enable --now pi-mail-bridge` | + 以 opencode 为例: ```bash @@ -90,17 +103,23 @@ DATABASE_URL= # 留空 = 内置 SQLit "plugin": ["file:///path/to/agentmail/plugins/opencode-mail-bridge"] ``` +pi 不是插件而是独立服务,因为 pi 扩展被加载进**一条已存在的**会话, +而三维地址要求每封邮件的 `path` 位成为会话工作目录 —— 扩展改不了这一点。 +桥用 pi 的 SDK(`createAgentSession`)按邮件起会话,一个进程里并存多条 +不同工作目录的会话。`deploy/install.sh` 会装好它的 systemd 单元。 + 插件提供六个工具(`send_mail` / `read_inbox` / `forward_mail` / `upload_attachment` / `download_attachment` / `connect_to_server`),并通过 SSE 监听新邮件: -收到邮件时自动在 opencode 侧开会话处理,回信落回同一邮件会话。 +收到邮件时自动在平台侧开会话处理,回信落回同一邮件会话。 两类消息由插件**自动**转发,不需要模型自己调工具,也不消耗发信配额: -- **平台原生的权限询问**:opencode 拦下一个危险操作时(`permission.ask`), - 插件把它转成邮件问人,人在网页上点「同意/一直同意/拒绝」,插件再回复 opencode 让它继续。 +- **平台原生的权限询问**:平台拦下一个危险操作时(opencode 的 `permission.ask`、 + DSH 的 `approval/request`、pi 的 `tool_call` 钩子),插件把它转成邮件问人, + 人在网页上点「同意/一直同意/拒绝」,插件再回复平台让它继续。 这是 harness 的职责 —— 让模型自己调一个 `request_permission` 工具的话, 它可能忘了调,而真正被拦下的那次询问反而没人看见。 -- **本轮的最终总结**:一轮跑完(`session.idle`)时把最后那段话作为回信发回去。 +- **本轮的最终总结**:一轮跑完时把最后那段话作为回信发回去。 模型已经把话说完了,插件只是搬运。 配额约束的是**模型的自主发信**,不是 harness 的转发 —— 否则配额用尽时 Agent 连交代都做不了。 @@ -120,7 +139,8 @@ DATABASE_URL= # 留空 = 内置 SQLit 三种生命周期:`permanent`(长期)/ `one_time`(首次使用后失效)/ `timed`(限时)。 -环境变量见 `deploy/install.sh` 生成的 `/etc/agentmail/opencode.env`。 +环境变量见 `deploy/install.sh` 生成的 `/etc/agentmail/opencode.env` +与 `/etc/agentmail/pi.env`。 ## 项目结构 @@ -140,9 +160,10 @@ agentmail/ │ ├── middleware/ # Agent / 用户双认证 │ ├── sse/ # 事件推送(按收件人分流) │ └── static/ # go:embed 的前端产物 -├── plugins/ # 各平台桥接插件(lib/ 下的纯函数模块逐字节共用) -│ ├── opencode-mail-bridge/ # opencode -│ └── dsh-mail-bridge/ # DeepSeek Harness(Cordis) +├── plugins/ # 各平台桥接(lib/ 下的纯函数模块逐字节共用) +│ ├── opencode-mail-bridge/ # opencode(插件) +│ ├── dsh-mail-bridge/ # DeepSeek Harness(Cordis 插件) +│ └── pi-mail-bridge/ # pi(常驻守护进程,用 SDK 起会话) ├── web/ # 前端(React + Vite + Tailwind) │ └── test/manual/ # 浏览器实测脚本(量真实盒子与命中区,不进 npm test) └── deploy/ # systemd 单元 + 安装脚本 diff --git a/deploy/check-shared-libs.sh b/deploy/check-shared-libs.sh index 7dde1d3..d8862d2 100755 --- a/deploy/check-shared-libs.sh +++ b/deploy/check-shared-libs.sh @@ -1,23 +1,42 @@ #!/usr/bin/env bash # 共用模块必须逐字节相同 —— 见 docs/PLUGIN-CONTRACT.md 第六节。 # -# 一侧改了另一侧没改,两个平台的行为就会悄悄分叉:同一封邮件在 opencode 那边 +# 一侧改了另一侧没改,几个平台的行为就会悄悄分叉:同一封邮件在 opencode 那边 # 标了已读、在 DSH 那边没标,而两处代码看起来都"对"。 +# +# 三方比对以 opencode 为基准逐个对比,而不是两两对比:后者在三方都不同时 +# 会打出三条互相矛盾的差异,读的人无从判断谁是对的。 set -euo pipefail -A=plugins/opencode-mail-bridge -B=plugins/dsh-mail-bridge +BASE=plugins/opencode-mail-bridge +PEERS=(plugins/dsh-mail-bridge plugins/pi-mail-bridge) fail=0 -for f in relay-dedup inbox-format session-snapshot workspace model-scope catchup; 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 + +for peer in "${PEERS[@]}"; do + for f in relay-dedup inbox-format session-snapshot workspace model-scope catchup addressing discovery; do + if [[ ! -f "$peer/lib/$f.js" ]]; then + echo "共用模块缺失:$peer/lib/$f.js" >&2 + fail=1 + continue + fi + if ! diff -q "$BASE/lib/$f.js" "$peer/lib/$f.js" >/dev/null 2>&1; then + echo "共用模块已分叉:lib/$f.js($BASE vs $peer)" >&2 + diff "$BASE/lib/$f.js" "$peer/lib/$f.js" | head -20 >&2 + fail=1 + fi + done + # 测试同样要同源:共用模块的行为约定写在测试里, + # 只同步实现不同步测试,等于允许一侧偷偷放宽约定。 + for f in inbox-format session-snapshot workspace model-scope catchup addressing discovery; do + if [[ ! -f "$peer/test/$f.test.mjs" ]]; then + echo "共用测试缺失:$peer/test/$f.test.mjs" >&2 + fail=1 + continue + fi + if ! diff -q "$BASE/test/$f.test.mjs" "$peer/test/$f.test.mjs" >/dev/null 2>&1; then + echo "共用测试已分叉:test/$f.test.mjs($BASE vs $peer)" >&2 + fail=1 + fi + done done -for f in inbox-format session-snapshot workspace model-scope catchup; 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 + +[[ $fail -eq 0 ]] && echo " 共用模块三方同源(opencode / dsh / pi)" || exit 1 diff --git a/deploy/install.sh b/deploy/install.sh index ac53c33..2111ce9 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# 把 AgentMail Gateway 与 opencode serve 安装为 systemd 服务。 +# 把 AgentMail Gateway 与各平台的 mail-bridge 安装为 systemd 服务。 # # sudo ./deploy/install.sh # @@ -19,8 +19,8 @@ echo "==> 构建前端" ( cd "$REPO/web" && npm run typecheck && npm test && npm run build ) echo "==> 校验插件共用模块同源" -# lib/ 下的纯函数模块在两个插件里逐字节相同(见 docs/PLUGIN-CONTRACT.md 第六节)。 -# 一侧改了另一侧没改,两个平台的行为就会悄悄分叉。 +# lib/ 下的纯函数模块在三个插件里逐字节相同(见 docs/PLUGIN-CONTRACT.md 第六节)。 +# 一侧改了另一侧没改,几个平台的行为就会悄悄分叉。 "$REPO/deploy/check-shared-libs.sh" # 插件的纯函数测试(自动转发去重等)。 @@ -45,6 +45,20 @@ else ( cd "$REPO/plugins/dsh-mail-bridge" \ && npm install --no-audit --no-fund && npx tsc && npm test ) fi +# pi 插件:纯 ESM,无构建步骤,唯一的外部依赖是全局装的 pi SDK。 +# +# 它不 npm install:@earendil-works/pi-coding-agent 是全局包(peerDependency), +# 装在项目里会得到第二份 SDK,两份各自维护 ~/.pi/agent 的会话索引缓存。 +# 因此这里只软链一次;SDK 不在时跳过测试(turn/naming 是纯函数, +# 但 lib 的测试也一起跑,没必要为缺 SDK 的机器留半套门禁)。 +PI_SDK=/usr/lib/node_modules/@earendil-works/pi-coding-agent +if [[ -d "$PI_SDK" ]]; then + install -d "$REPO/plugins/pi-mail-bridge/node_modules/@earendil-works" + ln -sfn "$PI_SDK" "$REPO/plugins/pi-mail-bridge/node_modules/@earendil-works/pi-coding-agent" + ( cd "$REPO/plugins/pi-mail-bridge" && npm test ) +else + echo " 未找到 pi SDK($PI_SDK),跳过 pi 插件测试" +fi echo "==> 前端产物嵌入 Gateway" # 只清构建产物,不能 rm -rf 整个目录: @@ -110,9 +124,37 @@ else echo " $ETC/opencode.env 已存在,保留不动" fi +if [[ ! -f "$ETC/pi.env" ]]; then + cat > "$ETC/pi.env" < 安装 systemd 单元" install -m 0644 "$REPO/deploy/agentmail-gateway.service" /etc/systemd/system/ install -m 0644 "$REPO/deploy/opencode-serve.service" /etc/systemd/system/ +install -m 0644 "$REPO/deploy/pi-mail-bridge.service" /etc/systemd/system/ systemctl daemon-reload echo "==> 启用并启动" @@ -122,6 +164,11 @@ if command -v opencode >/dev/null 2>&1; then else echo " 未找到 opencode,跳过 opencode-serve(装好后执行:systemctl enable --now opencode-serve)" fi +if [[ -d "$PI_SDK" ]]; then + systemctl enable --now pi-mail-bridge.service +else + echo " 未找到 pi SDK,跳过 pi-mail-bridge(装好后执行:systemctl enable --now pi-mail-bridge)" +fi sleep 3 echo diff --git a/deploy/pi-mail-bridge.service b/deploy/pi-mail-bridge.service new file mode 100644 index 0000000..3ccc614 --- /dev/null +++ b/deploy/pi-mail-bridge.service @@ -0,0 +1,37 @@ +[Unit] +Description=pi mail-bridge (AgentMail ↔ @earendil-works/pi-coding-agent) +After=network-online.target agentmail-gateway.service +Wants=network-online.target + +[Service] +Type=simple + +# 桥自己不需要工作目录 —— 每条会话的 cwd 来自邮件寻址的 path 位。 +# 但 systemd 要求一个存在的目录,且 pi 的 SettingsManager 会在这里找 +# 项目级配置,因此指向仓库而不是 /。 +WorkingDirectory=/home/program/agentmail/plugins/pi-mail-bridge +ExecStart=/usr/bin/node /home/program/agentmail/plugins/pi-mail-bridge/src/index.mjs + +# pi 靠 HOME 定位 ~/.pi/agent(settings.json、auth.json、models.json、sessions/)。 +# systemd 不会自动注入 HOME,不显式给就: +# - 读不到 provider 凭证 → 每封邮件都"没有可用模型" +# - 会话落到 /.pi 或直接失败 → C-8 的会话快照永远是空的 +Environment=HOME=/root + +# pi 的全局扩展 pi-a2a / pi-acp 绑死 127.0.0.1:12010 / 12011。 +# 桥用 noExtensions:true 起会话,本进程不会去 bind 那两个端口; +# 这两个变量是给「同机还跑着 pi CLI」的情况留的隔离位, +# 万一将来放开扩展加载,端口也不会和交互式 pi 撞。 +Environment=PI_A2A_PORT=13010 +Environment=PI_ACP_PORT=13011 + +EnvironmentFile=/etc/agentmail/pi.env + +Restart=always +RestartSec=10 + +# 桥是长驻守护进程,启动即注册 + 立刻打一次心跳 + 订阅 SSE(B-1), +# 不像 opencode 那样惰加载,因此不需要 ExecStartPost 预热。 + +[Install] +WantedBy=multi-user.target diff --git a/docs/PLUGIN-CONTRACT.md b/docs/PLUGIN-CONTRACT.md index b00f64b..95562ff 100644 --- a/docs/PLUGIN-CONTRACT.md +++ b/docs/PLUGIN-CONTRACT.md @@ -96,11 +96,20 @@ name@path.session | **C-8** | 权限/审批钩子 | `permission_hook` | 不转发权限询问 | 危险操作只能靠平台本地 UI 决策 | | **C-9** | 钩子可异步等待 | `async_permission` | 转出去后立即返回「待决」,决策经 SSE 回来后再补 | 模型会先被拒一次再重试 | | **C-10** | 列出会话 | `list_sessions` | 不上报平台会话快照 | 写信时只能续谈邮件驱动的会话 | -| **C-11** | 模型标题 | `model_title` | 会话别名退化为随机短名或平台 id | 补全列表里分不清哪条在谈什么 | +| **C-11** | 模型标题 | `model_title` | 走 `D-5` 阶梯:退到邮件主题派生别名 | 补全列表里的名字来自人写的主题,而非模型对内容的概括 | | **C-12** | 列出可用模型 | `list_models` | 不上报模型目录 | 管理员无法在配置页划定模型范围 | | **C-13** | 指定单轮模型 | `per_turn_model` | 不做降级尝试 | 主力模型故障时该 Agent 整体不可用 | | **C-14** | 会话内文件读写工具 | `file_tools` | 附件下载后模型看不到 | 附件功能形同虚设 | +> **C-11 可能是「部分具备」。** pi 是实例:它确实会生成会话标题,但生成器在 +> **pi-web** 包里,不在 `pi-coding-agent` 内核里。人在 pi-web 界面上开的会话有标题, +> 桥用 SDK 起的会话没有。判定的依据只能是**桥自己起的那条会话**上 +> `sessionName` 到底有没有值,不是「这个平台有没有这个功能」。 +> +> 具备 `C-11` 的平台还要回答第二个问题:**别名由谁定稿**。别名负有寻址唯一性义务 +> (撞名追 `-2`、`manual` 来源永远优先),平台侧没有这个约束。两侧各自命名会分叉, +> 因此必须由服务端定稿、插件把响应里的值**回写**进平台(见 `D-5`、`W-7`)。 + ### 能力自检脚本 接入前先回答这七个问题。任何一个答不出来,先去读平台文档,不要开始写代码: @@ -113,10 +122,15 @@ name@path.session 5. 怎么取到最后一条 assistant 消息的纯文本? → C-5 6. 注册工具时 execute 能拿到 session id 吗? → C-6 7. 权限钩子是同步还是异步?能不能真的等人? → C-8/C-9 +8. 往一条**空闲**会话里注入一轮,用的是哪个方法? → C-1(见 9.11) ``` -> 问题 4 在两次适配里都被漏掉过,两次都造成「无效模型被判成成功」—— +> 问题 4 在三次适配里被漏掉过两次,两次都造成「无效模型被判成成功」—— > 详见 `D-3` 与第九节。 +> +> 问题 8 是 pi 适配加上的:平台常常有两个注入入口(一个起新轮、一个往正在跑的轮次里 +> 排队),而排队那个在会话空闲时**静默什么也不做** —— 不报错、不超时、没有回信。 +> 见 9.11。 --- @@ -443,17 +457,68 @@ SSE 只推连上之后的事件。插件重启前发来的邮件不会再推一 心跳里**省略** `platform_sessions`(不是传 `[]`,见 `W-3`)。 后果:写信时的会话补全只能看到邮件驱动的那些。 -### D-5 无模型标题(缺 `C-11`) +### D-5 别名来源阶梯(`C-11` 缺失或不可用时) -| 优先级 | 别名来源 | -|---|---| -| 1 | 平台自带的 slug(如 `witty-planet`) | -| 2 | 由模型标题派生(`slugFromTitle`) | -| 3 | 不回写,保持服务端自动生成的别名 | +| 优先级 | 别名来源 | 何时用 | +|---|---|---| +| 1 | 平台自带的 slug(如 `witty-planet`) | 平台创建会话时就给 | +| 2 | 由平台标题派生(`slugFromTitle`) | 有标题且标题可用 | +| 3 | 由**邮件主题**派生 | 平台没有标题,或标题不可用(见下) | +| 4 | 不回写,保持服务端自动生成的别名 | 以上都没有 | **不得**用占位标题回写(`"新会话"`、`"处理邮件"` 之类):那会覆盖掉一个 本来可能更有意义的名字,且之后平台真的生成标题时无从判断该不该覆盖。 +> **第 3 级是 pi 适配加上的。** 原先的阶梯假定「平台要么有标题,要么完全没有命名机制」。 +> pi 是第三种情况:内核不生成标题(生成器在 pi-web 里),于是 SDK 起的会话 +> `sessionName` 恒为 `undefined`。只走前两级的话别名永远是空的 —— +> `name@path.<别名>` 续谈无从下手,`.new` 又是一次性的,那条会话事实上只能收一封信。 +> +> **「标题不可用」是真实存在的一类。** pi-web 的标题生成器会泄漏思维链, +> 本机 82 条会话里捞到过这两条真实样本: +> +> ``` +> The user is asking me to generate a title for a coding-agent +> 我们只需要生成标题,不包含其他内容。标题应反映请求内容:测试opencode的源。简短:最 +> ``` +> +> 它们语法上是合法标题(`cleanSessionName` 只取首行 + 截 60 字符), +> 派生出的别名却是一句废话。判废逻辑在 `lib/session-snapshot.js` 的 +> `isUnusableName()`:命中就降到下一级,**不是**改写成别的。 + +#### 别名必须由服务端定稿 + +具备 `C-11` 的平台也要走这一步。插件提议、服务端定稿、插件把响应里的值回写: + +``` +插件观察到平台的名字 + → POST /sessions/{id}/sync { alias: slug, title: 原文 } + → 读**响应里**的 alias(可能被改写过) + → 与平台当前名字不同 → 写回平台 +``` + +两个原因让单向推送无法收敛: + +| 服务端行为 | 后果 | +|---|---| +| 别名撞名时追 `-2`、`-3`(`SyncSessionAlias`) | 平台侧没有唯一性约束,不会跟着改 | +| `alias_source='manual'`(人手工改过)永远优先 | 服务端**原样返回当前别名**,忽略提议 | + +不回写的话:邮箱里显示 `fix-leak-2`、平台界面上显示 `fix-leak`, +用户按界面上看到的名字发信会收到「无法送达」。 + +回写有三条实测约束(pi 的形态,其它平台需各自确认): + +| # | 约束 | 违反的后果 | +|---|---|---| +| 1 | 只能用平台的改名 API,不能自己写会话文件 | 首条 assistant 消息落盘前文件不存在,pi 首次落盘用 `openSync(file,"wx")`,抢先创建让它抛 `EEXIST` | +| 2 | 活着的会话管理器从不重读文件 | 它自己后续的 `session_info` 会覆盖外部改名 | +| 3 | 空名字是**清除**语义 | 「没拿到定稿值」绝不能落成一次空写入 | + +还要防自激循环:改名通常会触发「名字变了」事件,而那个事件正是同步的触发源。 +插件必须记住**上次提交的内容指纹**并在相同时跳过。指纹不能用平台名字本身 —— +名字为空时(上面 pi 的常态)它无法区分「还没提交过」与「提交过、内容没变」。 + ### D-6 工作目录不存在 | # | 要求 | 强度 | @@ -584,7 +649,10 @@ Agent 侧只会收到两个事件: "mail_id": "9ea5ff18-...", "session_id": "7b5081e0-...", "from_name": "admin", "subject": "重构导入路径", "mail_type": "normal", "role": "to", - "to_workspace": "/home/program/agentmail" + "to_workspace": "/home/program/agentmail", + "session_alias": "refactor-imports", + "reply_address": "admin@.refactor-imports", + "self_address": "pi@/home/program/agentmail.refactor-imports" } ``` @@ -593,6 +661,13 @@ Agent 侧只会收到两个事件: | `role` | `to` 或 `cc` | | `to_workspace` | **收件方那个地址的 path 位**(抄送方拿到的是自己那个地址的) | | `mail_type` | `normal` / `permission_request` / … | +| `session_alias` | 这条会话今后的寻址名 | +| `reply_address` | 「把回信发回这条会话」的现成地址 | +| `self_address` | 对方应当用来称呼自己的地址,供转发/报告时引用 | + +> **`reply_address` 应当放进提示词。** 插件会自动转发本轮总结(`B-5`), +> 但模型仍然会主动发信 —— 要抄送第三方、或分多封交代不同的事时。让它自己拼三维地址 +> 的话,`.new` 会被拼进去,于是回信静默开出一条**新**会话,原来的线索里再无下文。 **`permission_decision`** @@ -911,21 +986,38 @@ GET /api/v1/attachments/{id} ## 八、平台差异对照 / Platform Matrix -两次真实适配的对照表。接新平台时逐行回答「我这边是什么」。 +三次真实适配的对照表。接新平台时逐行回答「我这边是什么」。 -| 关注点 | 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` | -| 模型失败信号 | `session.error` 事件 | `turn/end` 的 `reason.kind === 'error'` | -| 权限钩子 | `permission.ask`(**同步,不能等**) | `approval/request`(异步 waterfall,**能等**) | -| 会话列表 | `client.session.list()` | `ctx.sessionQuery.listSessions()` | -| 模型目录 | `client.config.providers()`(`models` 是**对象**) | `ctx.llm.listProviders()` + `listModels()` | -| 别名来源 | `session.slug`(创建时就有) | 模型标题派生 | -| 日志可见性 | `console.error` | `console.error`(`ctx.logger` 不进 journalctl) | +| 关注点 | opencode | DeepSeek Harness | pi | +|---|---|---|---| +| 插件形态 | `export default async function(input)` | Cordis:`export const inject` + `apply(ctx, config)` | **常驻守护进程**,不是插件(见下) | +| 建会话 | `client.session.create({ query: { directory } })` | `ctx.agents.create({ sessionId, meta: { cwd }, agentOptions })` | `createAgentSession({ cwd, sessionManager, … })` | +| 注入一轮 | `client.session.promptAsync({ parts })` | `agent.followup(UserMessage)` | `session.prompt(text)`;忙时 `prompt(text, {streamingBehavior:'followUp'})` | +| 工具定义 | zod schema | `defineTool()` + spec 格式参数 | `defineTool()` + **TypeBox** schema | +| 轮次结束 | `session.idle` 事件 | `agent/status` → `idle` | `prompt()` 的 promise resolve;事件是 `agent_end` | +| 模型失败信号 | `session.error` 事件 | `turn/end` 的 `reason.kind === 'error'` | `prompt()` reject **或** 末条 assistant 的 `stopReason==='error'` | +| 权限钩子 | `permission.ask`(**同步,不能等**) | `approval/request`(异步 waterfall,**能等**) | `tool_call` 扩展事件(**能 await**,实测) | +| 会话列表 | `client.session.list()` | `ctx.sessionQuery.listSessions()` | `SessionManager.listAll()`(**不传参**,传字符串会被当自定义目录) | +| 模型目录 | `client.config.providers()`(`models` 是**对象**) | `ctx.llm.listProviders()` + `listModels()` | `modelRuntime.getAvailable()`(**不是** `getModels()`:1221 条里只有 1 条能用) | +| 别名来源 | `session.slug`(创建时就有) | 模型标题派生 | **邮件主题派生**(SDK 会话没有平台标题,见下) | +| 日志可见性 | `console.error` | `console.error`(`ctx.logger` 不进 journalctl) | `console.error` | + +> **pi 为什么是守护进程而不是扩展**:pi 扩展被加载进**一条已经存在的**会话, +> 那条会话的 cwd 由启动 pi 的人决定。而 `B-3.1` 要求每封邮件的 `to_workspace` +> 成为会话 cwd —— 扩展做不到「按邮件新开一条 cwd 不同的会话」。 +> 桥因此用 SDK 起会话,一个进程里并存多条不同 cwd 的会话(实测可行)。 +> 代价是它需要自己的 systemd 单元(`deploy/pi-mail-bridge.service`)。 +> +> **pi 的 `C-11` 是有条件的**:pi 会生成会话标题,但生成器在 **pi-web** 包里 +> (`sessionNameGenerator.js`),不在 `pi-coding-agent` 内核里。桥用 SDK 起的会话 +> 走不到那条路径,`session.sessionName` 一直是 `undefined`。因此 pi 侧的别名走 +> `D-5` 阶梯的第二级(邮件主题派生),并把 Gateway 的定稿值**回写**进 +> `session.setSessionName()` —— 这一步让 pi-web 界面上显示的名字与邮箱里一致。 +> +> **回写只能用 `setSessionName()`**,三条实测约束:首条 assistant 消息落盘前 +> 会话文件还不存在,pi 首次落盘用 `openSync(file,"wx")`,外部抢先创建会让它抛 +> `EEXIST`;活着的 `SessionManager` 从不重读文件,它自己后续的 `session_info` +> 会覆盖外部改名;空名字是**清除**语义,不是「不改」。 --- @@ -1038,6 +1130,39 @@ socat 被 `Requires` 带停后再没起来。加 `PartOf` 后又发现 `start` 它把目录名当模块解析。必须写 glob:`node --test 'test/*.test.mjs'`。 +### 9.11 pi 的 `followUp()` 在会话空闲时什么也不做(静默丢邮件) + +`session.followUp(text)` 只往 `followUpQueue` 里塞消息,而那个队列**只在运行中的 +轮次末尾**被 drain(`pi-agent-core/agent.js` 的 run 循环,以及 `continue()`)。 +会话空闲时(上一轮早已结束)塞进去的消息永远没人取。 + +表现极具欺骗性:日志打了「续谈成功」,`prompt()` 没报错,收件箱里却只有来信、 +没有回复。既没有异常也没有超时。 + +> **正确做法**:按 `session.isStreaming` 分流 —— 空闲用 `prompt(text)` 直接起一轮, +> 正在跑用 `prompt(text, { streamingBehavior: 'followUp' })` 排到当轮之后 +> (缺 `streamingBehavior` 时 pi 会抛 `Agent is already processing.`)。 +> 不要用 `steer`:那会打断当前轮,而当前轮正在处理**上一封邮件**。 + +### 9.12 pi 的全局扩展会 `listen` 固定端口 + +`~/.pi/agent/extensions/` 下的 `pi-a2a` / `pi-acp` 在加载时就 bind +`127.0.0.1:12010` / `12011`。同机已有 pi 在跑时,任何新起的 pi 进程(包括 +`pi --help`)都会 `EADDRINUSE` 并把整条会话拖死。 + +> 桥用 `noExtensions: true` + 内联 `extensionFactories` 起会话:既避开端口冲突, +> 也不继承那套给人类交互用的扩展(TUI 命令、快捷键、状态栏对邮件没有意义)。 +> 邮件工具走 `customTools`,权限钩子走内联工厂。 + +### 9.13 `SessionManager.listAll(dir)` 的参数不是 agentDir + +它的字符串参数是**自定义会话目录**,会直接在里面找 `.jsonl`。传 +`getAgentDir()`(`~/.pi/agent`)得到的是空列表 —— 会话在它的 `sessions/` +子目录下按 cwd 分目录存放。**不传参数**才会走默认的逐 cwd 扫描。 + +同理不要用 `list(cwd)`:桥的进程 cwd 与会话 cwd 无关,按前者过滤会漏掉 +所有真正在干活的会话。 + --- ## 附:文档关系 @@ -1050,4 +1175,4 @@ socat 被 `Requires` 带停后再没起来。加 `PartOf` 后又发现 `start` | [`PHASE7-REMAINING.md`](PHASE7-REMAINING.md) | 未完成项与已知取舍 | 参照实现:`plugins/opencode-mail-bridge/`、`plugins/dsh-mail-bridge/`、 -`deploy/remote-agent-demo.py`(纯标准库的协议层对照)。 +`plugins/pi-mail-bridge/`、`deploy/remote-agent-demo.py`(纯标准库的协议层对照)。 diff --git a/gateway/cmd/server/main.go b/gateway/cmd/server/main.go index 21d32fd..6ef2333 100644 --- a/gateway/cmd/server/main.go +++ b/gateway/cmd/server/main.go @@ -113,6 +113,20 @@ func main() { // 邮件场景下的可用模型范围。上报走心跳(agent/heartbeat 的 models 字段), // 这里只读 —— 给非插件的第三方客户端与排查用。 r.Get("/agent/models/allowed", handler.GetAllowedModels) + + // ---- 寻址发现(只读)---- + // + // 没有这一组时,send_mail 的 to 是个只能靠记忆拼写的自由文本: + // 想回给抄送方只能从收件箱里拄一段 `opencode@/home.new`, + // 而 `.new` 是一次性的,拄过去只会再建一条会话。 + // 人类侧 AddressInput 逐段查 /contacts/suggest 从活数据里选, + // 这一组就是把同一份能力给 Agent。均为只读: + // 归档、改别名、权限决策仍然只有人能做。 + r.Get("/agent/contacts", handler.AgentListContacts) + r.Get("/agent/contacts/suggest", handler.AgentSuggestAddress) + r.Get("/agent/mail/{id}", handler.AgentGetMail) + r.Get("/agent/mail/{id}/thread", handler.AgentGetMailThread) + r.Get("/agent/sessions/{id}/participants", handler.AgentSessionParticipants) }) // ---- 人类登录态 ---- @@ -187,6 +201,12 @@ func main() { // 邮件场景下每个 Agent 可用的模型范围(勾选平台上报的目录) r.Get("/admin/agents/{name}/models", handler.AdminListAgentModels) r.Put("/admin/agents/{name}/models", handler.AdminSetAgentModels) + + // 停用 / 恢复一个 Agent。停用是可逆的归档:邮件与会话保留, + // 但从补全里消失、密钥被撤销、重新注册被拒。 + // 没有「彻底删除」—— Agent 名与人类用户名共用命名空间, + // 删掉后同名注册者会让历史邮件看起来像是他发的。 + r.Put("/admin/agents/{name}/status", handler.AdminSetAgentStatus) }) }) diff --git a/gateway/internal/handler/agent_discovery.go b/gateway/internal/handler/agent_discovery.go new file mode 100644 index 0000000..6317f73 --- /dev/null +++ b/gateway/internal/handler/agent_discovery.go @@ -0,0 +1,330 @@ +package handler + +import ( + "net/http" + "strings" + + "github.com/agentmail/gateway/internal/middleware" + "github.com/agentmail/gateway/internal/models" + "github.com/agentmail/gateway/internal/repo" + "github.com/google/uuid" +) + +// Agent 侧的寻址发现与线索读取。 +// +// # 为什么需要这一组端点 +// +// 在这之前,Agent 能读的只有自己的收件箱。`/agents`、`/contacts`、 +// `/contacts/suggest`、`/mail/{id}/thread`、`/sessions/{id}` 全部挂在 +// `middleware.UserAuth` 后面,Agent 密钥一律 401。后果是 `send_mail` 的 `to` +// 成了一个**只能靠记忆拼写的自由文本字段**: +// +// - 想回给抄送方,只能从收件箱渲染出的 `抄送: opencode@/home.new` 里抄一段, +// 而 `.new` 是一次性的,抄过去只会再建一条会话; +// - 想知道对方接受哪个工作目录,无从查询,只能猜。生产上真实发生过一次: +// dsh 猜了 `opencode@/home`,地址解析通过、投递成功,但 `/home` 不是 +// opencode 的工作目录 —— **猜错比报错更糟,它会静默变成新会话的 workspace**。 +// +// 人类侧从来没有这个问题:`AddressInput` 三段式逐段查 `/contacts/suggest`, +// name / path / session 每一段都从活数据里选。这一组端点就是把同一份能力 +// 给 Agent。 +// +// # 为什么不直接给 Agent 复用人类那几条路由 +// +// 两条理由: +// +// 1. **作用域不同。** 人类侧 `ListContactsFor(scope=username)` 的 scope 是 +// 「我参与过的会话」,管理员还能 `?all=true` 看全部。Agent 没有管理员概念, +// 也不该看到自己没参与过的线索。把 AgentAuth 加进人类路由组,等于让 +// `middleware.GetUser` 返回 nil 的请求走进一堆假定 user 非空的 handler。 +// 2. **审计与演进。** Agent 能读什么是插件契约的一部分(PLUGIN-CONTRACT 的 +// 能力矩阵),独立成组才能在一处看全。 +// +// # 一律只读 +// +// 这里没有任何写端点。归档、改别名、决策权限都仍然只有人能做 —— +// Agent 可以「看见并寻址」,但不能替人整理邮箱。 + +// GET /api/v1/agent/contacts +// +// 本 Agent 参与过的全部会话,每条给出可直接投递的 `address`。 +// 与人类侧 `/contacts` 同源(`repo.ListContactsFor`),scope 固定为自己。 +func AgentListContacts(w http.ResponseWriter, r *http.Request) { + agentName := middleware.GetAgentName(r) + if agentName == "" { + Error(w, http.StatusUnauthorized, "Unauthorized") + return + } + + archived := r.URL.Query().Get("archived") == "true" + contacts, err := repo.ListContactsFor(r.Context(), agentName, archived) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to list contacts") + return + } + + // 联系人条目里的 agent_name 是「会话对面那个人」,但 ListContactsFor 取的是 + // 会话首封邮件的 to_name(人类侧视角:对面是 Agent)。Agent 自己调用时, + // 首封邮件的 to_name 往往就是自己,对面反而是 from_name。 + // 因此这里补一个 peer 字段明确「该跟谁说话」,不改原字段以免动到前端。 + out := make([]map[string]any, 0, len(contacts)) + for _, c := range contacts { + peer := c.AgentName + if peer == agentName { + peer = c.LastFrom + } + out = append(out, map[string]any{ + "session_id": c.SessionID, + "session_alias": c.SessionAlias, + "subject": c.Subject, + "path": c.Path, + "status": c.Status, + "mail_count": c.MailCount, + "unread_count": c.UnreadCount, + "last_activity": c.LastActivity, + "last_from": c.LastFrom, + "max_rounds": c.MaxRounds, + "used_rounds": c.UsedRounds, + // peer 是这条会话里可与之通信的另一方 + "peer": peer, + // address 是投回这条会话的现成地址。别名为空的老会话给不出可寻址的 + // 形式,此时置空而不是拼一个 `.new` —— 那会开新线索而不是续谈。 + "address": addressForSession(peer, c.Path, c.SessionAlias), + }) + } + JSON(w, http.StatusOK, map[string]any{"contacts": out}) +} + +// addressForSession 拼「投回这条会话」的地址;无别名时返回空串。 +// +// 刻意不退化成 `name@path`(默认会话):默认会话是「该 name@path 当前最活跃的 +// 那条」,与调用方想回的那条不一定是同一条。给一个看着能用其实指向别处的地址, +// 比给空串危险。 +func addressForSession(name, path, alias string) string { + if alias == "" { + return "" + } + return models.FormatAddress(name, path, alias) +} + +// GET /api/v1/agent/contacts/suggest?name=&path= +// +// 三段式寻址补全,与人类侧 `/contacts/suggest` 同一套语义: +// +// 不带 name → 候选收件人名(在线 Agent + 活跃用户,去掉自己) +// 带 name 不带 path → 该 name 用过的工作目录 +// name + path 都带 → 该 name@path 下可续谈的会话别名,`new` 永远在最后 +// +// **这是「精准发信」的关键一环**:模型不再拼地址,而是逐段选。 +func AgentSuggestAddress(w http.ResponseWriter, r *http.Request) { + agentName := middleware.GetAgentName(r) + if agentName == "" { + Error(w, http.StatusUnauthorized, "Unauthorized") + return + } + + name := strings.TrimSpace(r.URL.Query().Get("name")) + path := strings.TrimSpace(r.URL.Query().Get("path")) + + if name == "" { + agents, err := repo.ListAgents(r.Context(), "") + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to list agents") + return + } + users, _ := repo.ListActiveUsernames(r.Context()) + + names := make([]string, 0, len(agents)+len(users)) + for _, a := range agents { + if a.Name == agentName { + continue // 不建议给自己发信 + } + names = append(names, a.Name) + } + names = append(names, users...) + JSON(w, http.StatusOK, map[string]any{ + "kind": "name", + "suggestions": emptySlice(names), + }) + return + } + + if path == "" { + paths, _ := repo.SuggestPaths(r.Context(), name) + JSON(w, http.StatusOK, map[string]any{ + "kind": "path", + "suggestions": emptySlice(paths), + }) + return + } + + // 可见性传自己的名字:只提示自己参与过的会话。 + // 传空会把别人的私下线索也列出来,那是越权。 + sessions, err := repo.SuggestSessionCandidates(r.Context(), agentName, name, path) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to suggest sessions") + return + } + + aliases := make([]string, 0, len(sessions)+1) + addresses := make([]string, 0, len(sessions)+1) + for _, c := range sessions { + aliases = append(aliases, c.Alias) + addresses = append(addresses, models.FormatAddress(name, path, c.Alias)) + } + // new 总在最后:它不是一条已存在的会话。排在前面会让模型在想续谈时 + // 顺手开出一条新线索 —— 生产上已经发生过。 + aliases = append(aliases, "new") + addresses = append(addresses, models.FormatAddress(name, path, "new")) + sessions = append(sessions, repo.SessionCandidate{ + Alias: "new", Source: "new", Title: "新建会话", + }) + + JSON(w, http.StatusOK, map[string]any{ + "kind": "session", + "suggestions": emptySlice(aliases), + // addresses 与 suggestions 同序,可直接塞进 send_mail 的 to + "addresses": emptySlice(addresses), + "candidates": emptySlice(sessions), + }) +} + +// GET /api/v1/agent/mail/{id}/thread +// +// 与人类侧 `/mail/{id}/thread` 同一份实现,可见性判据换成 +// 「本 Agent 参与过该会话」。抄送协作要靠它回答「谁已经回了、谁还没回」。 +func AgentGetMailThread(w http.ResponseWriter, r *http.Request) { + agentName := middleware.GetAgentName(r) + if agentName == "" { + Error(w, http.StatusUnauthorized, "Unauthorized") + return + } + serveMailThread(w, r, func(sid uuid.UUID) (bool, error) { + return repo.AgentCanAccessSession(r.Context(), agentName, sid) + }) +} + +// GET /api/v1/agent/mail/{id} +// +// 读单封邮件全文(含抄送清单与附件)。收件箱只给摘要, +// 而要回给抄送方就必须先看清这封信到底发给了谁。 +func AgentGetMail(w http.ResponseWriter, r *http.Request) { + agentName := middleware.GetAgentName(r) + if agentName == "" { + Error(w, http.StatusUnauthorized, "Unauthorized") + return + } + mailID, ok := pathUUID(w, r, "id") + if !ok { + return + } + + mail, err := repo.GetMailByID(r.Context(), mailID) + if err != nil { + Error(w, http.StatusNotFound, "Mail not found") + return + } + allowed, err := repo.AgentCanAccessSession(r.Context(), agentName, mail.SessionID) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to check permission") + return + } + if !allowed { + Error(w, http.StatusForbidden, "无权访问该邮件") + return + } + + fillAttachments(r, mail) + + alias := repo.SessionAliasOf(r.Context(), mail.SessionID) + JSON(w, http.StatusOK, map[string]any{ + "mail": mail, + "session_alias": alias, + // 回信地址与「我这个身份」都给现成的,省得插件自己拼。 + // mail.ToWorkspace 是收件方那个地址的 path 位。 + "reply_address": models.FormatAddress(mail.FromName, "", alias), + "self_address": models.FormatAddress(agentName, mail.ToWorkspace, alias), + "participants": participantsOf(mail, alias), + }) +} + +// GET /api/v1/agent/sessions/{id}/participants +// +// 列出该会话的全部参与方及各自的可投递地址。 +// +// 这是「发送给抄收方 / 转发方」缺的最后一块:知道有谁、以及**用什么地址找到他**。 +// 逐封邮件扫收件人与抄送,因为参与方是随往来变化的(一封转发就多一个人)。 +func AgentSessionParticipants(w http.ResponseWriter, r *http.Request) { + agentName := middleware.GetAgentName(r) + if agentName == "" { + Error(w, http.StatusUnauthorized, "Unauthorized") + return + } + sessionID, ok := pathUUID(w, r, "id") + if !ok { + return + } + allowed, err := repo.AgentCanAccessSession(r.Context(), agentName, sessionID) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to check permission") + return + } + if !allowed { + Error(w, http.StatusForbidden, "无权访问该会话") + return + } + + parts, err := repo.SessionParticipants(r.Context(), sessionID) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to list participants") + return + } + + alias := repo.SessionAliasOf(r.Context(), sessionID) + out := make([]map[string]any, 0, len(parts)) + for _, p := range parts { + out = append(out, map[string]any{ + "name": p.Name, + "path": p.Path, + "roles": p.Roles, // from / to / cc 的并集 + "is_self": p.Name == agentName, + "mail_count": p.MailCount, + // address 用**该参与方自己的 path**,不是调用方的: + // 抄送给 opencode@/a 与主发给 dsh@/b 是两个工作区, + // 用错 path 会让对方在别人的目录里开会话。 + "address": addressForSession(p.Name, p.Path, alias), + }) + } + + JSON(w, http.StatusOK, map[string]any{ + "session_id": sessionID, + "session_alias": alias, + "participants": out, + }) +} + +// participantsOf 从单封邮件里摘出参与方地址,供 AgentGetMail 直接返回。 +// 与 SessionParticipants 的区别:这里只看这一封(发件人 + 收件人 + 抄送), +// 用于「回这封信时该带上谁」;那里看整条会话。 +func participantsOf(m *models.Mail, alias string) []map[string]any { + out := []map[string]any{} + add := func(role, name, path string) { + if name == "" { + return + } + out = append(out, map[string]any{ + "role": role, + "name": name, + "path": path, + "address": addressForSession(name, path, alias), + }) + } + // from_workspace 对 Agent 存的是 Agent 名而非路径(历史遗留), + // 拿它当 path 会拼出错地址,所以发件人一侧留空 path 走默认。 + add("from", m.FromName, "") + add("to", m.ToName, m.ToWorkspace) + for _, c := range m.CCList { + add("cc", c.Name, c.Path) + } + return out +} diff --git a/gateway/internal/handler/agents.go b/gateway/internal/handler/agents.go index 2531560..77ea356 100644 --- a/gateway/internal/handler/agents.go +++ b/gateway/internal/handler/agents.go @@ -1,11 +1,15 @@ package handler import ( + "database/sql" + "errors" "net/http" + "strings" "github.com/agentmail/gateway/internal/middleware" "github.com/agentmail/gateway/internal/models" "github.com/agentmail/gateway/internal/repo" + "github.com/go-chi/chi/v5" ) // ---------- Agent ---------- @@ -106,6 +110,14 @@ func RegisterAgent(w http.ResponseWriter, r *http.Request) { } if err := repo.CreateOrUpdateAgent(r.Context(), req.Name, secret, req.Platform, req.Workspaces); err != nil { + // 已停用的 Agent 不得靠重新注册复活。回 403 而不是 500: + // 这是一个明确的策略拒绝,插件应当停止重试并把原因打出来。 + if errors.Is(err, repo.ErrAgentDisabled) { + Error(w, http.StatusForbidden, + "Agent \""+req.Name+"\" 已被管理员停用,无法注册。"+ + "如需重新启用,请在管理页「默认预算」里恢复它。") + return + } Error(w, http.StatusInternalServerError, "Failed to register agent") return } @@ -208,3 +220,57 @@ func ListAgents(w http.ResponseWriter, r *http.Request) { "agents": emptySlice(agents), }) } + +// ---------- 停用 / 恢复 ---------- + +type setAgentStatusRequest struct { + // Disabled true = 停用,false = 恢复 + Disabled bool `json:"disabled"` +} + +// PUT /api/v1/admin/agents/{name}/status —— 停用或恢复一个 Agent +// +// 停用是可逆的「归档」,不是删除: +// - 邮件、会话、权限记录、转发幂等键全部保留(往来里有一半是人自己写的) +// - 从地址补全、GET /agents、可授权范围里消失 +// - 全部密钥被撤销,插件拿不到新任务也发不出信 +// - 重新注册会被拒(否则插件下次启动就把它复活了) +// +// 不提供彻底删除:Agent 名与人类用户名共用命名空间,删掉之后历史邮件的 +// from_name 指向一个不存在的名字,此时有人注册同名 Agent(或人类账号), +// 那些旧邮件会看起来像是他发的。 +func AdminSetAgentStatus(w http.ResponseWriter, r *http.Request) { + name := strings.TrimSpace(chi.URLParam(r, "name")) + if name == "" { + Error(w, http.StatusBadRequest, "Missing agent name") + return + } + + var req setAgentStatusRequest + if !DecodeBody(w, r, &req) { + return + } + + revoked, err := repo.SetAgentDisabled(r.Context(), name, req.Disabled) + if errors.Is(err, sql.ErrNoRows) { + Error(w, http.StatusNotFound, "Agent 不存在: "+name) + return + } + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to update agent status") + return + } + + resp := map[string]any{ + "agent_name": name, + "disabled": req.Disabled, + } + if req.Disabled { + resp["keys_revoked"] = revoked + resp["detail"] = "已停用。邮件与会话保留;该 Agent 的密钥已全部撤销," + + "恢复后需要重新签发。" + } else { + resp["detail"] = "已恢复为离线状态。需要重新签发密钥,插件连上后自动转为在线。" + } + JSON(w, http.StatusOK, resp) +} diff --git a/gateway/internal/handler/forward.go b/gateway/internal/handler/forward.go index 020b8a0..93c1e3b 100644 --- a/gateway/internal/handler/forward.go +++ b/gateway/internal/handler/forward.go @@ -170,7 +170,7 @@ func doForward(w http.ResponseWriter, r *http.Request, mailID uuid.UUID, actor, attachedCount = n } - notifyRecipients(to, ccList, sessionID, newID, actor, subject) + notifyRecipients(r.Context(), to, ccList, sessionID, newID, actor, subject) JSON(w, http.StatusOK, map[string]any{ "mail_id": newID.String(), diff --git a/gateway/internal/handler/mail.go b/gateway/internal/handler/mail.go index 81249b4..39030b6 100644 --- a/gateway/internal/handler/mail.go +++ b/gateway/internal/handler/mail.go @@ -1,6 +1,7 @@ package handler import ( + "context" "errors" "fmt" "net/http" @@ -92,14 +93,32 @@ func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, sub if err != nil { // 建失败要把名额还回去:那次新建实际上没有发生 repo.ReleaseNewSession(r.Context(), byAgent) + return id, nil, err } - return id, nil, err + // `.new` 是一次性动作:它建完会话就用完了,之后要再投进这条会话只能靠 + // `name@path.<别名>`。未命名会话既查不到(FindNamedSessionFor 的 + // `session_alias = $1` 对 NULL 不成立)也补全不出来,收件方与抄送方 + // 除了回复那一封之外再也无法寻址到它 —— 再发一次 `.new` 只会建第三条会话。 + // 因此这里立刻给一个别名,平台随后仍可用 SyncSessionAlias 改写它。 + if aliasPtr == nil { + // 命名失败不该让发信失败:邮件本身能送达,代价只是这条会话暂时 + // 只能用 reply_to 续谈,比整封退回轻。 + _, _ = repo.EnsureSessionAlias(r.Context(), id, repo.AutoAliasFor(addr.Name, subject)) + } + return id, nil, nil case models.SessionDefault: // 默认会话「从未通信则建立」也会产生新会话,但一个 name@path 只有一条, // 不构成暴开的手段,因此不计入速率限制。 id, err := repo.FindOrCreateDefaultSession(r.Context(), addr.Name, addr.Path, fromAgent, subject) - return id, nil, err + if err != nil { + return id, nil, err + } + // 默认会话同样需要可寻址的别名:省略 session 位能投进来,但要**指名** + // 投进这一条(而不是「该 name@path 当前的默认会话」)仍然只能靠别名。 + // 已有别名时 EnsureSessionAlias 直接返回,复用旧会话不会被改名。 + _, _ = repo.EnsureSessionAlias(r.Context(), id, repo.AutoAliasFor(addr.Name, subject)) + return id, nil, nil default: // models.SessionNamed id, err := repo.FindNamedSessionFor(r.Context(), addr.Name, addr.Path, addr.Session) @@ -238,7 +257,7 @@ func SendMail(w http.ResponseWriter, r *http.Request) { return } - notifyRecipients(to, ccList, sessionID, mailID, agentName, req.Subject) + notifyRecipients(r.Context(), to, ccList, sessionID, mailID, agentName, req.Subject) // 回传会话别名与本任务剩余往返,让发件方知道后续用什么地址续谈、还能发几封 resp := map[string]any{ @@ -273,8 +292,17 @@ func SendMail(w http.ResponseWriter, r *http.Request) { // 三维地址 name@path.session 的 path 就是工作目录,插件要靠它建会话。 // 抄送给 opencode@/a 与主发给 dsh@/b 是两个不同的工作区,共用一份 payload // 会让抄送方在别人的目录里开会话。 -func notifyRecipients(to models.Address, cc []models.Address, sessionID, mailID uuid.UUID, from, subject string) { - payload := func(role, workspace string) map[string]interface{} { +// +// 同理,**每个收件方拿到的 reply_address 也是自己那个地址**,并且 session 位已经 +// 把 `new` 换成真实别名:`.new` 建完会话就失效了,把原文那个 `x@/p.new` +// 送给参与方只会让它下一次又建一条新会话。 +func notifyRecipients(ctx context.Context, to models.Address, cc []models.Address, sessionID, mailID uuid.UUID, from, subject string) { + // 别名在此时已由 resolveTarget 保证存在(`.new` 与默认会话都过 EnsureSessionAlias)。 + // 仍可能为空的情形:命名写入失败(已吐日志)。此时退回省略 session 位, + // 而不是把 "new" 写进去 —— 后者会让参与方反复建新会话。 + alias := repo.SessionAliasOf(ctx, sessionID) + + payload := func(role, workspace, forName string) map[string]interface{} { return map[string]interface{}{ "mail_id": mailID.String(), "session_id": sessionID.String(), @@ -286,6 +314,14 @@ func notifyRecipients(to models.Address, cc []models.Address, sessionID, mailID // 不带这一项的后果:插件只能自己拼一个临时目录,于是每封邮件都落在 // 不同的空目录里,DSH / opencode 按 cwd 分组时全进「未分组」。 "to_workspace": workspace, + // session_alias 是这条会话今后的寻址名。没有它的话,收到 `.new` + // 邮件的一方只持有一个 send_mail 不接受的 session_id。 + "session_alias": alias, + // reply_address 是「把回信发回这条会话」的现成地址。 + // 插件不必自己拼(拼错了就是静默开新会话)。 + "reply_address": models.FormatAddress(from, "", alias), + // self_address 是对方应当用来称呼自己的地址,供转发/报告时引用。 + "self_address": models.FormatAddress(forName, workspace, alias), } } @@ -297,7 +333,7 @@ func notifyRecipients(to models.Address, cc []models.Address, sessionID, mailID // 参与方去重:收件人 + 所有抄送 + 发件人自己(刷新他的发件箱) seen := map[string]bool{} - sse.Default.SendToRecipient(to.Name, "new_mail", payload("to", to.Path)) + sse.Default.SendToRecipient(to.Name, "new_mail", payload("to", to.Path, to.Name)) sse.Default.SendToRecipient(to.Name, "session_update", update) seen[to.Name] = true @@ -306,7 +342,7 @@ func notifyRecipients(to models.Address, cc []models.Address, sessionID, mailID continue } seen[c.Name] = true - sse.Default.SendToRecipient(c.Name, "new_mail", payload("cc", c.Path)) + sse.Default.SendToRecipient(c.Name, "new_mail", payload("cc", c.Path, c.Name)) sse.Default.SendToRecipient(c.Name, "session_update", update) } diff --git a/gateway/internal/handler/me.go b/gateway/internal/handler/me.go index b73ddb3..ab7700b 100644 --- a/gateway/internal/handler/me.go +++ b/gateway/internal/handler/me.go @@ -121,7 +121,7 @@ func MeSendMail(w http.ResponseWriter, r *http.Request) { return } - notifyRecipients(to, ccList, sessionID, mailID, user.Username, req.Subject) + notifyRecipients(r.Context(), to, ccList, sessionID, mailID, user.Username, req.Subject) resp := map[string]any{ "mail_id": mailID.String(), diff --git a/gateway/internal/handler/thread.go b/gateway/internal/handler/thread.go index aabc543..e36d334 100644 --- a/gateway/internal/handler/thread.go +++ b/gateway/internal/handler/thread.go @@ -57,6 +57,19 @@ func GetMailThread(w http.ResponseWriter, r *http.Request) { Error(w, http.StatusUnauthorized, "not authenticated") return } + serveMailThread(w, r, func(sid uuid.UUID) (bool, error) { + return repo.UserCanAccessSession(r.Context(), user, sid) + }) +} + +// serveMailThread 是人类与 Agent 两条对话树路径的公共实现。 +// +// 差别只在**会话可见性判据**:人类走 UserCanAccessSession(管理员全可见、 +// 其余看参与过的会话),Agent 走 AgentCanAccessSession(只看自己参与过的)。 +// 其余全部逻辑——上溯线索根、BFS 分页、锚点路径回填、detached 标记——两侧必须 +// 完全一致:让 Agent 看到一棵与人类不同形状的树,只会让双方对「谁回了谁」 +// 产生分歧,而这正是抄送协作要靠对话树解决的问题。 +func serveMailThread(w http.ResponseWriter, r *http.Request, canAccess func(uuid.UUID) (bool, error)) { mailID, ok := pathUUID(w, r, "id") if !ok { return @@ -69,7 +82,7 @@ func GetMailThread(w http.ResponseWriter, r *http.Request) { Error(w, http.StatusNotFound, "Mail not found") return } - allowed, err := repo.UserCanAccessSession(r.Context(), user, mail.SessionID) + allowed, err := canAccess(mail.SessionID) if err != nil { Error(w, http.StatusInternalServerError, "Failed to check permission") return @@ -118,7 +131,7 @@ func GetMailThread(w http.ResponseWriter, r *http.Request) { if v, ok := seen[sid]; ok { return v } - v, err := repo.UserCanAccessSession(r.Context(), user, sid) + v, err := canAccess(sid) if err != nil { v = false // 查不出来就当看不到:宁可少给,不可多给 } diff --git a/gateway/internal/models/address.go b/gateway/internal/models/address.go index 402cc5d..6142ab4 100644 --- a/gateway/internal/models/address.go +++ b/gateway/internal/models/address.go @@ -115,6 +115,36 @@ func ParseAddress(s string) (Address, error) { }, nil } +// FormatAddress 把三段拼回可寻址的 name@path.session。 +// +// **必须走这个函数而不是自己拼字符串**:path 为空时(人类用户没有工作区) +// 朴素拼接得到 "admin.silent-harbor",而它没有 @,ParseAddress 会把整串当成 +// 名字,session 位丢失,地址静默失效。空 path 也必须留下那个 @ 与 . —— +// "admin@.silent-harbor" 才解析成 name=admin path="" session=silent-harbor。 +// +// session 传空则省略该位(默认会话语义)。 +func FormatAddress(name, path, session string) string { + name = strings.TrimSpace(name) + path = strings.TrimSpace(path) + session = strings.TrimSpace(session) + if name == "" { + return "" + } + if session == "" { + if path == "" { + return name + } + return name + "@" + path + } + return name + "@" + path + "." + session +} + +// WithSession 返回同一收件方在指定会话下的地址。 +// 用于把 .new 换成刚建出来的会话别名 —— 参与方拿到的地址必须是能再次投递的那个。 +func (a Address) WithSession(session string) string { + return FormatAddress(a.Name, a.Path, session) +} + // ParseAddressList 解析逗号/分号/空白分隔的多个地址(用于 CC) func ParseAddressList(s string) ([]Address, error) { raw := strings.TrimSpace(s) diff --git a/gateway/internal/repo/agent_disable_test.go b/gateway/internal/repo/agent_disable_test.go new file mode 100644 index 0000000..d4f567a --- /dev/null +++ b/gateway/internal/repo/agent_disable_test.go @@ -0,0 +1,253 @@ +package repo + +import ( + "context" + "database/sql" + "errors" + "testing" + + "github.com/agentmail/gateway/internal/db" + "github.com/google/uuid" +) + +// 停用是可逆的「归档」,不是删除。这组测试钉住三件事: +// 停用后从候选里消失、密钥被撤销、重新注册不能复活它。 + +func TestSetAgentDisabledHidesFromCandidates(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + for _, n := range []string{"keeper", "goner"} { + if err := CreateOrUpdateAgent(ctx, n, "s", "test", nil); err != nil { + t.Fatalf("注册 %s: %v", n, err) + } + } + + if _, err := SetAgentDisabled(ctx, "goner", true); err != nil { + t.Fatalf("停用: %v", err) + } + + // 默认列表(地址补全、GET /agents、可授权范围都走这条)不含已停用的 + got, err := ListAgents(ctx, "") + if err != nil { + t.Fatalf("ListAgents: %v", err) + } + names := map[string]bool{} + for _, a := range got { + names[a.Name] = true + } + if names["goner"] { + t.Error("已停用的 Agent 仍出现在默认列表里 —— 人会把任务派给一个不会响应的地址") + } + if !names["keeper"] { + t.Error("停用一个把别的也弄没了") + } + + // statusFilter="all" 时要能看到 —— 那是管理页恢复它的唯一入口 + all, err := ListAgents(ctx, "all") + if err != nil { + t.Fatalf("ListAgents(all): %v", err) + } + found := false + for _, a := range all { + if a.Name == "goner" { + found = true + if a.Status != "disabled" { + t.Errorf("状态应为 disabled,实际 %q", a.Status) + } + } + } + if !found { + t.Error("statusFilter=all 也看不到已停用的,就再也无法恢复它了") + } +} + +func TestSetAgentDisabledRevokesKeys(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil { + t.Fatalf("注册: %v", err) + } + admin := seedAdminForTest(t, ctx) + for i := 0; i < 2; i++ { + if _, err := CreateAgentKey(ctx, "bot", "permanent", "k", 0, admin, ""); err != nil { + t.Fatalf("建密钥: %v", err) + } + } + + revoked, err := SetAgentDisabled(ctx, "bot", true) + if err != nil { + t.Fatalf("停用: %v", err) + } + if revoked != 2 { + t.Errorf("应撤销 2 把密钥,实际 %d", revoked) + } + + keys, err := ListAgentKeys(ctx, "bot") + if err != nil { + t.Fatalf("ListAgentKeys: %v", err) + } + if len(keys) != 0 { + t.Errorf("停用后仍留着 %d 把密钥 —— 插件还能用它调 /mail/send,"+ + "停用的语义是「不再参与工作」而不只是「不出现在补全里」", len(keys)) + } +} + +func TestDisabledAgentCannotReRegister(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil { + t.Fatalf("首次注册: %v", err) + } + if _, err := SetAgentDisabled(ctx, "bot", true); err != nil { + t.Fatalf("停用: %v", err) + } + + // 插件启动时会重新注册。不拒的话 status 被写回 online,停用等于没做。 + err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil) + if !errors.Is(err, ErrAgentDisabled) { + t.Fatalf("已停用的 Agent 重新注册应当被拒,实际 err=%v", err) + } + + disabled, err := AgentDisabled(ctx, "bot") + if err != nil { + t.Fatalf("AgentDisabled: %v", err) + } + if !disabled { + t.Error("注册尝试把停用状态冲掉了") + } +} + +func TestHeartbeatDoesNotReviveDisabledAgent(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil { + t.Fatalf("注册: %v", err) + } + if _, err := SetAgentDisabled(ctx, "bot", true); err != nil { + t.Fatalf("停用: %v", err) + } + + // 心跳是 30 秒一次的。不排除 disabled 的话停用最多维持半分钟。 + if _, err := HeartbeatAgent(ctx, "bot"); err != nil { + t.Fatalf("心跳本身不该报错: %v", err) + } + + disabled, _ := AgentDisabled(ctx, "bot") + if !disabled { + t.Error("心跳把已停用的 Agent 改回在线了") + } +} + +func TestRestoreAgentGoesOfflineNotOnline(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil { + t.Fatalf("注册: %v", err) + } + if _, err := SetAgentDisabled(ctx, "bot", true); err != nil { + t.Fatalf("停用: %v", err) + } + if _, err := SetAgentDisabled(ctx, "bot", false); err != nil { + t.Fatalf("恢复: %v", err) + } + + all, _ := ListAgents(ctx, "all") + for _, a := range all { + if a.Name != "bot" { + continue + } + // 恢复成 online 会让界面显示一个其实没在跑的 Agent 为在线; + // 它是否真的活着由下一次心跳决定。 + if a.Status != "offline" { + t.Errorf("恢复后应为 offline,实际 %q", a.Status) + } + } + + // 恢复后能重新注册 + if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil { + t.Errorf("恢复后应当能重新注册: %v", err) + } +} + +func TestSetAgentDisabledUnknownAgent(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + _, err := SetAgentDisabled(ctx, "nope", true) + if !errors.Is(err, sql.ErrNoRows) { + t.Errorf("停用不存在的 Agent 应回 ErrNoRows,实际 %v", err) + } +} + +// 停用不得动邮件与会话 —— 往来里有一半是人自己写的。 +func TestSetAgentDisabledKeepsMailAndSessions(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil { + t.Fatalf("注册: %v", err) + } + sid, err := CreateSession(ctx, nil, "bot", "一件事", "/tmp/ws") + if err != nil { + t.Fatalf("建会话: %v", err) + } + if _, err := CreateMail(ctx, sid, nil, + "human", "", "bot", "/tmp/ws", "主题", "正文", nil); err != nil { + t.Fatalf("建邮件: %v", err) + } + + if _, err := SetAgentDisabled(ctx, "bot", true); err != nil { + t.Fatalf("停用: %v", err) + } + + mails, err := ListInbox(ctx, "bot", "all", 10) + if err != nil { + t.Fatalf("ListInbox: %v", err) + } + if len(mails) != 1 { + t.Errorf("停用把邮件删了:剩 %d 封。那些往来里有一半是人自己写的", len(mails)) + } +} + +// 模型范围与平台会话镜像也保留:恢复后不必重配。 +func TestSetAgentDisabledKeepsModelScope(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil { + t.Fatalf("注册: %v", err) + } + if err := SetAllowedModels(ctx, "bot", []ModelRef{{Provider: "p", Model: "m"}}); err != nil { + t.Fatalf("设范围: %v", err) + } + + if _, err := SetAgentDisabled(ctx, "bot", true); err != nil { + t.Fatalf("停用: %v", err) + } + + allowed, err := ListAllowedModels(ctx, "bot") + if err != nil { + t.Fatalf("ListAllowedModels: %v", err) + } + if len(allowed) != 1 { + t.Errorf("停用把模型范围清了,恢复后管理员得重配一遍:%+v", allowed) + } +} + +// seedAdminForTest 插一个管理员并返回它的 user_id(CreateAgentKey 要 created_by)。 +func seedAdminForTest(t *testing.T, ctx context.Context) uuid.UUID { + t.Helper() + var id uuid.UUID + err := db.DB.QueryRowContext(ctx, + `INSERT INTO users (username, display_name, password_hash, role) + VALUES ('key-admin', 'Admin', 'x', 'admin') RETURNING user_id`).Scan(&id) + if err != nil { + t.Fatalf("seed admin: %v", err) + } + return id +} diff --git a/gateway/internal/repo/autoalias.go b/gateway/internal/repo/autoalias.go new file mode 100644 index 0000000..9068e71 --- /dev/null +++ b/gateway/internal/repo/autoalias.go @@ -0,0 +1,177 @@ +package repo + +import ( + "context" + "fmt" + "strings" + "unicode" + "unicode/utf8" + + "github.com/agentmail/gateway/internal/db" + "github.com/google/uuid" +) + +// 自动别名 —— 让 `.new` 建出来的会话立刻可被寻址。 +// +// # 为什么必须自动命名 +// +// `session` 位三态里 `new` 是**一次性动作**:它建出会话就用完了。之后要再投进 +// 同一条会话,只有两条路 —— `reply_to` 某封具体邮件,或者 `name@path.<别名>`。 +// 而 `CreateSession(alias=nil)` 建出来的会话别名是 NULL,于是: +// +// - `FindNamedSessionFor` 查不到它(`WHERE session_alias = $1` 对 NULL 不成立) +// - `SuggestSessionCandidates` 跳过它(`session_alias IS NOT NULL AND <> ''`) +// - 参与方拿到的 `new_mail` 里 `session_alias` 是空串 +// +// 结果是:被抄送方收到一封 `x@/p.new` 的邮件,**除了回复那一封之外无法再投进这条 +// 会话**。再发一次 `x@/p.new` 只会建第三条会话。这不是能力缺失,是寻址断链。 +// +// 原先的设计假定平台插件会通过 `POST /sessions/{id}/sync` 把模型生成的标题回写成 +// 别名,于是「未命名」只是短暂状态。但两件事让这个假定不成立: +// +// 1. 人类发的邮件根本没有平台侧,永远等不到回写; +// 2. 回写发生在模型跑完第一轮之后,而抄送方**在那之前**就要决定回信地址。 +// +// 因此本侧先给一个可用的别名,平台随后仍可用 `SyncSessionAlias` 改写它 —— +// `alias_source` 保持 `platform` 正是为此:自动名不是人定的名,不该挡住平台命名。 +// +// # 为什么不复用 SyncSessionAlias +// +// 那个函数假定「会话已存在、现在要改名」,并且会跳过 `manual`。这里的场景是 +// 「刚建完、还没有名字」,且必须在**建会话的同一个请求里**完成,否则中间那一瞬 +// 发出的 SSE 仍然带空别名。 + +// aliasMaxBytes 与 normalizeAlias 的截断上限一致(sessions.session_alias 为 VARCHAR(128))。 +const aliasMaxBytes = 128 + +// autoAliasAttempts 是撞名后追加 -2、-3… 的尝试次数上限。 +// 与 SyncSessionAlias 取同一个数量级:同一主题在同一天内开几十条会话已属异常, +// 真到了上限说明调用方在刷会话,此时报错比继续找空位更有价值。 +const autoAliasAttempts = 50 + +// AutoAliasFor 依据收件人与主题拼一个候选别名(未做唯一性检查)。 +// +// 形如 `dsh-重构导入路径`:前缀用收件方名字,后缀用主题。**两者都要**—— +// 只用主题时「服务恢复验证」这类通用主题会在不同 Agent 之间反复撞名, +// 只用名字则同一个 Agent 的所有会话都叫 `dsh-2`、`dsh-3`,看不出在聊什么。 +// +// 主题为空(少见但合法)时退回单独的名字,由调用方靠后缀去重。 +func AutoAliasFor(toName, subject string) string { + base := sanitizeAliasPart(toName) + topic := sanitizeAliasPart(subject) + + switch { + case base == "" && topic == "": + // 两边都拿不出可用字符(例如主题全是标点、名字为空)。 + // 返回空串让调用方走随机兜底,不要在这里编造。 + return "" + case base == "": + return truncateAlias(topic) + case topic == "": + return truncateAlias(base) + default: + return truncateAlias(base + "-" + topic) + } +} + +// EnsureSessionAlias 保证会话拥有一个可寻址的别名,返回最终别名。 +// +// 已有别名时原样返回,不做任何写入 —— 这让它可以被无条件调用, +// 包括「默认会话」路径上那条可能是刚建的、也可能是复用的会话。 +// +// 撞名时追加 -2、-3… 后缀;`want` 为空或全部被占用时退回 +// `session-`:一个能寻址的丑名字,远胜于没有名字。 +func EnsureSessionAlias(ctx context.Context, id uuid.UUID, want string) (string, error) { + if cur := SessionAliasOf(ctx, id); cur != "" { + return cur, nil + } + + cands := make([]string, 0, autoAliasAttempts+1) + if want != "" { + for i := 0; i < autoAliasAttempts; i++ { + if i == 0 { + cands = append(cands, want) + continue + } + cands = append(cands, truncateAlias(fmt.Sprintf("%s-%d", want, i+1))) + } + } + // 兜底:uuid 前 8 位。碰撞概率可忽略,且与 want 无关, + // 因此即便主题里一个可用字符都没有也总能拿到别名。 + cands = append(cands, "session-"+id.String()[:8]) + + for _, c := range cands { + // 条件写入:`session_alias IS NULL OR = ''` 保证并发下只有一方写成功, + // 另一方 RowsAffected=0,随后重读拿到对方写的名字 —— + // 两个请求都返回同一个别名,而不是各自以为自己命名成功。 + res, err := db.DB.ExecContext(ctx, + `UPDATE sessions SET session_alias = $1, updated_at = NOW() + WHERE session_id = $2 AND (session_alias IS NULL OR session_alias = '')`, + c, id) + if err != nil { + if db.IsUniqueViolation(err) { + continue // 别名被别的会话占了,试下一个后缀 + } + return "", err + } + if n, _ := res.RowsAffected(); n == 0 { + // 期间别人(并发请求或平台同步)已经命名过,尊重那个名字 + if cur := SessionAliasOf(ctx, id); cur != "" { + return cur, nil + } + // 写不进去且读不到名字,只可能是会话刚被删 + return "", fmt.Errorf("会话 %s 已不存在,无法分配别名", id) + } + return c, nil + } + + return "", fmt.Errorf("别名 %q 连同 -2..-%d 后缀与 uuid 兜底均被占用", want, autoAliasAttempts) +} + +// sanitizeAliasPart 把任意文本压成别名可用的片段。 +// +// 规则与 normalizeAlias 一致(非法字符换 -、压缩连续 -、去首尾 -), +// 另外多做两件事: +// +// - **去掉 Markdown / 标点噪声**:主题里的 `[联调]`、`—`、`:` 变成一串 +// 破折号毫无信息量。只保留字母、数字与非标点的 Unicode 字符(中文、日文等)。 +// - **压缩空白**:`Re: 服务恢复验证` → `Re-服务恢复验证`,而不是 `Re--服务恢复验证`。 +// +// 保留中文是刻意的:本项目的会话主题多为中文,转拼音需要额外依赖, +// 而 `dsh-重构导入路径` 作为地址完全可用(三维寻址只忌 `. / @` 与空白)。 +func sanitizeAliasPart(s string) string { + var b strings.Builder + lastDash := false + for _, r := range s { + keep := unicode.IsLetter(r) || unicode.IsDigit(r) + if keep { + b.WriteRune(r) + lastDash = false + continue + } + // 其余一切(空白、标点、符号、寻址保留字符)都折成单个 - + if !lastDash && b.Len() > 0 { + b.WriteByte('-') + lastDash = true + } + } + out := strings.Trim(b.String(), "-") + // "new" 是寻址保留字,作为整体别名时必须避开。 + // 加前缀而不是拒绝:调用方给的素材没有错,是这个词恰好被占用。 + if out == "new" { + return "session-new" + } + return out +} + +// truncateAlias 按字节截断且不切坏多字节字符(中文主题很容易超 128 字节)。 +func truncateAlias(s string) string { + if len(s) <= aliasMaxBytes { + return strings.Trim(s, "-") + } + cut := s[:aliasMaxBytes] + for len(cut) > 0 && !utf8.ValidString(cut) { + cut = cut[:len(cut)-1] + } + return strings.Trim(cut, "-") +} diff --git a/gateway/internal/repo/autoalias_test.go b/gateway/internal/repo/autoalias_test.go new file mode 100644 index 0000000..e8194b5 --- /dev/null +++ b/gateway/internal/repo/autoalias_test.go @@ -0,0 +1,283 @@ +package repo + +import ( + "context" + "strings" + "testing" + + "github.com/agentmail/gateway/internal/db" + "github.com/google/uuid" +) + +// 这一组测试守的是一条不变量:**`.new` 建出来的会话必须立刻可寻址**。 +// +// 破坏方式很隐蔽 —— 邮件照样送达、收件人照样能回复那一封,只有「指名投进同一条 +// 会话」这个动作静默失败(`FindNamedSessionFor` 查不到未命名会话),再发一次 +// `.new` 就多一条平行会话。所以这里的断言都落在「事后能不能按别名找回来」上, +// 而不是「有没有报错」。 + +func TestAutoAliasForCombinesNameAndSubject(t *testing.T) { + // 名字与主题都要在:只用主题时「服务恢复验证」这类通用主题会跨 Agent 撞名, + // 只用名字则同一个 Agent 的会话全叫 dsh-2、dsh-3,看不出在聊什么。 + got := AutoAliasFor("dsh", "重构导入路径") + if got != "dsh-重构导入路径" { + t.Fatalf("want dsh-重构导入路径, got %q", got) + } +} + +func TestAutoAliasForStripsAddressingChars(t *testing.T) { + // 别名要参与 name@path.session 的切分,含 . / @ 或空白会让地址解析歧义。 + // 主题里的 Markdown 与标点噪声([联调]、—、:)也不该变成一串破折号。 + cases := []struct{ in, want string }{ + {"[联调] llmsproxy / ModelRouter — 请提供部署现状", "x-联调-llmsproxy-ModelRouter-请提供部署现状"}, + {"a.b.c", "x-a-b-c"}, + {"has spaces", "x-has-spaces"}, + {"user@host", "x-user-host"}, + {"Re: 服务恢复验证", "x-Re-服务恢复验证"}, + } + for _, c := range cases { + got := AutoAliasFor("x", c.in) + if got != c.want { + t.Errorf("AutoAliasFor(x, %q) = %q, want %q", c.in, got, c.want) + } + if strings.ContainsAny(got, ". \t/@") { + t.Errorf("别名 %q 含寻址保留字符,会破坏地址解析", got) + } + } +} + +func TestAutoAliasForAvoidsReservedNew(t *testing.T) { + // "new" 是 session 位的保留字。别名若正好是它,`x@/p.new` 就同时是 + // 「投进这条会话」与「再建一条」两种意思。 + if got := AutoAliasFor("", "new"); got == "new" { + t.Fatal("别名不得为保留字 new") + } +} + +func TestAutoAliasForEmptyMaterial(t *testing.T) { + // 素材里一个可用字符都没有时返回空串,交由 EnsureSessionAlias 走 uuid 兜底, + // 而不是在这里编造一个名字。 + if got := AutoAliasFor("", "···"); got != "" { + t.Fatalf("want empty, got %q", got) + } +} + +func TestAutoAliasForTruncatesAtByteLimit(t *testing.T) { + // session_alias 是 VARCHAR(128),而中文主题很容易超;按字节截断时 + // 不能把多字节字符切坏(切坏后写库会得到非法 UTF-8)。 + got := AutoAliasFor("bot", strings.Repeat("中", 200)) + if len(got) > aliasMaxBytes { + t.Fatalf("别名 %d 字节,超过上限 %d", len(got), aliasMaxBytes) + } + if !utf8Valid(got) { + t.Fatal("截断切坏了多字节字符") + } +} + +func utf8Valid(s string) bool { + for _, r := range s { + if r == '\uFFFD' { + return false + } + } + return true +} + +func TestEnsureSessionAliasMakesNewSessionAddressable(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + // 复刻 `.new` 的真实路径:CreateSession(alias=nil) —— 别名是 NULL。 + sid, err := CreateSession(ctx, nil, "admin", "重构导入路径", "/home/program/agentmail") + if err != nil { + t.Fatalf("建会话: %v", err) + } + seedMailForSession(t, sid, "admin", "dsh", "/home/program/agentmail") + + // 命名前:按别名找不回来(这正是线上那条断链) + if _, err := FindNamedSessionFor(ctx, "dsh", "/home/program/agentmail", "dsh-重构导入路径"); err == nil { + t.Fatal("未命名会话竟然能按别名找到,测试前提不成立") + } + + alias, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("dsh", "重构导入路径")) + if err != nil { + t.Fatalf("命名: %v", err) + } + if alias == "" { + t.Fatal("别名为空") + } + + // 命名后:收件方能指名投回这条会话,而不是又开一条 + got, err := FindNamedSessionFor(ctx, "dsh", "/home/program/agentmail", alias) + if err != nil { + t.Fatalf("按别名寻址: %v", err) + } + if got != sid { + t.Fatalf("别名 %q 指向 %s,应指向 %s", alias, got, sid) + } +} + +func TestEnsureSessionAliasKeepsExistingName(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + // 调用方显式命名过(发信时传了 session_alias),标记为 manual。 + // 自动命名绝不能覆盖它 —— 人记住的地址不该下一秒失效。 + want := "llmsproxy-joint" + sid, err := CreateSession(ctx, &want, "dsh", "联调", "/home/program/llmsproxy") + if err != nil { + t.Fatalf("建会话: %v", err) + } + + got, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("opencode", "别的主题")) + if err != nil { + t.Fatalf("命名: %v", err) + } + if got != want { + t.Fatalf("已有别名被改写成 %q,应保持 %q", got, want) + } +} + +func TestEnsureSessionAliasIsIdempotent(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + // 默认会话路径上 EnsureSessionAlias 会被每封邮件调用一次 + // (会话可能是刚建的也可能是复用的),因此重复调用必须返回同一个名字。 + sid, _ := CreateSession(ctx, nil, "admin", "服务恢复验证", "/tmp/ws") + first, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("dsh", "服务恢复验证")) + if err != nil { + t.Fatalf("首次命名: %v", err) + } + second, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("dsh", "服务恢复验证")) + if err != nil { + t.Fatalf("二次命名: %v", err) + } + if first != second { + t.Fatalf("重复调用给出两个别名: %q vs %q", first, second) + } +} + +func TestEnsureSessionAliasSuffixesOnCollision(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + // 同一个 Agent + 同一主题会反复出现(「服务恢复验证」发两次)。 + // 别名全局唯一(负责寻址),撞名必须让位而不是报错 —— 发信不该因为 + // 主题重复而失败。 + want := AutoAliasFor("dsh", "服务恢复验证") + + a, _ := CreateSession(ctx, nil, "admin", "服务恢复验证", "/tmp/ws") + aliasA, err := EnsureSessionAlias(ctx, a, want) + if err != nil { + t.Fatalf("首个会话命名: %v", err) + } + + b, _ := CreateSession(ctx, nil, "admin", "服务恢复验证", "/tmp/ws") + aliasB, err := EnsureSessionAlias(ctx, b, want) + if err != nil { + t.Fatalf("第二个会话命名: %v", err) + } + + if aliasA == aliasB { + t.Fatalf("两条会话拿到同一个别名 %q", aliasA) + } + if aliasB != want+"-2" { + t.Fatalf("撞名后缀应为 %s-2,实际 %q", want, aliasB) + } + + // 两个别名各自指向自己那条会话,没有相互覆盖 + for alias, expect := range map[string]uuid.UUID{aliasA: a, aliasB: b} { + var got uuid.UUID + err := db.DB.QueryRowContext(ctx, + `SELECT session_id FROM sessions WHERE session_alias = $1`, alias).Scan(&got) + if err != nil { + t.Fatalf("查别名 %q: %v", alias, err) + } + if got != expect { + t.Errorf("别名 %q 指向 %s,应指向 %s", alias, got, expect) + } + } +} + +func TestEnsureSessionAliasFallsBackToUUID(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + // 主题与名字都拿不出可用字符时(AutoAliasFor 返回空串), + // 仍必须得到一个能寻址的别名 —— 丑名字远胜于没有名字。 + sid, _ := CreateSession(ctx, nil, "admin", "···", "/tmp/ws") + alias, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("", "···")) + if err != nil { + t.Fatalf("兜底命名: %v", err) + } + if alias == "" { + t.Fatal("兜底后别名仍为空") + } + if !strings.HasPrefix(alias, "session-") { + t.Fatalf("兜底别名应形如 session-xxxxxxxx,实际 %q", alias) + } +} + +func TestEnsureSessionAliasSurfacesInSuggestions(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + // 补全候选只收「有别名的非归档会话」(`session_alias IS NOT NULL AND <> ''`)。 + // 自动命名的另一半价值就在这里:命名前这条会话在人类的三段式补全里 + // 也是不可见的,人同样只能靠回复某封邮件才能续谈。 + seedUser(t, ctx, "admin") + sid, _ := CreateSession(ctx, nil, "admin", "重构导入路径", "/home/program/agentmail") + seedMailForSession(t, sid, "admin", "dsh", "/home/program/agentmail") + + before, err := SuggestSessionCandidates(ctx, "admin", "dsh", "/home/program/agentmail") + if err != nil { + t.Fatalf("补全(命名前): %v", err) + } + for _, c := range before { + if c.Source == "mail" { + t.Fatalf("未命名会话不该出现在补全里,却拿到 %+v", c) + } + } + + alias, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("dsh", "重构导入路径")) + if err != nil { + t.Fatalf("命名: %v", err) + } + + after, err := SuggestSessionCandidates(ctx, "admin", "dsh", "/home/program/agentmail") + if err != nil { + t.Fatalf("补全(命名后): %v", err) + } + found := false + for _, c := range after { + if c.Alias == alias { + found = true + } + } + if !found { + t.Fatalf("命名后 %q 仍未出现在补全候选里: %+v", alias, after) + } +} + +// seedMailForSession 往会话里塞一封邮件。 +// FindNamedSessionFor 与 SuggestSessionCandidates 都要求「该收件人参与过」, +// 只建会话不建邮件的话两者都查不到,测试会得出错误结论。 +func seedMailForSession(t *testing.T, sid uuid.UUID, from, to, workspace string) { + t.Helper() + if _, err := CreateMail(context.Background(), sid, nil, + from, "", to, workspace, "主题", "正文", nil); err != nil { + t.Fatalf("seed mail: %v", err) + } +} + +// seedUser 插一个用户。SuggestSessionCandidates 的可见性条件要查 users 表。 +func seedUser(t *testing.T, ctx context.Context, username string) { + t.Helper() + _, err := db.DB.ExecContext(ctx, + `INSERT INTO users (username, display_name, password_hash, role) + VALUES ($1, $1, 'x', 'admin')`, username) + if err != nil { + t.Fatalf("seed user %s: %v", username, err) + } +} diff --git a/gateway/internal/repo/participants.go b/gateway/internal/repo/participants.go new file mode 100644 index 0000000..c065cd3 --- /dev/null +++ b/gateway/internal/repo/participants.go @@ -0,0 +1,135 @@ +package repo + +import ( + "context" + "encoding/json" + "sort" + + "github.com/agentmail/gateway/internal/db" + "github.com/agentmail/gateway/internal/models" + "github.com/google/uuid" +) + +// Participant 是一条会话里的一个参与方。 +// +// Path 是该参与方**自己那个地址的 path 位**,不是别人的:一封主发给 dsh@/b、 +// 抄送给 opencode@/a 的邮件里,两人的工作目录不同,混用会让对方在别人的目录里 +// 开会话(生产上已发生过一次,见 PLUGIN-CONTRACT 9.3)。 +type Participant struct { + Name string `json:"name"` + Path string `json:"path"` + // Roles 是该参与方在这条会话里出现过的全部身份,from / to / cc 的并集。 + // 用集合而非单值:同一个人常常既发过信也被抄送过,只留最后一个身份会让 + // 「谁是这件事的负责人」这个判断出错。 + Roles []string `json:"roles"` + // MailCount 是该参与方作为发件人的邮件数。用来回答「谁还没回」—— + // 参与方列表里 from 计数为 0 的那个就是还没开口的人。 + MailCount int `json:"mail_count"` +} + +// SessionParticipants 列出会话的全部参与方及各自的地址素材。 +// +// 为什么要逐封扫而不是看 sessions 表:**参与方是随往来增长的**。会话建立时 +// 只有发件人与收件人,一封抄送、一次转发都会带进新的人。sessions 表里只有 +// from_agent 一个名字,回答不了「这条线索上现在有谁」。 +// +// 排序:按首次出现顺序(created_at)。这让主收件人稳定排在抄送方之前, +// 模型据此判断「谁是负责人、谁是配合方」——按名字排序会丢掉这个信息。 +func SessionParticipants(ctx context.Context, sessionID uuid.UUID) ([]Participant, error) { + rows, err := db.DB.QueryContext(ctx, ` + SELECT from_name, COALESCE(from_workspace,''), + to_name, COALESCE(to_workspace,''), + cc_list + FROM mails + WHERE session_id = $1 + ORDER BY created_at ASC, mail_id ASC + `, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + + type acc struct { + p Participant + roles map[string]bool + order int + } + seen := map[string]*acc{} + next := 0 + + // note 记录一次「某人以某身份出现」。 + // + // path 只在**当前为空且新值非空**时补写:同一个人可能在不同邮件里带不同 + // path(先被抄送到 /a,后被主发到 /b)。保留首个非空值而不是最后一个, + // 与排序口径一致(首次出现顺序),也避免一封转发把地址改指到别处。 + note := func(name, path, role string, isSender bool) { + if name == "" { + return + } + a, ok := seen[name] + if !ok { + a = &acc{ + p: Participant{Name: name, Path: path}, + roles: map[string]bool{}, + order: next, + } + next++ + seen[name] = a + } + if a.p.Path == "" && path != "" { + a.p.Path = path + } + a.roles[role] = true + if isSender { + a.p.MailCount++ + } + } + + for rows.Next() { + var fromName, fromWS, toName, toWS string + var ccRaw []byte + if err := rows.Scan(&fromName, &fromWS, &toName, &toWS, &ccRaw); err != nil { + return nil, err + } + + // **发件人一侧不取 from_workspace 当 path。** Agent 回信时那一列存的是 + // Agent 名而不是路径(历史遗留,FindOrCreateDefaultSession 的注释里也提到 + // 同一个坑)。拿它拼地址会得到 `dsh@dsh.alias` 这种投不出去的东西。 + note(fromName, "", "from", true) + note(toName, toWS, "to", false) + + if len(ccRaw) > 0 { + var cc []models.Address + if json.Unmarshal(ccRaw, &cc) == nil { + for _, c := range cc { + note(c.Name, c.Path, "cc", false) + } + } + } + } + if err := rows.Err(); err != nil { + return nil, err + } + + out := make([]Participant, 0, len(seen)) + for _, a := range seen { + a.p.Roles = sortedKeys(a.roles) + out = append(out, a.p) + } + sort.Slice(out, func(i, j int) bool { + return seen[out[i].Name].order < seen[out[j].Name].order + }) + return out, nil +} + +// sortedKeys 给出稳定顺序的角色列表。 +// map 迭代顺序随机,不排序的话同一条会话每次返回的 roles 顺序都不同, +// 插件侧做 diff 或缓存时会误判为「参与方变了」。 +func sortedKeys(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/gateway/internal/repo/participants_test.go b/gateway/internal/repo/participants_test.go new file mode 100644 index 0000000..08dd25e --- /dev/null +++ b/gateway/internal/repo/participants_test.go @@ -0,0 +1,170 @@ +package repo + +import ( + "context" + "testing" + + "github.com/agentmail/gateway/internal/models" + "github.com/google/uuid" +) + +// 参与方列表要回答的是「这条线索上现在有谁、用什么地址找到他、谁还没开口」。 +// 三个问题里每一个都曾经答错过: +// - 有谁:sessions 表只有 from_agent 一个名字,抄送方与转发引入的人都不在里面 +// - 什么地址:拿 from_workspace 当 path 会拼出 dsh@dsh.alias 这种投不出去的东西 +// - 谁还没回:只留最后一个身份的话,既发过信又被抄送过的人会被算成纯配合方 + +func TestSessionParticipantsIncludesCC(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + sid, _ := CreateSession(ctx, nil, "admin", "抄收联调", "/home/program/llmsproxy") + // 复刻线上那封:admin 主发 dsh,抄送 opencode + mustMail(t, sid, "admin", "", "dsh", "/home/program/llmsproxy", + []models.Address{{Name: "opencode", Path: "/home", Session: "new", Raw: "opencode@/home.new"}}) + + parts, err := SessionParticipants(ctx, sid) + if err != nil { + t.Fatalf("列参与方: %v", err) + } + + byName := map[string]Participant{} + for _, p := range parts { + byName[p.Name] = p + } + for _, want := range []string{"admin", "dsh", "opencode"} { + if _, ok := byName[want]; !ok { + t.Errorf("参与方缺 %s,实得 %+v", want, parts) + } + } + // 抄送方的 path 必须是它自己那个地址的 path 位,不是主收件人的。 + // 用错的后果:对方在别人的工作目录里开会话。 + if got := byName["opencode"].Path; got != "/home" { + t.Errorf("opencode 的 path = %q,应为 /home(它自己地址的 path 位)", got) + } + if got := byName["dsh"].Path; got != "/home/program/llmsproxy" { + t.Errorf("dsh 的 path = %q,应为 /home/program/llmsproxy", got) + } +} + +func TestSessionParticipantsSenderPathStaysEmpty(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + sid, _ := CreateSession(ctx, nil, "admin", "回信", "/tmp/ws") + // Agent 回信时 from_workspace 存的是 Agent 名而非路径(历史遗留)。 + // 若把它当 path,地址会拼成 dsh@dsh.alias —— 投不出去。 + mustMail(t, sid, "dsh", "dsh", "admin", "", nil) + + parts, _ := SessionParticipants(ctx, sid) + for _, p := range parts { + if p.Name == "dsh" && p.Path == "dsh" { + t.Fatal("发件人的 path 取了 from_workspace(那列存的是 Agent 名),会拼出无效地址") + } + } +} + +func TestSessionParticipantsMergesRoles(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + sid, _ := CreateSession(ctx, nil, "admin", "往返", "/tmp/ws") + mustMail(t, sid, "admin", "", "dsh", "/tmp/ws", nil) // admin=from, dsh=to + mustMail(t, sid, "dsh", "dsh", "admin", "", nil) // dsh=from, admin=to + + parts, _ := SessionParticipants(ctx, sid) + for _, p := range parts { + if len(p.Roles) != 2 { + t.Errorf("%s 的 roles = %v,双方都该同时有 from 与 to", p.Name, p.Roles) + } + } +} + +func TestSessionParticipantsCountsOnlySends(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + sid, _ := CreateSession(ctx, nil, "admin", "谁还没回", "/tmp/ws") + mustMail(t, sid, "admin", "", "dsh", "/tmp/ws", + []models.Address{{Name: "opencode", Path: "/tmp/ws", Raw: "opencode@/tmp/ws"}}) + mustMail(t, sid, "dsh", "dsh", "admin", "", nil) + + parts, _ := SessionParticipants(ctx, sid) + got := map[string]int{} + for _, p := range parts { + got[p.Name] = p.MailCount + } + // MailCount 只数「作为发件人」的邮件:抄送方 opencode 一封都没发, + // 计数为 0 正是「还没开口的人」这个判断的依据。 + if got["opencode"] != 0 { + t.Errorf("opencode 只被抄送未发信,MailCount 应为 0,实为 %d", got["opencode"]) + } + if got["admin"] != 1 || got["dsh"] != 1 { + t.Errorf("admin/dsh 各发过一封,实为 %d/%d", got["admin"], got["dsh"]) + } +} + +func TestSessionParticipantsKeepsFirstSeenOrder(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + sid, _ := CreateSession(ctx, nil, "admin", "顺序", "/tmp/ws") + mustMail(t, sid, "admin", "", "dsh", "/tmp/ws", + []models.Address{{Name: "opencode", Path: "/home", Raw: "opencode@/home"}}) + + parts, _ := SessionParticipants(ctx, sid) + // 按首次出现排序,让主收件人稳定排在抄送方之前 —— + // 模型据此判断谁是负责人、谁是配合方;按名字排序会丢掉这个信息。 + want := []string{"admin", "dsh", "opencode"} + if len(parts) != len(want) { + t.Fatalf("参与方数量 %d,期望 %d: %+v", len(parts), len(want), parts) + } + for i, w := range want { + if parts[i].Name != w { + t.Errorf("第 %d 位是 %s,期望 %s", i, parts[i].Name, w) + } + } +} + +func TestSessionParticipantsPrefersFirstNonEmptyPath(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + sid, _ := CreateSession(ctx, nil, "admin", "改指", "/tmp/a") + // 同一个人先被抄送到 /home,后被主发到 /tmp/b。 + // 保留首个非空值,与排序口径一致,也避免一封转发把地址改指到别处。 + mustMail(t, sid, "admin", "", "dsh", "/tmp/a", + []models.Address{{Name: "opencode", Path: "/home", Raw: "opencode@/home"}}) + mustMail(t, sid, "admin", "", "opencode", "/tmp/b", nil) + + parts, _ := SessionParticipants(ctx, sid) + for _, p := range parts { + if p.Name == "opencode" && p.Path != "/home" { + t.Fatalf("opencode 的 path = %q,应保持首次出现的 /home", p.Path) + } + } +} + +func TestSessionParticipantsEmptySession(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + // 会话刚建、还没有邮件。返回空列表而不是报错: + // 调用方拿到空表能正常渲染「暂无参与方」,拿到 error 只能整个失败。 + sid, _ := CreateSession(ctx, nil, "admin", "空会话", "/tmp/ws") + parts, err := SessionParticipants(ctx, sid) + if err != nil { + t.Fatalf("空会话应正常返回: %v", err) + } + if len(parts) != 0 { + t.Fatalf("空会话应无参与方,实得 %+v", parts) + } +} + +func mustMail(t *testing.T, sid uuid.UUID, from, fromWS, to, toWS string, cc []models.Address) { + t.Helper() + if _, err := CreateMail(context.Background(), sid, nil, + from, fromWS, to, toWS, "主题", "正文", cc); err != nil { + t.Fatalf("建邮件: %v", err) + } +} diff --git a/gateway/internal/repo/quota.go b/gateway/internal/repo/quota.go index b321852..64c50e6 100644 --- a/gateway/internal/repo/quota.go +++ b/gateway/internal/repo/quota.go @@ -43,6 +43,9 @@ type AgentStats struct { SentTotal int `json:"sent_total"` // ActiveSessions 该 Agent 参与的未归档会话数,配合默认值判断设多少合适 ActiveSessions int `json:"active_sessions"` + // Status 是 agents.status:online / offline / disabled。 + // 管理页靠它决定显示「停用」还是「恢复」。 + Status string `json:"status"` } // DefaultRoundsFor 读取该 Agent 的新任务默认预算。 @@ -127,8 +130,12 @@ func BumpSentCount(ctx context.Context, agentName string) { // ListAgentStats 列出所有 Agent 的默认预算与统计(管理员视图)。 func ListAgentStats(ctx context.Context) ([]AgentStats, error) { + // 带上 status:管理页靠它区分「在线 / 离线 / 已停用」并决定显示 + // 「停用」还是「恢复」按钮。不过滤 disabled —— 这里是唯一能把已停用的 + // Agent 恢复回来的地方,过滤掉就再也找不到它了。 rows, err := db.DB.QueryContext(ctx, - `SELECT agent_name, COALESCE(default_rounds, 0), COALESCE(used_rounds, 0) + `SELECT agent_name, COALESCE(default_rounds, 0), COALESCE(used_rounds, 0), + COALESCE(status, 'offline') FROM agents ORDER BY agent_name`) if err != nil { return nil, err @@ -138,7 +145,7 @@ func ListAgentStats(ctx context.Context) ([]AgentStats, error) { out := []AgentStats{} for rows.Next() { var st AgentStats - if err := rows.Scan(&st.AgentName, &st.DefaultRounds, &st.SentTotal); err != nil { + if err := rows.Scan(&st.AgentName, &st.DefaultRounds, &st.SentTotal, &st.Status); err != nil { return nil, err } out = append(out, st) diff --git a/gateway/internal/repo/repo.go b/gateway/internal/repo/repo.go index 4839b45..fd69f49 100644 --- a/gateway/internal/repo/repo.go +++ b/gateway/internal/repo/repo.go @@ -16,7 +16,28 @@ import ( // ---------- Agent ---------- +// ErrAgentDisabled 表示该 Agent 已被管理员停用。 +// +// 停用是可逆的「归档」:邮件、会话、权限记录全部保留,只是不再接受新任务。 +// 与删除分开是因为往来邮件里有一半是人自己写的 —— 停用 Agent 不该删掉 +// 用户的东西;而 Agent 名与人类用户名共用命名空间,历史邮件里的 from_name +// 指向一个已删除的名字时,下一个同名注册者会看起来像是当初的发信人。 +var ErrAgentDisabled = errors.New("agent disabled") + func CreateOrUpdateAgent(ctx context.Context, name, secret, platform string, workspaces []models.Workspace) error { + // 已停用的 Agent 不得靠重新注册复活。 + // + // 少了这一步的后果:管理员停用后,那个平台的插件下次启动就会重新注册 + // (注册是插件启动流程的一部分),status 被写回 online,停用等于没做。 + // 必须让插件收到一个明确的错误,而不是静默成功。 + disabled, dErr := AgentDisabled(ctx, name) + if dErr != nil { + return dErr + } + if disabled { + return ErrAgentDisabled + } + wsJSON, _ := json.Marshal(workspaces) // 注意 DO UPDATE 里【不】碰 default_rounds: // 那是管理员配的值,Agent 重启重新注册不应该把它冲回默认。 @@ -34,8 +55,13 @@ func CreateOrUpdateAgent(ctx context.Context, name, secret, platform string, wor } func HeartbeatAgent(ctx context.Context, agentName string) (int, error) { + // 只把【非停用】的 Agent 标成在线。 + // + // 不加这个条件的话,停用后插件的心跳会把 status 从 disabled 改回 online + // —— 而心跳是每 30 秒一次的,停用最多维持半分钟。 _, err := db.DB.ExecContext(ctx, - `UPDATE agents SET last_seen = NOW(), status = 'online' WHERE agent_name = $1`, + `UPDATE agents SET last_seen = NOW(), status = 'online' + WHERE agent_name = $1 AND status <> 'disabled'`, agentName) if err != nil { return 0, err @@ -43,13 +69,83 @@ func HeartbeatAgent(ctx context.Context, agentName string) (int, error) { return CountUnread(ctx, agentName) } +// AgentDisabled 该 Agent 是否已被停用。Agent 不存在时返回 false —— +// 「还没注册」与「被停用」是两件事,前者应当能正常注册。 +func AgentDisabled(ctx context.Context, agentName string) (bool, error) { + var status string + err := db.DB.QueryRowContext(ctx, + `SELECT status FROM agents WHERE agent_name = $1`, agentName).Scan(&status) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + return status == "disabled", nil +} + +// SetAgentDisabled 停用或恢复一个 Agent。 +// +// 停用时连带撤销它的全部密钥:留着密钥的话,那个平台的插件仍然能用它调 +// /mail/send —— 停用的语义是「这个 Agent 不再参与工作」,不只是「不出现在 +// 补全列表里」。恢复时不会把密钥变回来,管理员需要重新签发。 +// +// 返回撤销的密钥数,供界面提示。 +func SetAgentDisabled(ctx context.Context, agentName string, disabled bool) (int, error) { + if !disabled { + // 恢复:回到 offline 而不是 online —— 它是否真的在线由下一次心跳决定, + // 直接写 online 会让界面显示一个其实没在跑的 Agent 为在线。 + _, err := db.DB.ExecContext(ctx, + `UPDATE agents SET status = 'offline' WHERE agent_name = $1 AND status = 'disabled'`, + agentName) + return 0, err + } + + tx, err := db.DB.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + defer tx.Rollback() + + res, err := tx.ExecContext(ctx, + `UPDATE agents SET status = 'disabled' WHERE agent_name = $1`, agentName) + if err != nil { + return 0, err + } + if n, _ := res.RowsAffected(); n == 0 { + return 0, sql.ErrNoRows + } + + keyRes, err := tx.ExecContext(ctx, + `DELETE FROM agent_keys WHERE agent_name = $1`, agentName) + if err != nil { + return 0, err + } + revoked, _ := keyRes.RowsAffected() + + if err := tx.Commit(); err != nil { + return 0, err + } + return int(revoked), nil +} + +// ListAgents 列出 Agent。 +// +// statusFilter 为空时默认**排除已停用的** —— 这个函数的三个调用点 +// (地址补全、GET /agents、可授权范围)都是在回答「现在能派活给谁」, +// 而停用的 Agent 不该出现在那里。要连停用一起看,传 statusFilter="all"。 func ListAgents(ctx context.Context, statusFilter string) ([]models.Agent, error) { // 带上 default_rounds:前端补全收件人时要显示「派给它的任务默认几个来回」, // 否则人得先去管理员页查一遍才敢派活。 q := `SELECT agent_id, agent_name, workspaces, platform, status, COALESCE(default_rounds, 0) FROM agents` args := []any{} - if statusFilter != "" { + switch statusFilter { + case "": + q += ` WHERE status <> 'disabled'` + case "all": + // 不加条件 + default: q += ` WHERE status = $1` args = append(args, statusFilter) } @@ -739,7 +835,7 @@ func ListContactsFor(ctx context.Context, forUser string, archived bool) ([]Cont SELECT 1 FROM mails mm WHERE mm.session_id = s.session_id AND (mm.from_name = $1 OR mm.to_name = $1 - OR `+db.CCHas("mm.cc_list", 1)+`) + OR ` + db.CCHas("mm.cc_list", 1) + `) ))` args = append(args, forUser) } @@ -861,22 +957,76 @@ func FindSessionByAddress(ctx context.Context, name, path, alias string) (uuid.U return id, err } -// SuggestPaths 返回某 agent 已注册的工作区名(用于发信补全) +// SuggestPaths 给出某个收件方可用的工作目录候选(三段式补全的 path 位)。 +// +// 两个来源并集,**历史优先**: +// +// 1. mails.to_workspace 里真实投递过的目录(按最近使用倒序) +// 2. agents.workspaces 里注册时自报的目录 +// +// 早先只看第 2 项,而它对两个官方插件**永远是空的** —— 契约(W-2) +// 明确要求 `workspaces: []`,因为工作目录由每封邮件的 to_workspace 决定而不是 +// 注册时固定。于是补全的第二段对 dsh / opencode 一直给空列表, +// 人得手敲路径,Agent 更只能猜 —— 生产上 dsh 猜了 `opencode@/home`, +// 解析通过、投递成功,但那不是 opencode 的工作目录。 +// +// 按最近使用倒序而非字典序:同一个 Agent 常在几个项目间切,刚用过的那个 +// 几乎总是下一封想用的那个。 func SuggestPaths(ctx context.Context, agentName string) ([]string, error) { + out := []string{} + seen := map[string]bool{} + + // 来源 1:真实投递历史。即使 Agent 未注册(人类收件方)也能给出候选。 + // + // 只 SELECT 路径一列,排序用的 MAX(created_at) 不进结果集: + // SQLite 把时间戳存为 TEXT,把它 Scan 进 time.Time 会失败, + // 而那个值除了排序之外无用 —— 取回来只是多一个能静默失败的环节。 + rows, err := db.DB.QueryContext(ctx, ` + SELECT to_workspace + FROM mails + WHERE to_name = $1 AND COALESCE(to_workspace, '') <> '' + GROUP BY to_workspace + ORDER BY MAX(created_at) DESC + `, agentName) + if err == nil { + defer rows.Close() + for rows.Next() { + var p string + if rows.Scan(&p) != nil { + continue + } + if !seen[p] { + seen[p] = true + out = append(out, p) + } + } + } + + // 来源 2:注册时自报。报了但还没收过信的目录靠这一步进入候选, + // 否则新接入的 Agent 在第一封邮件之前仍然无路径可选。 var wsJSON []byte - err := db.DB.QueryRowContext(ctx, - `SELECT workspaces FROM agents WHERE agent_name = $1`, agentName).Scan(&wsJSON) - if err != nil { - return []string{}, err - } - var ws []models.Workspace - if len(wsJSON) > 0 { - json.Unmarshal(wsJSON, &ws) - } - out := make([]string, 0, len(ws)) - for _, w := range ws { - out = append(out, w.Name) + if err := db.DB.QueryRowContext(ctx, + `SELECT workspaces FROM agents WHERE agent_name = $1`, agentName).Scan(&wsJSON); err == nil { + var ws []models.Workspace + if len(wsJSON) > 0 { + json.Unmarshal(wsJSON, &ws) + } + for _, w := range ws { + // 三维地址的 path 位是**路径**,不是工作区的展示名。 + // 这里取 Path 而不是 Name:remotebot 报的是 + // {name:"demo", path:"/tmp/remotebot-ws"},取 Name 会给出 "demo" + // —— 一个拉起会话时不存在的目录。 + p := w.Path + if p == "" { + p = w.Name + } + if p != "" && !seen[p] { + seen[p] = true + out = append(out, p) + } + } } + return out, nil } @@ -978,7 +1128,7 @@ func ListSessionsFor(ctx context.Context, forUser string, limit int) ([]models.S SELECT 1 FROM mails mm WHERE mm.session_id = s.session_id AND (mm.from_name = $1 OR mm.to_name = $1 - OR `+db.CCHas("mm.cc_list", 1)+`) + OR ` + db.CCHas("mm.cc_list", 1) + `) ))` args = append(args, forUser) } diff --git a/plugins/dsh-mail-bridge/lib/addressing.d.ts b/plugins/dsh-mail-bridge/lib/addressing.d.ts new file mode 100644 index 0000000..5314da6 --- /dev/null +++ b/plugins/dsh-mail-bridge/lib/addressing.d.ts @@ -0,0 +1,13 @@ +export interface MailParticipant { + role: string; + name: string; + path: string; + address: string; + is_self: boolean; +} + +export function formatAddress(name: string, path?: string, session?: string): string; +export function roleOf(mail: any, selfName: string): 'to' | 'cc' | 'unknown'; +export function replyAddressFor(mail: any, alias?: string): string; +export function selfAddressFor(mail: any, selfName: string, alias?: string): string; +export function participantsOfMail(mail: any, selfName?: string, alias?: string): MailParticipant[]; diff --git a/plugins/dsh-mail-bridge/lib/addressing.js b/plugins/dsh-mail-bridge/lib/addressing.js new file mode 100644 index 0000000..f80ea11 --- /dev/null +++ b/plugins/dsh-mail-bridge/lib/addressing.js @@ -0,0 +1,141 @@ +/** + * 三维寻址的构造与判读 —— 所有平台插件共用。 + * + * 为什么这些函数必须共用、且必须是纯函数: + * + * 地址拼错不会报错。`name@path.session` 的每一段都可以省略,任何组合都能被 + * `ParseAddress` 解析出**某个**结果,于是拼错的代价不是失败而是**投到别处**。 + * 生产上真实发生过两次: + * + * 1. 插件把 `.new` 原样当作回信地址 —— `.new` 是一次性动作,回过去只会 + * 再建一条平行会话,双方从此各说各话。 + * 2. path 为空时朴素拼接得到 `admin.silent-harbor` —— 没有 `@`, + * 整串被当成名字,session 位静默丢失。 + * + * 两次都是「拼字符串」造成的,所以拼地址这件事收进这里,各平台不再自己拼。 + */ + +/** + * 拼一个可寻址的 `name@path.session`。 + * + * **空 path 也必须留下 `@` 与 `.`**:`admin@.silent-harbor` 才解析成 + * name=admin path="" session=silent-harbor。省掉 `@` 得到的 + * `admin.silent-harbor` 会被整串当作名字。 + * + * session 省略时不写那一位(默认会话语义)。 + * + * @param {string} name 收件方名(Agent 名或人类用户名) + * @param {string} [path] 工作目录,可为空 + * @param {string} [session] 会话别名;空则省略该位 + * @returns {string} 地址,name 为空时返回空串 + */ +export function formatAddress(name, path, session) { + const n = String(name ?? '').trim(); + const p = String(path ?? '').trim(); + const s = String(session ?? '').trim(); + if (!n) return ''; + if (!s) return p ? `${n}@${p}` : n; + return `${n}@${p}.${s}`; +} + +/** + * 判断自己在这封邮件里是收件人还是抄送方。 + * + * 为什么需要它:被抄送方与主收件人的**职责不同**。线上那封联调邮件里, + * admin 主发 dsh、抄送 opencode,分工是「dsh 提供源码解读、opencode 提供部署 + * 现状、最后由 dsh 汇报」。收件箱若不区分身份,两方都会以为自己是负责人, + * 或者都以为自己只是旁观者。 + * + * @param {any} mail `/mail/inbox` 返回的一封邮件 + * @param {string} selfName 自己的 Agent 名 + * @returns {'to'|'cc'|'unknown'} + */ +export function roleOf(mail, selfName) { + const self = String(selfName ?? '').trim(); + if (!self) return 'unknown'; + if (mail?.to_name === self) return 'to'; + if (Array.isArray(mail?.cc_list) && mail.cc_list.some(c => c?.name === self)) { + return 'cc'; + } + return 'unknown'; +} + +/** + * 给出「把回信发回这条会话」的地址。 + * + * 发件人一侧**不带 path**:Agent 回信时 `from_workspace` 存的是 Agent 名而不是 + * 路径(历史遗留),拿它拼会得到 `dsh@dsh.alias` 这种投不出去的东西。 + * 人类发件人本来就没有工作目录。 + * + * 别名为空时退回 `name`(默认会话)而不是编一个 —— 但注意这与「投回同一条会话」 + * 不等价,默认会话是该 name 当前最活跃的那条。调用方要区分时看返回值有没有 `.`。 + * + * @param {any} mail 一封邮件 + * @param {string} [alias] 会话别名,缺省取 mail.session_alias + * @returns {string} + */ +export function replyAddressFor(mail, alias) { + const a = alias ?? mail?.session_alias ?? ''; + return formatAddress(mail?.from_name, '', a); +} + +/** + * 给出自己在这条会话里的地址,供转发说明或向第三方引用时使用。 + * + * 用 `to_workspace`(自己那个地址的 path 位)而不是发件人的: + * 抄送给 `opencode@/a` 与主发给 `dsh@/b` 是两个不同的工作区。 + * + * @param {any} mail 一封邮件 + * @param {string} selfName 自己的 Agent 名 + * @param {string} [alias] 会话别名,缺省取 mail.session_alias + * @returns {string} + */ +export function selfAddressFor(mail, selfName, alias) { + const a = alias ?? mail?.session_alias ?? ''; + // 抄送方拿到的 to_workspace 是主收件人的,自己的 path 在 cc_list 里。 + // 不取对的那个会让「我是谁」这句话指向别人的工作目录。 + let path = mail?.to_workspace ?? ''; + if (mail?.to_name !== selfName && Array.isArray(mail?.cc_list)) { + const mine = mail.cc_list.find(c => c?.name === selfName); + if (mine) path = mine.path ?? ''; + } + return formatAddress(selfName, path, a); +} + +/** + * 列出这封邮件的全部参与方及各自可投递的地址。 + * + * 这是「回给抄收方」缺的那块信息:知道有谁,**以及用什么地址找到他**。 + * 抄送方的 path 取它自己那个地址的 path 位。 + * + * 自己会被标 `is_self`,而不是从列表里剔掉 —— 剔掉的话模型无法确认 + * 「这封信是不是也发给了我」,也就无法判断自己是不是该回。 + * + * @param {any} mail 一封邮件 + * @param {string} [selfName] 自己的名字,用于标记 is_self + * @param {string} [alias] 会话别名,缺省取 mail.session_alias + * @returns {{role: string, name: string, path: string, address: string, is_self: boolean}[]} + */ +export function participantsOfMail(mail, selfName, alias) { + const a = alias ?? mail?.session_alias ?? ''; + const self = String(selfName ?? '').trim(); + const out = []; + const add = (role, name, path) => { + const n = String(name ?? '').trim(); + if (!n) return; + out.push({ + role, + name: n, + path: String(path ?? ''), + address: formatAddress(n, path, a), + is_self: !!self && n === self, + }); + }; + // 发件人一侧 path 留空,理由同 replyAddressFor + add('from', mail?.from_name, ''); + add('to', mail?.to_name, mail?.to_workspace); + if (Array.isArray(mail?.cc_list)) { + for (const c of mail.cc_list) add('cc', c?.name, c?.path); + } + return out; +} diff --git a/plugins/dsh-mail-bridge/lib/discovery.d.ts b/plugins/dsh-mail-bridge/lib/discovery.d.ts new file mode 100644 index 0000000..2b3edbf --- /dev/null +++ b/plugins/dsh-mail-bridge/lib/discovery.d.ts @@ -0,0 +1,6 @@ +export function renderNameSuggestions(names: readonly string[]): string; +export function renderPathSuggestions(paths: readonly string[], name: string): string; +export function renderSessionSuggestions(data: any, name: string, path: string): string; +export function renderParticipants(data: any): string; +export function renderContacts(data: any, limit?: number): string; +export function renderThread(data: any, selfName?: string): string; diff --git a/plugins/dsh-mail-bridge/lib/discovery.js b/plugins/dsh-mail-bridge/lib/discovery.js new file mode 100644 index 0000000..7b81d57 --- /dev/null +++ b/plugins/dsh-mail-bridge/lib/discovery.js @@ -0,0 +1,237 @@ +/** + * 寻址发现工具 —— 所有平台插件共用的**纯逻辑**部分。 + * + * 三个 Agent 侧只读端点(`/agent/contacts`、`/agent/contacts/suggest`、 + * `/agent/sessions/{id}/participants`)的返回值怎么渲染给模型看,与平台 SDK 无关, + * 所以收进这里。各平台只负责把自己的工具定义壳套上去。 + * + * # 这一组端点解决的问题 + * + * 在它们存在之前,`send_mail` 的 `to` 是一个**只能靠记忆拼写的自由文本字段**。 + * 人类侧从来不是这样:三段式输入框逐段查候选,name / path / session 每一段都从 + * 活数据里选。Agent 只能猜,而猜错不会报错 —— 生产上 dsh 猜了 + * `opencode@/home`,地址解析通过、投递成功,但那不是 opencode 的工作目录, + * 那个错误路径静默变成了新会话的 workspace。 + * + * # 渲染的取舍 + * + * 一律输出**可直接粘进 `to` 的完整地址**,而不是把三段分开列。模型看到 + * `opencode@/home.silent-harbor` 会整串复制;看到 `name=opencode path=/home + * session=silent-harbor` 则要自己拼,而自己拼就是问题的来源。 + */ + +/** + * 渲染候选收件人清单(`kind: "name"`)。 + * + * 只给名字,不给地址:此时还不知道 path 与 session,硬拼出来的 + * 裸名字地址会投到「默认会话」—— 那不一定是调用方想要的那条。 + * 明确提示下一步该查什么,模型才会继续往下走而不是就地拼一个。 + * + * @param {string[]} names + * @returns {string} + */ +export function renderNameSuggestions(names) { + const list = Array.isArray(names) ? names.filter(Boolean) : []; + if (list.length === 0) return '当前没有可投递的收件人。'; + return [ + `可投递的收件人(${list.length} 个):`, + list.map(n => `- ${n}`).join('\n'), + '', + '下一步:用 suggest_address 带上 name 查它可用的工作目录(path 位)。', + ].join('\n'); +} + +/** + * 渲染工作目录候选(`kind: "path"`)。 + * + * 空列表要说清「这不代表不能发」:path 位允许为空(人类用户没有工作目录), + * 不解释的话模型会卡在这一步,或者编一个路径出来。 + * + * @param {string[]} paths + * @param {string} name 正在查的收件人名,用于拼下一步的提示 + * @returns {string} + */ +export function renderPathSuggestions(paths, name) { + const list = Array.isArray(paths) ? paths.filter(Boolean) : []; + if (list.length === 0) { + return [ + `${name} 没有记录在案的工作目录。`, + '这不代表不能给它发信 —— path 位可以留空(人类用户就没有工作目录)。', + `直接用 suggest_address(name="${name}", path="") 查它的会话,或直接发给 ${name}。`, + ].join('\n'); + } + return [ + `${name} 用过的工作目录(按最近使用排序):`, + list.map(p => `- ${p}`).join('\n'), + '', + `下一步:用 suggest_address(name="${name}", path="<上面某一个>") 查该目录下可续谈的会话。`, + ].join('\n'); +} + +/** + * 渲染会话候选(`kind: "session"`)。 + * + * **`addresses` 与 `suggestions` 同序**,服务端保证。这里优先用 `addresses`: + * 那是服务端拼好的完整地址,插件不必自己拼(自己拼过一次,拼错了)。 + * + * `new` 永远在最后且带一句警告:它不是一条已存在的会话。排在前面会让模型 + * 在想续谈时顺手开出一条新线索 —— 生产上已经发生过。 + * + * @param {object} data `/agent/contacts/suggest` 的返回体 + * @param {string} name + * @param {string} path + * @returns {string} + */ +export function renderSessionSuggestions(data, name, path) { + const aliases = Array.isArray(data?.suggestions) ? data.suggestions : []; + const addresses = Array.isArray(data?.addresses) ? data.addresses : []; + const candidates = Array.isArray(data?.candidates) ? data.candidates : []; + + // 只有 new 一项 = 这个 name@path 下还没有任何可续谈的会话 + const existing = aliases.filter(a => a !== 'new'); + if (existing.length === 0) { + return [ + `${name}${path ? '@' + path : ''} 下还没有可续谈的会话。`, + `要开一条新线索用 ${addressAt(addresses, aliases, 'new') || `${name}@${path}.new`},`, + '并在 send_mail 里传 session_alias 给它命名,之后就能按名字续谈。', + ].join('\n'); + } + + const lines = [`${name}${path ? '@' + path : ''} 下可续谈的会话:`]; + for (let i = 0; i < aliases.length; i++) { + const alias = aliases[i]; + const addr = addresses[i] || ''; + const c = candidates[i] || {}; + if (alias === 'new') continue; // new 单独放最后 + const bits = []; + if (c.title) bits.push(c.title); + if (typeof c.unread === 'number' && c.unread > 0) bits.push(`${c.unread} 封未读`); + if (c.source === 'platform') bits.push('平台侧会话'); + lines.push(`- ${addr || alias}${bits.length ? ` (${bits.join(',')})` : ''}`); + } + lines.push(''); + lines.push('把上面某个地址原样填进 send_mail 的 to 即可投进那条会话。'); + const newAddr = addressAt(addresses, aliases, 'new'); + if (newAddr) { + lines.push(`若确实要开一条**新**线索(而不是接着上面某条谈)才用 ${newAddr}。`); + } + return lines.join('\n'); +} + +/** 按别名在同序的 addresses 里取地址。 */ +function addressAt(addresses, aliases, alias) { + const i = aliases.indexOf(alias); + return i >= 0 ? addresses[i] || '' : ''; +} + +/** + * 渲染会话参与方清单。 + * + * 这是「发送给抄收方 / 转发方」缺的最后一块:知道有谁、**用什么地址找到他**、 + * 以及谁还没开口。`mail_count` 为 0 的那个就是还没回应的人 —— 服务端只数 + * 「作为发件人」的邮件,正是为了让这个判断成立。 + * + * @param {object} data `/agent/sessions/{id}/participants` 的返回体 + * @returns {string} + */ +export function renderParticipants(data) { + const parts = Array.isArray(data?.participants) ? data.participants : []; + if (parts.length === 0) return '该会话还没有参与方(可能是一条刚建立的空会话)。'; + + const alias = data?.session_alias || ''; + const lines = [`会话 #${alias || '未命名'} 的参与方:`]; + for (const p of parts) { + const tags = []; + if (p.is_self) tags.push('就是你'); + if (Array.isArray(p.roles) && p.roles.length) { + tags.push(p.roles.map(roleLabel).join('/')); + } + if (p.mail_count === 0 && !p.is_self) tags.push('尚未回应'); + const addr = p.address ? p.address : '(无可投递地址:该会话尚未命名)'; + lines.push(`- ${p.name} ${addr}${tags.length ? ` [${tags.join(',')}]` : ''}`); + } + lines.push(''); + lines.push('要联系其中某一方,把它的地址原样填进 send_mail 的 to。'); + return lines.join('\n'); +} + +/** + * 渲染联系人清单(本 Agent 参与过的全部会话)。 + * + * 按未读优先、其次最近活跃排序:模型问「我还有什么没处理」时, + * 有未读的那些才是答案。 + * + * @param {object} data `/agent/contacts` 的返回体 + * @param {number} limit 最多列出多少条 + * @returns {string} + */ +export function renderContacts(data, limit = 20) { + const list = Array.isArray(data?.contacts) ? data.contacts.slice() : []; + if (list.length === 0) return '还没有任何往来会话。'; + + list.sort((a, b) => { + const ua = a?.unread_count || 0; + const ub = b?.unread_count || 0; + if (ua !== ub) return ub - ua; + return String(b?.last_activity || '').localeCompare(String(a?.last_activity || '')); + }); + + const shown = list.slice(0, limit); + const lines = [`往来会话(共 ${list.length} 条${list.length > shown.length ? `,列出前 ${shown.length}` : ''}):`]; + for (const c of shown) { + const bits = []; + if (c.unread_count > 0) bits.push(`${c.unread_count} 封未读`); + if (c.subject) bits.push(c.subject); + if (c.max_rounds > 0) { + const left = Math.max(0, c.max_rounds - (c.used_rounds || 0)); + bits.push(`剩 ${left}/${c.max_rounds} 个来回`); + } + const addr = c.address || '(未命名会话,只能用 reply_to 续谈)'; + lines.push(`- ${addr}${bits.length ? ` (${bits.join(',')})` : ''}`); + } + return lines.join('\n'); +} + +/** 角色的中文标签。模型读到「抄送方」比读到 cc 更容易判对分工。 */ +function roleLabel(role) { + switch (role) { + case 'from': return '发件人'; + case 'to': return '收件人'; + case 'cc': return '抄送方'; + default: return String(role); + } +} + +/** + * 渲染对话树,回答「谁已经回了、谁还没回」。 + * + * 缩进表示层级。**detached 必须标出来**:那表示父邮件不在本次结果里 + * (无权查看或尚未加载),不标的话模型会以为这是一条独立线索。 + * + * @param {object} data `/agent/mail/{id}/thread` 的返回体 + * @param {string} [selfName] 自己的名字,用于标出哪几封是自己发的 + * @returns {string} + */ +export function renderThread(data, selfName = '') { + const nodes = Array.isArray(data?.nodes) ? data.nodes : []; + if (nodes.length === 0) return '这条线索上没有可见的邮件。'; + + const lines = [`线索共 ${data?.total ?? nodes.length} 封${data?.hidden ? `(另有 ${data.hidden} 封无权查看)` : ''}:`]; + for (const n of nodes) { + const depth = typeof n?.depth === 'number' ? Math.max(0, n.depth) : 0; + const indent = ' '.repeat(Math.min(depth, 8)); + const marks = []; + if (selfName && n?.from_name === selfName) marks.push('你发的'); + if (n?.mail_id === data?.anchor_mail_id) marks.push('当前这封'); + if (n?.detached) marks.push(n.parent_hidden ? '父邮件无权查看' : '父邮件尚未加载'); + lines.push( + `${indent}- ${n?.from_name ?? '?'} → ${n?.to_name ?? '?'}: ${n?.subject ?? '(无主题)'}` + + ` [${n?.mail_id ?? '?'}]${marks.length ? ` (${marks.join(',')})` : ''}` + ); + } + if (data?.has_more) { + lines.push(''); + lines.push(`还有更多,用 offset=${data.next_offset} 继续取。`); + } + return lines.join('\n'); +} diff --git a/plugins/dsh-mail-bridge/lib/inbox-format.d.ts b/plugins/dsh-mail-bridge/lib/inbox-format.d.ts index 2fea3ad..cfebe1c 100644 --- a/plugins/dsh-mail-bridge/lib/inbox-format.d.ts +++ b/plugins/dsh-mail-bridge/lib/inbox-format.d.ts @@ -2,6 +2,6 @@ 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 renderMail(mail: any, bodyLimit?: number, selfName?: string): string; +export function renderInbox(mails: readonly any[], bodyLimit?: number, selfName?: string): 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 index 974bd54..8f2f54b 100644 --- a/plugins/dsh-mail-bridge/lib/inbox-format.js +++ b/plugins/dsh-mail-bridge/lib/inbox-format.js @@ -6,6 +6,8 @@ * 渲染出的文本与标记已读的时机都该一致。新接一个平台时直接复用这里。 */ +import { roleOf, replyAddressFor, participantsOfMail } from './addressing.js'; + /** 人类可读的字节数,用于附件清单展示。 */ export function formatSize(n) { if (typeof n !== 'number' || !Number.isFinite(n)) return '?'; @@ -19,19 +21,41 @@ export function formatSize(n) { * * @param {any} m `/mail/inbox` 返回的一封邮件 * @param {number} bodyLimit 正文截断长度 + * @param {string} [selfName] 自己的 Agent 名。给了就能判定「我是收件人还是抄送方」 + * 并给出参与方地址;不给则退化成旧行为(兼容未传该参数的调用方)。 * @returns {string} */ -export function renderMail(m, bodyLimit = 200) { +export function renderMail(m, bodyLimit = 200, selfName = '') { + const alias = m?.session_alias || ''; const lines = [ `[${m?.status ?? 'unknown'}] ${m?.from_name ?? 'unknown'}: ${m?.subject ?? '(无主题)'}`, `邮件 ID: ${m?.mail_id ?? 'unknown'}`, - `会话: #${m?.session_alias || '未命名'}`, + `会话: #${alias || '未命名'}`, ]; + + // 收件人必须显示。不显示的后果:被抄送方既不知道主收件人是谁, + // 也无法向对方转达或汇报 —— 线上那封联调邮件要求「由收件人汇报」, + // 抄送方却看不到收件人叫什么。 + if (m?.to_name) { + let toLine = `收件人: ${m.to_name}`; + if (m?.to_workspace) toLine += `@${m.to_workspace}`; + lines.push(toLine); + } + // 抄送要显示:一封邮件为什么同时到了几个人手上,只有抄送能解释。 // 不显示的话模型会以为这是私下发给它一个人的,回信时漏掉其他参与方。 if (Array.isArray(m?.cc_list) && m.cc_list.length > 0) { lines.push('抄送: ' + m.cc_list.map(c => c?.raw || c?.name || '?').join('、')); } + + // 自己的身份。抄送方与主收件人的职责不同,不区分的话两方都会 + // 以为自己是负责人,或者都以为自己只是旁观者。 + if (selfName) { + const role = roleOf(m, selfName); + if (role === 'to') lines.push('你的身份: 收件人(主办)'); + else if (role === 'cc') lines.push('你的身份: 抄送方(配合)'); + } + // **必须给出 attachment_id**:只说「有附件」模型就无从下载。 if (Array.isArray(m?.attachments) && m.attachments.length > 0) { lines.push( @@ -42,22 +66,52 @@ export function renderMail(m, bodyLimit = 200) { ); lines.push('下载附件请用 download_attachment 工具。'); } + // 列表接口只给 body_preview(省带宽),单封接口才有 body。两者都兜住。 const body = m?.body_preview || m?.body || ''; lines.push(`内容: ${String(body).slice(0, bodyLimit)}`); + + // 可投递地址放在最后,紧贴正文 —— 模型读完内容紧接着就要决定发给谁。 + // + // 这一段是「精准发信」的关键:之前模型只能从抄送行里拄一个 + // `opencode@/home.new` 拄过去,而 `.new` 是一次性的,回过去只会再建一条 + // 平行会话。这里给的地址全部已经把 session 位换成真实别名。 + if (selfName && alias) { + const parts = participantsOfMail(m, selfName, alias); + const others = parts.filter(p => !p.is_self && p.address); + if (others.length > 0) { + lines.push( + '可投递地址: ' + + others.map(p => `${p.address}(${roleLabel(p.role)})`).join('、') + ); + lines.push(`直接回信给发件人用 ${replyAddressFor(m, alias)},或传 reply_to=${m?.mail_id ?? ''}。`); + } + } + return lines.join('\n'); } +/** 角色的中文标签。模型读到「抄送方」比读到 cc 更容易判对分工。 */ +function roleLabel(role) { + switch (role) { + case 'from': return '发件人'; + case 'to': return '收件人'; + case 'cc': return '抄送方'; + default: return role; + } +} + /** * 渲染整个收件箱。 * @param {any[]} mails * @param {number} bodyLimit + * @param {string} [selfName] 自己的 Agent 名,透传给 renderMail * @returns {string} */ -export function renderInbox(mails, bodyLimit = 200) { +export function renderInbox(mails, bodyLimit = 200, selfName = '') { const list = Array.isArray(mails) ? mails : []; if (list.length === 0) return '收件箱为空。'; - return list.map(m => renderMail(m, bodyLimit)).join('\n\n'); + return list.map(m => renderMail(m, bodyLimit, selfName)).join('\n\n'); } /** diff --git a/plugins/dsh-mail-bridge/lib/model-scope.d.ts b/plugins/dsh-mail-bridge/lib/model-scope.d.ts index af808f5..00a32bf 100644 --- a/plugins/dsh-mail-bridge/lib/model-scope.d.ts +++ b/plugins/dsh-mail-bridge/lib/model-scope.d.ts @@ -13,6 +13,7 @@ export declare const MAX_CATALOG: number; export function snapshotOpencodeModels(config: any): CatalogEntry[]; export function snapshotDshModels(entries: readonly any[]): CatalogEntry[]; +export function snapshotPiModels(models: readonly any[]): CatalogEntry[]; export function modelAttemptOrder( allowed: readonly ModelRoute[] | undefined, diff --git a/plugins/dsh-mail-bridge/lib/model-scope.js b/plugins/dsh-mail-bridge/lib/model-scope.js index 5ee21e0..74fbe11 100644 --- a/plugins/dsh-mail-bridge/lib/model-scope.js +++ b/plugins/dsh-mail-bridge/lib/model-scope.js @@ -65,6 +65,37 @@ export function snapshotDshModels(entries) { return dedupeAndCap(out); } +/** + * 把 pi 的模型列表整理成上报格式。 + * + * pi 侧的取法是 `await modelRuntime.getAvailable()` —— **不是** `getModels()`。 + * 两者差别很大:本机实测目录里有 1221 个模型,而带凭证、真能调起来的只有 1 个。 + * 上报 `getModels()` 的结果会让管理员在配置页选中一个注定失败的路由, + * 而失败要到真发邮件时才暴露(模型目录上报的全部意义就是避免这件事)。 + * + * pi 的 Model 对象上,provider 在 `provider` 字段、模型 id 在 `id` 字段, + * 展示名在 `name`。形状与 DSH 侧一致,但语义来源不同,因此单独一个函数 + * ——照抄 snapshotDshModels 会让「必须用 getAvailable」这条约束无处记录。 + * + * @param {any[]} models `await modelRuntime.getAvailable()` 的结果 + * @returns {object[]} + */ +export function snapshotPiModels(models) { + const list = Array.isArray(models) ? models : []; + const out = []; + for (const m of list) { + const provider = typeof m?.provider === 'string' ? m.provider : ''; + const model = typeof m?.id === 'string' ? m.id : ''; + if (!provider || !model) continue; + out.push({ + provider, + model, + display_name: typeof m?.name === 'string' ? m.name : '', + }); + } + return dedupeAndCap(out); +} + /** * 决定这一轮按什么顺序尝试模型。 * diff --git a/plugins/dsh-mail-bridge/lib/session-snapshot.d.ts b/plugins/dsh-mail-bridge/lib/session-snapshot.d.ts index d478850..3a43e0b 100644 --- a/plugins/dsh-mail-bridge/lib/session-snapshot.d.ts +++ b/plugins/dsh-mail-bridge/lib/session-snapshot.d.ts @@ -19,4 +19,11 @@ export function snapshotDshSessions( isMailDriven?: (id: string) => boolean ): PlatformSessionReport[]; +export function snapshotPiSessions( + entries: readonly any[], + isMailDriven?: (id: string) => boolean +): PlatformSessionReport[]; + +export function isUnusableName(name: string): boolean; + export function slugFromTitle(title: string): string; diff --git a/plugins/dsh-mail-bridge/lib/session-snapshot.js b/plugins/dsh-mail-bridge/lib/session-snapshot.js index e4cbfee..34e38a7 100644 --- a/plugins/dsh-mail-bridge/lib/session-snapshot.js +++ b/plugins/dsh-mail-bridge/lib/session-snapshot.js @@ -83,6 +83,83 @@ export function snapshotDshSessions(entries, isMailDriven = () => false) { return dedupeBySlug(sortAndCap(out)); } +/** + * 把 pi 的 `SessionManager.list()/listAll()` 结果整理成上报格式。 + * + * pi 的会话名字来自会话文件里最后一条 `session_info` 条目: + * - pi-web 在一条会话的首次 prompt 时用模型生成一个 2-6 词的标题 + * - TUI 的 `/name`、启动参数 `--name`、`/resume` 里的改名也写同一处 + * - **pi 内核(SDK)自己不生成**:桥用 createAgentSession 起的会话没有名字, + * 要由桥按「Gateway 定稿的别名」回写(见 index 的 syncNaming) + * + * 与另两个平台的差异:pi 的 SessionInfo 里**没有 subagent 标记**。 + * pi-subagents 把子会话写在自定义 sessionDir(run 根目录)下,默认会话目录 + * 列不到它们,因此这里不需要 S-2 那样的显式过滤。 + * + * @param {any[]} entries SessionInfo 列表 `[{ id, cwd, name, modified }]` + * @param {(id: string) => boolean} isMailDriven + * @returns {object[]} + */ +export function snapshotPiSessions(entries, isMailDriven = () => false) { + const list = Array.isArray(entries) ? entries : []; + const out = []; + for (const e of list) { + const id = typeof e?.id === 'string' ? e.id : ''; + if (!id) continue; + const name = typeof e?.name === 'string' ? e.name : ''; + // 没有名字的会话不报(S-1):pi 的列表在无名时显示首条消息, + // 而首条消息对邮件驱动的会话就是桥自己拼的提示词 —— 拿它当别名毫无区分度。 + if (!name) continue; + // 模型把思维链当标题写进来的那些不报(见 isUnusableName) + if (isUnusableName(name)) continue; + const slug = slugFromTitle(name); + if (!slug) continue; + out.push({ + platform_id: id, + // 老会话的 cwd 是空串(pi 的 SessionInfo 注释里写明了),照实上报, + // 服务端按空 workspace 处理,不要拿桥自己的 cwd 冒充。 + workspace: typeof e?.cwd === 'string' ? e.cwd : '', + slug, + title: name, + mail_driven: Boolean(isMailDriven(id)), + updated_at: toISO(e?.modified ?? e?.created), + }); + } + return dedupeBySlug(sortAndCap(out)); +} + +/** + * 判断一个平台侧名字是否不适合当别名。 + * + * 这条判废是 pi 特有的:pi-web 的标题生成器(`sessionNameGenerator`)只做了 + * 「取首行 + 去引号 + 截 60 字符」,没有防思维链泄漏。本机 81 条会话里实测捞到: + * + * "The user is asking me to generate a title for a coding-agent" + * "我们只需要生成标题,不包含其他内容。标题应反映请求内容:测试opencode的源。简短:…" + * + * 这类字符串派生出的别名又长又没有指代作用,填进三维地址里更是灾难。 + * 判废后调用方回退到「不上报」或「用邮件主题派生」,都比它强。 + * + * 宁可漏判也不误判:错杀一个好名字会让那条会话失去可寻址的别名, + * 而漏掉一个坏名字只是别名难看。 + * + * @param {string} name + * @returns {boolean} + */ +export function isUnusableName(name) { + const s = String(name ?? '').trim(); + if (!s) return true; + // 自指标题生成任务 = 模型把系统提示词复述了出来 + if (/生成标题|标题应|拟一个标题|generate a (short |concise )?title|session title|as a title/i.test(s)) { + return true; + } + // 以第三人称叙述用户意图开头 = 思维链的典型开场 + if (/^(the user\b|用户(想|要|在|希望)|我们只需要|我需要先|首先(,|,))/i.test(s)) return true; + // 又长又分句 = 一段话而不是一个标题(pi-web 截断上限是 60) + if (s.length >= 48 && /[。;;]|\.\s/.test(s)) return true; + return false; +} + /** 判断一条会话是否为 subagent 子会话。两个字段任一成立即算。 */ function isSubagent(e) { if (e?.origin === 'subagent') return true; @@ -131,11 +208,17 @@ export function slugFromTitle(title) { return slug; } -/** 毫秒时间戳或 ISO 串 → ISO 串;无法解析时返回 undefined。 */ +/** 毫秒时间戳、ISO 串或 Date → ISO 串;无法解析时返回 undefined。 */ function toISO(v) { if (typeof v === 'number' && Number.isFinite(v)) { return new Date(v).toISOString(); } + // pi 的 SessionInfo 给的是 Date 实例(created/modified),不是时间戳。 + // 少了这一支会让整份快照的 updated_at 全是 undefined,于是服务端只能按 + // 上报时间排序 —— 补全列表里「最近在谈的那条」不再排在前面。 + if (v instanceof Date) { + return Number.isNaN(v.getTime()) ? undefined : v.toISOString(); + } if (typeof v === 'string' && v) { const d = new Date(v); if (!Number.isNaN(d.getTime())) return d.toISOString(); diff --git a/plugins/dsh-mail-bridge/src/index.ts b/plugins/dsh-mail-bridge/src/index.ts index 593bf96..13ada26 100644 --- a/plugins/dsh-mail-bridge/src/index.ts +++ b/plugins/dsh-mail-bridge/src/index.ts @@ -42,6 +42,14 @@ import { DEFAULT_INBOX_STATUS, DEFAULT_INBOX_LIMIT, } from '../lib/inbox-format.js'; +import { + renderNameSuggestions, + renderPathSuggestions, + renderSessionSuggestions, + renderParticipants, + renderContacts, + renderThread, +} from '../lib/discovery.js'; // ─── 凭证管理 ─── @@ -294,11 +302,11 @@ export function apply(ctx: any, config: PluginConfig): void { try { await deliverMail(ev, 'mail'); } catch (e: any) { - ctx.logger.error(`[dsh-mail-bridge] 补投 ${ev.mail_id} 失败: ${e?.message || e}`); + console.error(`[dsh-mail-bridge] 补投 ${ev.mail_id} 失败: ${e?.message || e}`); } } } catch (e: any) { - ctx.logger.error(`[dsh-mail-bridge] 补投失败: ${e?.message || e}`); + console.error(`[dsh-mail-bridge] 补投失败: ${e?.message || e}`); } } @@ -440,6 +448,82 @@ export function apply(ctx: any, config: PluginConfig): void { }); } + // ─── 建会话(磁盘上已有则 resume)─── + + /** + * 问持久化层:磁盘上是否已经有这条会话? + * + * `sessionMap` 是纯内存的,插件重启后为空,于是同一封邮件的续谈会走 + * 「新开会话」那条路,用回同一个 `mail-` —— 而那个 id 上一次 + * 已经落过盘。只能问持久化层,因为这是重启后唯一还存在的事实来源。 + * + * 读不到就当作不存在:`readSession` 在会话不存在、日志不可读、replay 校验 + * 不过时都会抛。三种情形里只有第一种适合 create,但后两种 resume 也一样 + * 救不回来 —— 那就让 create 去报它自己的错。 + */ + async function persistedCwd(sessionId: string): Promise { + const q: any = (ctx as any).get?.('sessionQuery'); + if (!q?.readSession) return undefined; + try { + const snap = await q.readSession(sessionId); + return snap?.header?.cwd ?? ''; + } catch { + return undefined; + } + } + + /** + * 启一个 agent:磁盘上没有这个 id 就 create,有就 resume。 + * + * # 为何必须先探测,不能靠 try/catch + * + * id 冲突不是 `create` 报的:持久化是在**轮次进行中** flush 的,所以 + * `create` 会正常返回,错误到 `turn/end` 才以 `reason.kind === 'error'` + * 冲出来(实测:`UNKNOWN: session "..." already has a persisted log on disk`)。 + * 把修法写成 catch 里改 resume 完全不会生效 —— 这与「模型失败不是同步抛出的」 + * 是同一类陷阱,只是上了一层。 + * + * # 为何 resume 而不是换一个新 id + * + * 换 id 等于把之前的往来上下文丢掉,模型会重新问一遍已经问过的问题。 + * resume 把磁盘上那条会话装回来接着谈,这同时修掉了一个已知取舍: + * 插件重启后续谈的邮件不再另开一条平台会话。 + * + * resume 不接受 `meta`:cwd 取自持久化的 header。这正是想要的 —— 上一次在哪个 + * 目录,就继续在那儿;传一个不同的 cwd 只会得到 + * `is already persisted at a different cwd` 而不是“改目录”。 + */ + async function startAgent( + sessionId: string, cwd: string, route: any, + ): Promise<{ handle: any; resumed: boolean }> { + // route 为 undefined 表示不指定模型,交给平台自己选 + const agentOptions = route ? { provider: route.provider, model: route.model } : {}; + + const onDisk = await persistedCwd(sessionId); + if (onDisk !== undefined) { + // 用 console.error 而不是 ctx.logger.info:后者不进 journalctl(实测), + // 而这条是排查「邮件投不进去」时唯一能看到的线索。 + console.error(`[dsh-mail-bridge] 会话 ${sessionId} 已在磁盘上(cwd=${onDisk || '未记录'}),改为 resume 续谈`); + const handle = await ctx.agents.resume({ + resumeSessionId: sessionId as any, + agentOptions, + setup: undefined, + }); + return { handle, resumed: true }; + } + + const handle = await ctx.agents.create({ + sessionId, + meta: { cwd }, + agentOptions, + // setup 留空:DSH 的 base bundle 已经注册了 agent-loop、llm、tools 等服务。 + // 模型路由通过 agentOptions 传入即可 —— 挂载 preset 或 + // installModelSelection 反而会让 turn 崩溃(实测)。 + setup: undefined, + }); + return { handle, resumed: false }; + } + // ─── 投递邮件到 DSH 会话 ─── async function deliverMail(data: any, kind: string): Promise<{ sessionID: string; reused: boolean }> { @@ -517,20 +601,13 @@ export function apply(ctx: any, config: PluginConfig): void { let handle: any; try { - handle = await ctx.agents.create({ - sessionId: attemptSessionId, - meta: { cwd }, - // route 为 undefined 表示不指定模型,交给平台自己选 - agentOptions: route ? { provider: route.provider, model: route.model } : {}, - // setup 留空:DSH 的 base bundle 已经注册了 agent-loop、llm、tools 等服务。 - // 模型路由通过 agentOptions 传入即可 —— 挂载 preset 或 - // installModelSelection 反而会让 turn 崩溃(实测)。 - setup: undefined, - }); + const started = await startAgent(attemptSessionId, cwd, route); + handle = started.handle; } catch (e: any) { - // create 本身很少失败(它不校验模型),但会话 id 冲突之类仍会抛 + // create/resume 本身很少失败(create 不校验模型), + // 但 cwd 不符、日志 replay 不过之类仍会抛 failures.push({ ...(route ?? {}), error: e?.message || String(e) }); - ctx.logger.error(`[dsh-mail-bridge] 建会话失败 ${label}: ${e?.message || e}`); + console.error(`[dsh-mail-bridge] 建会话失败 ${label}: ${e?.message || e}`); continue; } @@ -553,13 +630,13 @@ export function apply(ctx: any, config: PluginConfig): void { if (outcome.ok) { if (failures.length > 0) { - ctx.logger.info(`[dsh-mail-bridge] ${label} 成功(前 ${failures.length} 个失败)`); + console.error(`[dsh-mail-bridge] ${label} 成功(前 ${failures.length} 个失败)`); } return { sessionID: attemptSessionId, reused: false }; } failures.push({ ...(route ?? {}), error: outcome.error }); - ctx.logger.error(`[dsh-mail-bridge] 模型 ${label} 失败: ${outcome.error}`); + console.error(`[dsh-mail-bridge] 模型 ${label} 失败: ${outcome.error}`); // 拆掉这一路的 agent 与映射,否则它会占着会话 id, // 而 agent/status 还会为这个死会话触发一次自动转发 reverseMap.delete(attemptSessionId); @@ -693,7 +770,9 @@ export function apply(ctx: any, config: PluginConfig): void { // 渲染与已读策略放 lib/inbox-format.js:它们与平台 SDK 无关, // 各平台插件必须一致(见该文件里每条规则对应的错误行为)。 - const listed = renderInbox(mails); + // + // 传 AGENT_NAME 才能判定「我是收件人还是抄送方」并给出可投递地址。 + const listed = renderInbox(mails, 200, AGENT_NAME); // 读过就标掉,否则每次拉收件箱都重复捞同一批, // 处理过的和新来的混在一起,模型分不清哪封该回。 @@ -722,10 +801,21 @@ export function apply(ctx: any, config: PluginConfig): void { async execute(args: any): Promise { const data = await readFile(args.file_path); const filename = args.file_path.split('/').pop() || 'file'; + // **必须发真正的 multipart。** + // + // 早先这里发的是 `Content-Type: application/octet-stream` 加一个 + // `X-Filename` 头,而服务端走 `ParseMultipartForm` + `FormFile("file")` —— + // 于是 **这个工具从来没成功过一次**,每次都回「解析 multipart 失败」。 + // 模型甚至把它当成了文件存在性探针(存在→报 multipart 错、 + // 不存在→ENOENT),那是对症状的准确利用,但不是它应该做的事。 + // + // 不设 Content-Type:交给 FormData 自己带 boundary,手写的一定对不上。 + const form = new FormData(); + form.append('file', new Blob([data]), filename); const res = await fetch(`${client.baseURL}/api/v1/attachments`, { method: 'POST', - headers: { ...client.authHeaders(), 'Content-Type': 'application/octet-stream', 'X-Filename': filename }, - body: data, + headers: client.authHeaders(), + body: form, }); const json = await res.json() as any; if (!res.ok) throw new Error(json?.error || `HTTP ${res.status}`); @@ -757,8 +847,185 @@ export function apply(ctx: any, config: PluginConfig): void { }, })); + // ─── 寻址发现工具(读 Agent 侧只读端点)─── + // + // 在这一组之前,send_mail 的 to 是个只能靠记忆拼写的自由文本字段, + // 而拼错不报错:生产上本插件猜了 `opencode@/home`,投递成功, + // 但那不是 opencode 的工作目录,静默变成了新会话的 workspace。 + + ctx.tools.register(defineTool({ + name: 'suggest_address', + description: + '查询可用的收件人地址,用于精准发信。不带参数给候选收件人名;带 name 给它可用的工作目录;' + + 'name+path 都带则给该目录下可续谈的会话与现成地址。**发信前应先用它确认地址**,' + + '不要凭记忆拼写 —— 拼错不会报错,只会投到别的会话。', + parameters: { + name: { type: 'string', description: '收件人名;留空则列出所有候选收件人' }, + path: { type: 'string', description: '工作目录;与 name 同时给出才列会话' }, + }, + output: { + schema: { type: 'string' }, + render: (_args: any, value: string) => [{ type: 'text', text: value }], + }, + async execute(args: any): Promise { + const name = String(args.name || '').trim(); + const path = String(args.path || '').trim(); + const qs = new URLSearchParams(); + if (name) qs.set('name', name); + if (path) qs.set('path', path); + const data = await client.get(`/agent/contacts/suggest?${qs.toString()}`); + // 按服务端回的 kind 分派而不是按本地参数:省略与传空串在服务端 + // 是同一个意思,但「哪一段该渲染成什么」只有服务端知道。 + switch (data?.kind) { + case 'name': return renderNameSuggestions(data.suggestions); + case 'path': return renderPathSuggestions(data.suggestions, name); + default: return renderSessionSuggestions(data, name, path); + } + }, + })); + + ctx.tools.register(defineTool({ + name: 'list_contacts', + description: + '列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。' + + '用于回答「我还有什么没处理」与「上次跟某人聊的那条线索地址是什么」。', + parameters: { + limit: { type: 'number', description: '最多列出多少条,默认 20' }, + }, + output: { + schema: { type: 'string' }, + render: (_args: any, value: string) => [{ type: 'text', text: value }], + }, + async execute(args: any): Promise { + const data = await client.get('/agent/contacts'); + return renderContacts(data, args.limit || 20); + }, + })); + + ctx.tools.register(defineTool({ + name: 'session_participants', + description: + '列出某条会话的全部参与方(发件人/收件人/抄送方)及各自的可投递地址,' + + '并标出谁还没回应。**要回给抄收方或向第三方转达时先用它拿地址**。', + parameters: { + session_id: { type: 'string', required: true, description: '会话 ID' }, + }, + output: { + schema: { type: 'string' }, + render: (_args: any, value: string) => [{ type: 'text', text: value }], + }, + async execute(args: any): Promise { + const data = await client.get(`/agent/sessions/${args.session_id}/participants`); + return renderParticipants(data); + }, + })); + + ctx.tools.register(defineTool({ + name: 'read_thread', + description: + '查看一封邮件所在线索的完整往来(谁回了谁、谁还没回)。多方抄送协作时' + + '用它确认别人已经说了什么,避免重复提问或重复汇报。', + parameters: { + mail_id: { type: 'string', required: true, description: '线索中任一封邮件的 ID' }, + offset: { type: 'number', description: '分页偏移,续取时传上次返回的 next_offset' }, + }, + output: { + schema: { type: 'string' }, + render: (_args: any, value: string) => [{ type: 'text', text: value }], + }, + async execute(args: any): Promise { + const qs = args.offset ? `?offset=${args.offset}` : ''; + const data = await client.get(`/agent/mail/${args.mail_id}/thread${qs}`); + return renderThread(data, AGENT_NAME); + }, + })); + + ctx.tools.register(defineTool({ + name: 'read_mail', + description: + '读一封邮件的完整内容,含收件人、抄送清单、附件与每个参与方的可投递地址。' + + '收件箱只给摘要;要回给抄收方就得先看清这封信发给了谁。', + parameters: { + mail_id: { type: 'string', required: true, description: '邮件 ID' }, + }, + output: { + schema: { type: 'string' }, + render: (_args: any, value: string) => [{ type: 'text', text: value }], + }, + async execute(args: any): Promise { + const data = await client.get(`/agent/mail/${args.mail_id}`); + const m = data?.mail || {}; + const lines = [ + `发件人: ${m.from_name || '?'}`, + `收件人: ${m.to_name || '?'}${m.to_workspace ? '@' + m.to_workspace : ''}`, + `主题: ${m.subject || '(无主题)'}`, + `会话: #${data.session_alias || '未命名'}(session_id: ${m.session_id || '?'})`, + ]; + if (Array.isArray(m.cc_list) && m.cc_list.length) { + lines.push(`抄送: ${m.cc_list.map((c: any) => c?.raw || c?.name).join('、')}`); + } + if (Array.isArray(m.attachments) && m.attachments.length) { + lines.push(`附件: ${m.attachments + .map((a: any) => `${a.filename}(${formatSize(a.size_bytes)}, id=${a.attachment_id})`) + .join('、')}`); + } + lines.push('', m.body || '(空正文)', ''); + if (Array.isArray(data.participants) && data.participants.length) { + lines.push('可投递地址: ' + data.participants + .filter((p: any) => p.address && p.name !== AGENT_NAME) + .map((p: any) => `${p.address}(${p.role})`) + .join('、')); + } + if (data.reply_address) { + lines.push(`回信给发件人用 ${data.reply_address},或传 reply_to=${m.mail_id}。`); + } + return lines.join('\n'); + }, + })); + + // forward_mail —— 转发给新收件人。 + // + // 之前 DSH 侧缺这个工具(opencode 侧一直有),于是本平台上「把这封信 + // 转给某人」只能退化成 send_mail 重抄一遍正文 —— 丢掉附件、丢掉 + // parent_mail_id,对话树上也看不出这条新线索从何而来。 + ctx.tools.register(defineTool({ + name: 'forward_mail', + description: + '转发一封邮件给新的收件人(自动引用原文与附件)。与回复不同:回复落回原会话,' + + '转发按目标地址另行定位会话(它是一条新线索)。只能转发自己参与过的邮件。', + parameters: { + mail_id: { type: 'string', required: true, description: '要转发的邮件 ID' }, + to: { type: 'string', required: true, description: '新收件人的三维地址(先用 suggest_address 确认)' }, + comment: { type: 'string', description: '转发说明,置于引用原文之前' }, + cc: { type: 'string', description: '抄送,逗号分隔多个三维地址' }, + subject: { type: 'string', description: '自定义主题;留空则自动加 Fwd: 前缀' }, + session_alias: { type: 'string', description: '仅当目标地址以 .new 结尾时生效:给新会话命名' }, + }, + output: { + schema: { type: 'string' }, + render: (_args: any, value: string) => [{ type: 'text', text: value }], + }, + async execute(args: any, toolCtx: any): Promise { + const result = await client.post(`/mail/${args.mail_id}/forward`, { + to: args.to, + comment: args.comment || '', + cc: args.cc || '', + subject: args.subject || '', + session_alias: args.session_alias || '', + }); + // 转发也是一次「模型亲手发信」,要计入 explicitSends, + // 否则本轮结束时自动转发会再把同一段话发一遍。 + noteExplicitSend(toolCtx?.sessionID, args.to, ''); + return `已转发。新 Mail ID: ${result.mail_id},Session: ${result.session_id}`; + }, + })); + return () => { - for (const n of ['send_mail', 'read_inbox', 'upload_attachment', 'download_attachment']) { + for (const n of [ + 'send_mail', 'read_inbox', 'read_mail', 'forward_mail', + 'upload_attachment', 'download_attachment', + 'suggest_address', 'list_contacts', 'session_participants', 'read_thread', + ]) { try { ctx.tools.unregister(n); } catch {} } }; diff --git a/plugins/dsh-mail-bridge/test/addressing.test.mjs b/plugins/dsh-mail-bridge/test/addressing.test.mjs new file mode 100644 index 0000000..279f80d --- /dev/null +++ b/plugins/dsh-mail-bridge/test/addressing.test.mjs @@ -0,0 +1,145 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + formatAddress, + roleOf, + replyAddressFor, + selfAddressFor, + participantsOfMail, +} from '../lib/addressing.js'; + +// 地址拼错不会报错,只会投到别处 —— 所以这一组测试全部落在 +// 「拼出来的东西还能不能被正确解析回三段」上。 + +test('formatAddress: 空 path 仍保留 @ 与 .', () => { + // 生产事故:朴素拼接得到 admin.silent-harbor,没有 @, + // 整串被 ParseAddress 当成名字,session 位静默丢失。 + assert.equal(formatAddress('admin', '', 'silent-harbor'), 'admin@.silent-harbor'); +}); + +test('formatAddress: 省略 session 位', () => { + assert.equal(formatAddress('dsh', '/home/program/agentmail', ''), 'dsh@/home/program/agentmail'); + // 名字与 path 都有但都不带会话 → 默认会话语义 + assert.equal(formatAddress('dsh', '', ''), 'dsh'); +}); + +test('formatAddress: path 含 . 与 / 时仍按最后一个 . 切', () => { + // path 里允许 . 与 /,切分靠最后一个 . —— 拼出来的必须满足这个约定 + const addr = formatAddress('bot', '/srv/app.v2', 'fix-leak'); + assert.equal(addr, 'bot@/srv/app.v2.fix-leak'); + assert.equal(addr.slice(addr.lastIndexOf('.') + 1), 'fix-leak'); +}); + +test('formatAddress: 名字为空返回空串而不是残缺地址', () => { + // 返回 "@/path.alias" 会被投递端当成缺名字报错, + // 但那是在很后面才发现;这里直接给空串让调用方立刻看出没法拼。 + assert.equal(formatAddress('', '/p', 'a'), ''); + assert.equal(formatAddress(null, '/p', 'a'), ''); +}); + +test('formatAddress: 去掉首尾空白', () => { + assert.equal(formatAddress(' dsh ', ' /home ', ' alias '), 'dsh@/home.alias'); +}); + +const ccMail = { + from_name: 'admin', + to_name: 'dsh', + to_workspace: '/home/program/llmsproxy', + cc_list: [{ name: 'opencode', path: '/home', session: 'new', raw: 'opencode@/home.new' }], + session_alias: 'silent-harbor', +}; + +test('roleOf: 区分主收件人与抄送方', () => { + // 被抄送方与主收件人职责不同:线上那封联调邮件里 dsh 负责汇报、 + // opencode 只提供信息。不区分身份两方都会以为自己是负责人。 + assert.equal(roleOf(ccMail, 'dsh'), 'to'); + assert.equal(roleOf(ccMail, 'opencode'), 'cc'); + assert.equal(roleOf(ccMail, 'someone-else'), 'unknown'); +}); + +test('roleOf: 名字为空时不猜', () => { + assert.equal(roleOf(ccMail, ''), 'unknown'); + assert.equal(roleOf(ccMail, undefined), 'unknown'); +}); + +test('replyAddressFor: 用会话别名而非原地址的 .new', () => { + // 关键回归:把 .new 原样当回信地址会再建一条平行会话。 + const addr = replyAddressFor(ccMail); + assert.equal(addr, 'admin@.silent-harbor'); + assert.ok(!addr.endsWith('.new'), '回信地址不得以 .new 结尾'); +}); + +test('replyAddressFor: 发件人一侧不带 path', () => { + // Agent 回信时 from_workspace 存的是 Agent 名而不是路径, + // 拿它拼会得到 dsh@dsh.alias —— 投不出去。 + const mail = { from_name: 'dsh', from_workspace: 'dsh', session_alias: 'x' }; + assert.equal(replyAddressFor(mail), 'dsh@.x'); +}); + +test('replyAddressFor: 无别名时退回默认会话形式', () => { + const mail = { from_name: 'admin', session_alias: '' }; + const addr = replyAddressFor(mail); + assert.equal(addr, 'admin'); + // 调用方靠有没有 . 判断这是不是「投回同一条会话」 + assert.ok(!addr.includes('.'), '默认会话形式不含 session 位'); +}); + +test('selfAddressFor: 抄送方取自己那个地址的 path', () => { + // to_workspace 是主收件人的工作目录。抄送方拿它当自己的 path, + // 「我是谁」这句话就指向了别人的目录。 + assert.equal(selfAddressFor(ccMail, 'opencode'), 'opencode@/home.silent-harbor'); + assert.equal(selfAddressFor(ccMail, 'dsh'), 'dsh@/home/program/llmsproxy.silent-harbor'); +}); + +test('participantsOfMail: 抄送方的 path 是自己那个', () => { + const parts = participantsOfMail(ccMail, 'dsh'); + const byName = Object.fromEntries(parts.map(p => [p.name, p])); + + assert.equal(byName.opencode.path, '/home'); + assert.equal(byName.opencode.address, 'opencode@/home.silent-harbor'); + assert.equal(byName.dsh.path, '/home/program/llmsproxy'); + // 发件人 path 留空,理由同 replyAddressFor + assert.equal(byName.admin.address, 'admin@.silent-harbor'); +}); + +test('participantsOfMail: 地址一律用会话别名,不带 .new', () => { + // cc_list 里原本记的是 opencode@/home.new。参与方地址必须换成别名, + // 否则「回给抄收方」这个动作每次都会新开会话。 + for (const p of participantsOfMail(ccMail, 'dsh')) { + assert.ok(!p.address.endsWith('.new'), `${p.name} 的地址仍是 .new: ${p.address}`); + } +}); + +test('participantsOfMail: 自己被标记而不是被剔除', () => { + // 剔掉的话模型无法确认这封信是不是也发给了自己, + // 也就无法判断自己该不该回。 + const parts = participantsOfMail(ccMail, 'opencode'); + const me = parts.find(p => p.name === 'opencode'); + assert.ok(me, '自己应出现在参与方列表里'); + assert.equal(me.is_self, true); + assert.equal(parts.filter(p => p.is_self).length, 1); +}); + +test('participantsOfMail: 角色齐全且顺序为 from → to → cc', () => { + // 主收件人稳定排在抄送方之前,模型据此判断谁是负责人、谁是配合方 + const parts = participantsOfMail(ccMail, 'dsh'); + assert.deepEqual(parts.map(p => p.role), ['from', 'to', 'cc']); +}); + +test('participantsOfMail: 无抄送时只有两方', () => { + const mail = { from_name: 'admin', to_name: 'dsh', to_workspace: '/w', session_alias: 'a' }; + const parts = participantsOfMail(mail, 'dsh'); + assert.equal(parts.length, 2); +}); + +test('participantsOfMail: 跳过空名字条目', () => { + // cc_list 里出现空对象(历史数据或解析残缺)不该产出一个 address 为空的参与方 + const mail = { + from_name: 'admin', to_name: 'dsh', to_workspace: '/w', + cc_list: [{ name: '', path: '/x' }, {}], + session_alias: 'a', + }; + const parts = participantsOfMail(mail, 'dsh'); + assert.equal(parts.length, 2); + for (const p of parts) assert.notEqual(p.address, ''); +}); diff --git a/plugins/dsh-mail-bridge/test/discovery.test.mjs b/plugins/dsh-mail-bridge/test/discovery.test.mjs new file mode 100644 index 0000000..3e3c75e --- /dev/null +++ b/plugins/dsh-mail-bridge/test/discovery.test.mjs @@ -0,0 +1,218 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + renderNameSuggestions, + renderPathSuggestions, + renderSessionSuggestions, + renderParticipants, + renderContacts, + renderThread, +} from '../lib/discovery.js'; + +// 这一组渲染的唯一目的是让模型**不要自己拼地址**。 +// 所以断言集中在两点:给出的地址能原样使用;以及模型知道下一步该查什么。 + +test('renderNameSuggestions 只给名字并指向下一步', () => { + // 此时还不知道 path 与 session,硬拼裸名字地址会投到「默认会话」—— + // 那不一定是调用方想要的那条。 + const got = renderNameSuggestions(['opencode', 'admin']); + assert.match(got, /opencode/); + assert.match(got, /admin/); + assert.match(got, /suggest_address/, '要告诉模型下一步查什么'); +}); + +test('renderNameSuggestions 空列表给明确文案', () => { + assert.match(renderNameSuggestions([]), /没有可投递的收件人/); + assert.match(renderNameSuggestions(undefined), /没有可投递的收件人/); +}); + +test('renderPathSuggestions 空列表要说清「仍然能发」', () => { + // 不解释的话模型会卡在这一步,或者编一个路径出来。 + const got = renderPathSuggestions([], 'admin'); + assert.match(got, /可以留空/); + assert.match(got, /admin/); +}); + +test('renderPathSuggestions 列出目录并指向下一步', () => { + const got = renderPathSuggestions(['/home', '/home/program/agentmail'], 'opencode'); + assert.match(got, /\/home\/program\/agentmail/); + assert.match(got, /最近使用/); + assert.match(got, /suggest_address\(name="opencode", path="/); +}); + +const sessionData = { + kind: 'session', + suggestions: ['silent-harbor', 'happy-tiger', 'new'], + addresses: [ + 'opencode@/home.silent-harbor', + 'opencode@/home.happy-tiger', + 'opencode@/home.new', + ], + candidates: [ + { alias: 'silent-harbor', title: '联调 llmsproxy', source: 'mail', unread: 2 }, + { alias: 'happy-tiger', title: '补投验证', source: 'mail', unread: 0 }, + { alias: 'new', title: '新建会话', source: 'new' }, + ], +}; + +test('renderSessionSuggestions 用服务端拼好的完整地址', () => { + // 插件自己拼过一次,拼错了(空 path 时漏掉 @)。addresses 与 suggestions + // 同序由服务端保证,直接用。 + const got = renderSessionSuggestions(sessionData, 'opencode', '/home'); + assert.match(got, /opencode@\/home\.silent-harbor/); + assert.match(got, /opencode@\/home\.happy-tiger/); + assert.match(got, /原样填进 send_mail 的 to/); +}); + +test('renderSessionSuggestions 带出标题与未读数', () => { + const got = renderSessionSuggestions(sessionData, 'opencode', '/home'); + assert.match(got, /联调 llmsproxy/); + assert.match(got, /2 封未读/); +}); + +test('不变量:new 不与已存在会话混列,且带警告', () => { + // new 排在前面会让模型在想续谈时顺手开出一条新线索 —— 生产上已经发生过。 + const got = renderSessionSuggestions(sessionData, 'opencode', '/home'); + const lines = got.split('\n'); + const newLineIdx = lines.findIndex(l => l.includes('.new')); + const harborIdx = lines.findIndex(l => l.includes('silent-harbor')); + assert.ok(harborIdx >= 0 && newLineIdx > harborIdx, 'new 必须排在已存在会话之后'); + assert.match(got, /新\*\*线索|新\*\*/, 'new 要带「这是开新线索」的提示'); +}); + +test('renderSessionSuggestions 无已存在会话时引导命名', () => { + // 这是关键引导:开新会话时传 session_alias,之后才能按名字续谈。 + // 不传的话服务端会自动命名,但模型不知道那个名字。 + const got = renderSessionSuggestions( + { suggestions: ['new'], addresses: ['dsh@/tmp.new'], candidates: [{ alias: 'new', source: 'new' }] }, + 'dsh', '/tmp', + ); + assert.match(got, /还没有可续谈的会话/); + assert.match(got, /session_alias/); +}); + +const participantData = { + session_id: 'f3d824ce', + session_alias: 'silent-harbor', + participants: [ + { name: 'admin', path: '', roles: ['from'], is_self: false, mail_count: 1, address: 'admin@.silent-harbor' }, + { name: 'dsh', path: '/home/program/llmsproxy', roles: ['to'], is_self: true, mail_count: 0, address: 'dsh@/home/program/llmsproxy.silent-harbor' }, + { name: 'opencode', path: '/home', roles: ['cc'], is_self: false, mail_count: 0, address: 'opencode@/home.silent-harbor' }, + ], +}; + +test('renderParticipants 给出每个参与方的地址', () => { + const got = renderParticipants(participantData); + assert.match(got, /opencode@\/home\.silent-harbor/); + assert.match(got, /admin@\.silent-harbor/); + assert.match(got, /原样填进 send_mail 的 to/); +}); + +test('不变量:标出「尚未回应」的人', () => { + // mail_count 为 0 就是还没开口的人。服务端只数「作为发件人」的邮件, + // 正是为了让这个判断成立。 + const got = renderParticipants(participantData); + const line = got.split('\n').find(l => l.includes('opencode')); + assert.match(line, /尚未回应/); + // 自己不该被标「尚未回应」—— 自己正在处理这封 + const selfLine = got.split('\n').find(l => l.includes('dsh')); + assert.ok(!selfLine.includes('尚未回应')); + assert.match(selfLine, /就是你/); +}); + +test('renderParticipants 用中文角色标签', () => { + // 模型读到「抄送方」比读到 cc 更容易判对分工。 + const got = renderParticipants(participantData); + assert.match(got, /抄送方/); + assert.match(got, /发件人/); +}); + +test('renderParticipants 无地址时说明原因', () => { + const got = renderParticipants({ + session_alias: '', + participants: [{ name: 'x', roles: ['to'], mail_count: 0, address: '' }], + }); + assert.match(got, /尚未命名/); +}); + +test('renderParticipants 空会话不崩', () => { + assert.match(renderParticipants({ participants: [] }), /还没有参与方/); + assert.match(renderParticipants({}), /还没有参与方/); +}); + +test('renderContacts 未读优先排序', () => { + // 模型问「我还有什么没处理」时,有未读的那些才是答案。 + const got = renderContacts({ + contacts: [ + { address: 'a@.x', unread_count: 0, last_activity: '2026-09-03T02:00:00Z' }, + { address: 'b@.y', unread_count: 3, last_activity: '2026-09-01T00:00:00Z' }, + ], + }); + const lines = got.split('\n').filter(l => l.startsWith('- ')); + assert.match(lines[0], /b@\.y/, '有未读的应排在最前'); + assert.match(lines[0], /3 封未读/); +}); + +test('renderContacts 带出剩余预算', () => { + const got = renderContacts({ + contacts: [{ address: 'a@.x', unread_count: 0, max_rounds: 20, used_rounds: 17 }], + }); + assert.match(got, /剩 3\/20 个来回/); +}); + +test('renderContacts 未命名会话说明只能 reply_to', () => { + const got = renderContacts({ contacts: [{ address: '', unread_count: 1 }] }); + assert.match(got, /reply_to/); +}); + +test('renderContacts 空列表', () => { + assert.match(renderContacts({ contacts: [] }), /还没有任何往来会话/); +}); + +const threadData = { + anchor_mail_id: 'm-2', + total: 3, + hidden: 1, + nodes: [ + { mail_id: 'm-1', from_name: 'admin', to_name: 'dsh', subject: '抄收联调', depth: 0 }, + { mail_id: 'm-2', from_name: 'dsh', to_name: 'opencode', subject: '[联调] 请提供部署现状', depth: 1 }, + { mail_id: 'm-3', from_name: 'opencode', to_name: 'dsh', subject: 'Re: 联调', depth: 2, detached: true, parent_hidden: true }, + ], +}; + +test('renderThread 用缩进表示层级', () => { + const got = renderThread(threadData, 'dsh'); + const lines = got.split('\n'); + const l1 = lines.find(l => l.includes('m-1')); + const l2 = lines.find(l => l.includes('m-2')); + assert.ok(l2.indexOf('- ') > l1.indexOf('- '), '子节点应更深缩进'); +}); + +test('不变量:detached 必须标出来', () => { + // 不标的话模型会以为这是一条独立线索,而它其实挂在一封看不到的邮件下面。 + const got = renderThread(threadData, 'dsh'); + const line = got.split('\n').find(l => l.includes('m-3')); + assert.match(line, /父邮件无权查看/); +}); + +test('renderThread 标出自己发的与当前这封', () => { + const got = renderThread(threadData, 'dsh'); + assert.match(got.split('\n').find(l => l.includes('m-2')), /你发的/); + assert.match(got.split('\n').find(l => l.includes('m-2')), /当前这封/); +}); + +test('renderThread 报告不可见数量', () => { + // 「共 3 封」与实际列出 3 条一致,但另有 1 封无权查看 —— + // 不说的话模型会以为自己看到了全貌。 + assert.match(renderThread(threadData), /另有 1 封无权查看/); +}); + +test('renderThread 有更多时给出 offset', () => { + const got = renderThread({ ...threadData, has_more: true, next_offset: 60 }); + assert.match(got, /offset=60/); +}); + +test('renderThread 空线索不崩', () => { + assert.match(renderThread({ nodes: [] }), /没有可见的邮件/); + assert.match(renderThread({}), /没有可见的邮件/); +}); diff --git a/plugins/dsh-mail-bridge/test/inbox-format.test.mjs b/plugins/dsh-mail-bridge/test/inbox-format.test.mjs index f32ee5d..c496a68 100644 --- a/plugins/dsh-mail-bridge/test/inbox-format.test.mjs +++ b/plugins/dsh-mail-bridge/test/inbox-format.test.mjs @@ -117,6 +117,96 @@ test('附件字段不是数组时忽略', () => { assert.ok(!got.includes('抄送')); }); +// ─── 收件人与身份(只有知道自己是谁才能判定)─── + +test('不变量:收件人要显示出来', () => { + // 不显示的后果:被抄送方不知道主收件人是谁,无法向对方转达或汇报。 + // 线上那封联调邮件要求「由收件人汇报」,而抄送方看不到收件人叫什么。 + const got = renderMail(mail({ to_name: 'dsh', to_workspace: '/home/program/llmsproxy' })); + assert.match(got, /收件人: dsh@\/home\/program\/llmsproxy/); +}); + +test('收件人无工作目录时只显名字', () => { + const got = renderMail(mail({ to_name: 'admin', to_workspace: '' })); + assert.match(got, /收件人: admin$/m); +}); + +test('不传 selfName 时不出现身份行(兼容旧调用)', () => { + const got = renderMail(mail({ to_name: 'dsh' })); + assert.ok(!got.includes('你的身份')); +}); + +test('不变量:区分收件人与抄送方身份', () => { + // 两者职责不同。不区分的话两方都会以为自己是负责人, + // 或者都以为自己只是旁观者。 + const m = mail({ + to_name: 'dsh', + to_workspace: '/home/program/llmsproxy', + cc_list: [{ name: 'opencode', path: '/home', raw: 'opencode@/home.new' }], + }); + assert.match(renderMail(m, 200, 'dsh'), /你的身份: 收件人/); + assert.match(renderMail(m, 200, 'opencode'), /你的身份: 抄送方/); + // 不相关的名字不编造身份 + assert.ok(!renderMail(m, 200, 'someone').includes('你的身份')); +}); + +// ─── 可投递地址(「精准发信」的关键)─── + +const joint = () => mail({ + mail_id: 'm-7', + from_name: 'admin', + to_name: 'dsh', + to_workspace: '/home/program/llmsproxy', + cc_list: [{ name: 'opencode', path: '/home', session: 'new', raw: 'opencode@/home.new' }], + session_alias: 'silent-harbor', +}); + +test('不变量:给出每个参与方的可投递地址', () => { + // 之前模型只能从抄送行里拄一个 `opencode@/home.new`, + // 而那个地址回过去只会再建一条平行会话。 + const got = renderMail(joint(), 200, 'dsh'); + assert.match(got, /可投递地址/); + assert.match(got, /opencode@\/home\.silent-harbor(抄送方)/); + assert.match(got, /admin@\.silent-harbor(发件人)/); +}); + +test('不变量:可投递地址里绝不出现 .new', () => { + // 这是本轮修的根因的直接回归:`.new` 是一次性动作, + // 把它当回信地址会让双方各说各话。 + const got = renderMail(joint(), 200, 'dsh'); + const line = got.split('\n').find(l => l.startsWith('可投递地址')); + assert.ok(line, '应有可投递地址行'); + assert.ok(!line.includes('.new'), `地址行仍含 .new: ${line}`); +}); + +test('可投递地址不列自己', () => { + const got = renderMail(joint(), 200, 'dsh'); + const line = got.split('\n').find(l => l.startsWith('可投递地址')); + assert.ok(!line.includes('dsh@'), `不该把自己当成收件人选项: ${line}`); +}); + +test('同时给出 reply_to 这条更稳的路', () => { + // 地址可能拼错,reply_to 不会 —— 两条路都告诉模型。 + const got = renderMail(joint(), 200, 'dsh'); + assert.match(got, /reply_to=m-7/); +}); + +test('无会话别名时不给地址(宁可不给不可给错)', () => { + // 别名为空时拼不出「投回这条会话」的地址。给一个看着能用 + // 实际指向默认会话的地址,比不给危险。 + const got = renderMail(mail({ + to_name: 'dsh', session_alias: '', + cc_list: [{ name: 'opencode', path: '/home' }], + }), 200, 'dsh'); + assert.ok(!got.includes('可投递地址')); +}); + +test('renderInbox 透传 selfName', () => { + const got = renderInbox([joint()], 200, 'opencode'); + assert.match(got, /你的身份: 抄送方/); + assert.match(got, /dsh@\/home\/program\/llmsproxy\.silent-harbor(收件人)/); +}); + // ─── renderInbox ─── test('renderInbox 空收件箱给明确文案', () => { diff --git a/plugins/dsh-mail-bridge/test/model-scope.test.mjs b/plugins/dsh-mail-bridge/test/model-scope.test.mjs index 58f0980..ae15c29 100644 --- a/plugins/dsh-mail-bridge/test/model-scope.test.mjs +++ b/plugins/dsh-mail-bridge/test/model-scope.test.mjs @@ -12,6 +12,7 @@ import assert from 'node:assert/strict'; import { snapshotOpencodeModels, snapshotDshModels, + snapshotPiModels, modelAttemptOrder, renderFailureReport, MAX_CATALOG, @@ -106,6 +107,46 @@ test('目录截断到 MAX_CATALOG', () => { assert.equal(snapshotDshModels(many).length, MAX_CATALOG); }); +// ─── pi 目录 ─── + +test('pi 目录用 provider + id', () => { + const got = snapshotPiModels([ + { provider: 'llmsproxy', id: 'AUTO', name: 'AUTO' }, + { provider: 'anthropic', id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6' }, + ]); + assert.equal(got.length, 2); + assert.deepEqual(got[1], { + provider: 'anthropic', + model: 'claude-sonnet-4-6', + display_name: 'Claude Sonnet 4.6', + }); +}); + +test('pi 目录跳过缺 provider 或 id 的条目', () => { + const got = snapshotPiModels([ + { provider: '', id: 'x' }, + { provider: 'p' }, + { provider: 'p', id: 'ok' }, + ]); + assert.equal(got.length, 1); + assert.equal(got[0].model, 'ok'); +}); + +test('pi 目录容错:非数组不崩', () => { + assert.deepEqual(snapshotPiModels(undefined), []); + assert.deepEqual(snapshotPiModels(null), []); + assert.deepEqual(snapshotPiModels('oops'), []); +}); + +test('pi 目录同样受 MAX_CATALOG 截断', () => { + // 本机 pi 的完整目录有 1221 个模型(getModels),远超上限。 + // 桥实际上报的是 getAvailable() 的结果(只有带凭证的),但截断仍要生效。 + const many = Array.from({ length: MAX_CATALOG + 50 }, (_, i) => ({ + provider: 'p', id: `m${i}`, name: `M${i}`, + })); + assert.equal(snapshotPiModels(many).length, MAX_CATALOG); +}); + // ─── modelAttemptOrder ─── test('管理员划定范围时按 rank 顺序尝试', () => { diff --git a/plugins/dsh-mail-bridge/test/session-snapshot.test.mjs b/plugins/dsh-mail-bridge/test/session-snapshot.test.mjs index d714441..18f2e09 100644 --- a/plugins/dsh-mail-bridge/test/session-snapshot.test.mjs +++ b/plugins/dsh-mail-bridge/test/session-snapshot.test.mjs @@ -12,6 +12,8 @@ import assert from 'node:assert/strict'; import { snapshotOpencodeSessions, snapshotDshSessions, + snapshotPiSessions, + isUnusableName, slugFromTitle, MAX_REPORTED, } from '../lib/session-snapshot.js'; @@ -226,3 +228,99 @@ test('不同标题不受去重影响', () => { ]); assert.equal(got.length, 2); }); + +// ─── pi:名字来自会话文件的 session_info ─── + +const piSession = (over = {}) => ({ + id: '01a064cc-df57-7b2d-bebb-736776105485', + cwd: '/home/program/agentmail', + name: '重构导入路径', + messageCount: 6, + created: new Date(1788300000000), + modified: new Date(1788344476744), + ...over, +}); + +test('pi 快照取 cwd 与 session_info 名字', () => { + const [got] = snapshotPiSessions([piSession()]); + assert.equal(got.workspace, '/home/program/agentmail'); + assert.equal(got.title, '重构导入路径'); + assert.equal(got.slug, '重构导入路径'); + assert.equal(got.platform_id, '01a064cc-df57-7b2d-bebb-736776105485'); +}); + +test('不变量:pi 无名会话不上报', () => { + // pi 的列表在无名时显示首条消息,而邮件驱动会话的首条消息是桥自己拼的提示词 + // (「你收到一封新邮件(AgentMail)…」)—— 拿它当别名毫无区分度,且条条撞名。 + const got = snapshotPiSessions([ + piSession({ id: 'named', name: '有名字' }), + piSession({ id: 'anon', name: undefined }), + piSession({ id: 'blank', name: '' }), + ]); + assert.deepEqual(got.map(s => s.platform_id), ['named']); +}); + +test('不变量:pi 老会话的空 cwd 照实上报', () => { + // SessionInfo 的注释写明老会话 cwd 是空串。拿桥自己的 cwd 冒充会让 + // 那条会话在补全里挂到一个它其实不属于的工作区下。 + const [got] = snapshotPiSessions([piSession({ cwd: '' })]); + assert.equal(got.workspace, ''); +}); + +test('不变量:pi 的 updated_at 取 modified(文件 mtime)', () => { + const [got] = snapshotPiSessions([piSession()]); + assert.equal(got.updated_at, new Date(1788344476744).toISOString()); +}); + +test('pi 快照按最近活跃排序并对撞名 slug 去重', () => { + const got = snapshotPiSessions([ + piSession({ id: 'old', name: '同一个标题', modified: new Date(1000) }), + piSession({ id: 'new', name: '同一个标题', modified: new Date(9000) }), + ]); + assert.equal(got.length, 1); + assert.equal(got[0].platform_id, 'new'); +}); + +test('pi 快照标记邮件驱动的会话', () => { + const got = snapshotPiSessions( + [piSession({ id: 'mail-one' }), piSession({ id: 'human', name: '人开的' })], + (id) => id === 'mail-one' + ); + assert.equal(got.find(s => s.platform_id === 'mail-one').mail_driven, true); + assert.equal(got.find(s => s.platform_id === 'human').mail_driven, false); +}); + +// ─── isUnusableName:pi-web 标题生成器的思维链泄漏 ─── + +test('不变量:思维链泄漏的标题判废', () => { + // 都是本机 ~/.pi/agent/sessions 里实测捞到的真实 session_info 名字。 + // pi-web 的 cleanSessionName 只做「取首行 + 去引号 + 截 60 字符」,不防这个。 + assert.equal(isUnusableName('The user is asking me to generate a title for a coding-agent'), true); + assert.equal( + isUnusableName('我们只需要生成标题,不包含其他内容。标题应反映请求内容:测试opencode的源。简短:Opencode源测试。或者更简'), + true + ); +}); + +test('isUnusableName 放过正常标题', () => { + // 宁可漏判也不误判:错杀一个好名字会让那条会话失去可寻址的别名。 + assert.equal(isUnusableName('查看Agent接入群聊'), false); + assert.equal(isUnusableName('你应该知道内网拓扑结构吧'), false); + assert.equal(isUnusableName('homeagent-gateway'), false); + assert.equal(isUnusableName('重构导入路径'), false); + assert.equal(isUnusableName('Fix flaky auth test'), false); +}); + +test('isUnusableName 判废空名字', () => { + assert.equal(isUnusableName(''), true); + assert.equal(isUnusableName(' '), true); + assert.equal(isUnusableName(undefined), true); +}); + +test('判废的名字不进快照', () => { + const got = snapshotPiSessions([ + piSession({ id: 'leaked', name: 'The user is asking me to generate a title for a coding-agent' }), + piSession({ id: 'clean', name: '正常标题' }), + ]); + assert.deepEqual(got.map(s => s.platform_id), ['clean']); +}); diff --git a/plugins/opencode-mail-bridge/index.js b/plugins/opencode-mail-bridge/index.js index 33ac039..fb20fd3 100644 --- a/plugins/opencode-mail-bridge/index.js +++ b/plugins/opencode-mail-bridge/index.js @@ -20,6 +20,14 @@ import { DEFAULT_INBOX_STATUS, DEFAULT_INBOX_LIMIT, } from "./lib/inbox-format.js"; +import { + renderNameSuggestions, + renderPathSuggestions, + renderSessionSuggestions, + renderParticipants, + renderContacts, + renderThread, +} from "./lib/discovery.js"; import { explicitSends, noteExplicitSend, @@ -242,7 +250,11 @@ const readInboxTool = { // 渲染与已读策略放 lib/inbox-format.js:它们与平台 SDK 无关, // 各平台插件必须一致(见该文件里每条规则对应的错误行为)。 - const listed = renderInbox(data.mails); + // + // 传 AGENT_NAME 是为了让渲染能判定「我是收件人还是抄送方」并给出 + // 可投递地址 —— 不传的话模型只能从抄送行里抄一个 `.new`,而那是 + // 一次性的,回过去只会再建一条平行会话。 + const listed = renderInbox(data.mails, 200, AGENT_NAME); const ids = idsToMarkRead(args.filter, data.mails); if (ids.length) { @@ -317,6 +329,130 @@ const downloadAttachmentTool = { }, }; +// ─── 寻址发现工具(读 Agent 侧只读端点)─── +// +// 在这一组之前,send_mail 的 to 是个只能靠记忆拼写的自由文本字段。人类侧 +// 从来不是这样:三段式输入框逐段查候选。Agent 只能猜,而猜错不报错 —— +// 生产上 dsh 猜了 `opencode@/home`,投递成功,但那不是 opencode 的工作目录, +// 那个错误路径静默变成了新会话的 workspace。 +// +// 渲染逻辑在 lib/discovery.js(与平台 SDK 无关,三平台共用)。 + +const suggestAddressTool = { + description: + "查询可用的收件人地址,用于精准发信。分三段逐步查:不带参数给候选收件人名;" + + "带 name 给它可用的工作目录;name+path 都带则给该目录下可续谈的会话别名与现成地址。" + + "**发信前应先用它确认地址**,不要凭记忆拼写 —— 拼错不会报错,只会投到别的会话。", + args: { + name: z.string().optional().describe("收件人名;留空则列出所有候选收件人"), + path: z.string().optional().describe("工作目录;与 name 同时给出才列会话"), + }, + async execute(args) { + const name = (args.name || "").trim(); + const path = (args.path || "").trim(); + const qs = new URLSearchParams(); + if (name) qs.set("name", name); + if (path) qs.set("path", path); + const data = await apiGet(`/agent/contacts/suggest?${qs.toString()}`); + + // 按服务端回的 kind 分派,而不是按本地参数判断:省略 path 与传空串在 + // 服务端是同一个意思,但「哪一段该渲染成什么」只有服务端知道。 + switch (data?.kind) { + case "name": + return renderNameSuggestions(data.suggestions); + case "path": + return renderPathSuggestions(data.suggestions, name); + default: + return renderSessionSuggestions(data, name, path); + } + }, +}; + +const listContactsTool = { + description: + "列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。" + + "用于回答「我还有什么没处理」以及「上次跟某人聊的那条线索地址是什么」。", + args: { + limit: z.number().optional().describe("最多列出多少条,默认 20"), + }, + async execute(args) { + const data = await apiGet("/agent/contacts"); + return renderContacts(data, args.limit || 20); + }, +}; + +const sessionParticipantsTool = { + description: + "列出某条会话的全部参与方(发件人/收件人/抄送方)及各自的可投递地址," + + "并标出谁还没回应。**要回给抄收方或向第三方转达时先用它拿地址**。", + args: { + session_id: z.string().describe("会话 ID(read_inbox 未直接给出时可从 read_thread 或新邮件通知取得)"), + }, + async execute(args) { + const data = await apiGet(`/agent/sessions/${args.session_id}/participants`); + return renderParticipants(data); + }, +}; + +const readThreadTool = { + description: + "查看一封邮件所在线索的完整往来(谁回了谁、谁还没回)。多方抄送协作时" + + "用它确认别人已经说了什么,避免重复提问或重复汇报。", + args: { + mail_id: z.string().describe("线索中任一封邮件的 ID"), + offset: z.number().optional().describe("分页偏移,续取时传上次返回的 next_offset"), + }, + async execute(args) { + const qs = args.offset ? `?offset=${args.offset}` : ""; + const data = await apiGet(`/agent/mail/${args.mail_id}/thread${qs}`); + return renderThread(data, AGENT_NAME); + }, +}; + +const readMailTool = { + description: + "读一封邮件的完整内容,含收件人、抄送清单、附件与每个参与方的可投递地址。" + + "收件箱只给摘要;要回给抄收方就得先看清这封信发给了谁。", + args: { + mail_id: z.string().describe("邮件 ID"), + }, + async execute(args) { + const data = await apiGet(`/agent/mail/${args.mail_id}`); + const m = data?.mail || {}; + const lines = [ + `发件人: ${m.from_name || "?"}`, + `收件人: ${m.to_name || "?"}${m.to_workspace ? "@" + m.to_workspace : ""}`, + `主题: ${m.subject || "(无主题)"}`, + `会话: #${data.session_alias || "未命名"}(session_id: ${m.session_id || "?"})`, + ]; + if (Array.isArray(m.cc_list) && m.cc_list.length) { + lines.push(`抄送: ${m.cc_list.map(c => c?.raw || c?.name).join("、")}`); + } + if (Array.isArray(m.attachments) && m.attachments.length) { + lines.push( + `附件: ${m.attachments + .map(a => `${a.filename}(${formatSize(a.size_bytes)}, id=${a.attachment_id})`) + .join("、")}` + ); + } + lines.push("", m.body || "(空正文)", ""); + // 参与方地址由服务端拼好(session 位已是真实别名,不是 .new) + if (Array.isArray(data.participants) && data.participants.length) { + lines.push( + "可投递地址: " + + data.participants + .filter(p => p.address && p.name !== AGENT_NAME) + .map(p => `${p.address}(${p.role})`) + .join("、") + ); + } + if (data.reply_address) { + lines.push(`回信给发件人用 ${data.reply_address},或传 reply_to=${m.mail_id}。`); + } + return lines.join("\n"); + }, +}; + // 平台原生权限询问 → 邮件。 // // **不作为工具暴露给模型**:opencode 自己就有权限机制(permission.ask 钩子 / @@ -1052,10 +1188,16 @@ export default async function mailBridge(input) { tool: { send_mail: sendMailTool, read_inbox: readInboxTool, + read_mail: readMailTool, forward_mail: forwardMailTool, upload_attachment: uploadAttachmentTool, download_attachment: downloadAttachmentTool, connect_to_server: connectToServerTool, + // 寻址发现:让模型选地址而不是拼地址 + suggest_address: suggestAddressTool, + list_contacts: listContactsTool, + session_participants: sessionParticipantsTool, + read_thread: readThreadTool, }, }; } diff --git a/plugins/opencode-mail-bridge/lib/addressing.js b/plugins/opencode-mail-bridge/lib/addressing.js new file mode 100644 index 0000000..f80ea11 --- /dev/null +++ b/plugins/opencode-mail-bridge/lib/addressing.js @@ -0,0 +1,141 @@ +/** + * 三维寻址的构造与判读 —— 所有平台插件共用。 + * + * 为什么这些函数必须共用、且必须是纯函数: + * + * 地址拼错不会报错。`name@path.session` 的每一段都可以省略,任何组合都能被 + * `ParseAddress` 解析出**某个**结果,于是拼错的代价不是失败而是**投到别处**。 + * 生产上真实发生过两次: + * + * 1. 插件把 `.new` 原样当作回信地址 —— `.new` 是一次性动作,回过去只会 + * 再建一条平行会话,双方从此各说各话。 + * 2. path 为空时朴素拼接得到 `admin.silent-harbor` —— 没有 `@`, + * 整串被当成名字,session 位静默丢失。 + * + * 两次都是「拼字符串」造成的,所以拼地址这件事收进这里,各平台不再自己拼。 + */ + +/** + * 拼一个可寻址的 `name@path.session`。 + * + * **空 path 也必须留下 `@` 与 `.`**:`admin@.silent-harbor` 才解析成 + * name=admin path="" session=silent-harbor。省掉 `@` 得到的 + * `admin.silent-harbor` 会被整串当作名字。 + * + * session 省略时不写那一位(默认会话语义)。 + * + * @param {string} name 收件方名(Agent 名或人类用户名) + * @param {string} [path] 工作目录,可为空 + * @param {string} [session] 会话别名;空则省略该位 + * @returns {string} 地址,name 为空时返回空串 + */ +export function formatAddress(name, path, session) { + const n = String(name ?? '').trim(); + const p = String(path ?? '').trim(); + const s = String(session ?? '').trim(); + if (!n) return ''; + if (!s) return p ? `${n}@${p}` : n; + return `${n}@${p}.${s}`; +} + +/** + * 判断自己在这封邮件里是收件人还是抄送方。 + * + * 为什么需要它:被抄送方与主收件人的**职责不同**。线上那封联调邮件里, + * admin 主发 dsh、抄送 opencode,分工是「dsh 提供源码解读、opencode 提供部署 + * 现状、最后由 dsh 汇报」。收件箱若不区分身份,两方都会以为自己是负责人, + * 或者都以为自己只是旁观者。 + * + * @param {any} mail `/mail/inbox` 返回的一封邮件 + * @param {string} selfName 自己的 Agent 名 + * @returns {'to'|'cc'|'unknown'} + */ +export function roleOf(mail, selfName) { + const self = String(selfName ?? '').trim(); + if (!self) return 'unknown'; + if (mail?.to_name === self) return 'to'; + if (Array.isArray(mail?.cc_list) && mail.cc_list.some(c => c?.name === self)) { + return 'cc'; + } + return 'unknown'; +} + +/** + * 给出「把回信发回这条会话」的地址。 + * + * 发件人一侧**不带 path**:Agent 回信时 `from_workspace` 存的是 Agent 名而不是 + * 路径(历史遗留),拿它拼会得到 `dsh@dsh.alias` 这种投不出去的东西。 + * 人类发件人本来就没有工作目录。 + * + * 别名为空时退回 `name`(默认会话)而不是编一个 —— 但注意这与「投回同一条会话」 + * 不等价,默认会话是该 name 当前最活跃的那条。调用方要区分时看返回值有没有 `.`。 + * + * @param {any} mail 一封邮件 + * @param {string} [alias] 会话别名,缺省取 mail.session_alias + * @returns {string} + */ +export function replyAddressFor(mail, alias) { + const a = alias ?? mail?.session_alias ?? ''; + return formatAddress(mail?.from_name, '', a); +} + +/** + * 给出自己在这条会话里的地址,供转发说明或向第三方引用时使用。 + * + * 用 `to_workspace`(自己那个地址的 path 位)而不是发件人的: + * 抄送给 `opencode@/a` 与主发给 `dsh@/b` 是两个不同的工作区。 + * + * @param {any} mail 一封邮件 + * @param {string} selfName 自己的 Agent 名 + * @param {string} [alias] 会话别名,缺省取 mail.session_alias + * @returns {string} + */ +export function selfAddressFor(mail, selfName, alias) { + const a = alias ?? mail?.session_alias ?? ''; + // 抄送方拿到的 to_workspace 是主收件人的,自己的 path 在 cc_list 里。 + // 不取对的那个会让「我是谁」这句话指向别人的工作目录。 + let path = mail?.to_workspace ?? ''; + if (mail?.to_name !== selfName && Array.isArray(mail?.cc_list)) { + const mine = mail.cc_list.find(c => c?.name === selfName); + if (mine) path = mine.path ?? ''; + } + return formatAddress(selfName, path, a); +} + +/** + * 列出这封邮件的全部参与方及各自可投递的地址。 + * + * 这是「回给抄收方」缺的那块信息:知道有谁,**以及用什么地址找到他**。 + * 抄送方的 path 取它自己那个地址的 path 位。 + * + * 自己会被标 `is_self`,而不是从列表里剔掉 —— 剔掉的话模型无法确认 + * 「这封信是不是也发给了我」,也就无法判断自己是不是该回。 + * + * @param {any} mail 一封邮件 + * @param {string} [selfName] 自己的名字,用于标记 is_self + * @param {string} [alias] 会话别名,缺省取 mail.session_alias + * @returns {{role: string, name: string, path: string, address: string, is_self: boolean}[]} + */ +export function participantsOfMail(mail, selfName, alias) { + const a = alias ?? mail?.session_alias ?? ''; + const self = String(selfName ?? '').trim(); + const out = []; + const add = (role, name, path) => { + const n = String(name ?? '').trim(); + if (!n) return; + out.push({ + role, + name: n, + path: String(path ?? ''), + address: formatAddress(n, path, a), + is_self: !!self && n === self, + }); + }; + // 发件人一侧 path 留空,理由同 replyAddressFor + add('from', mail?.from_name, ''); + add('to', mail?.to_name, mail?.to_workspace); + if (Array.isArray(mail?.cc_list)) { + for (const c of mail.cc_list) add('cc', c?.name, c?.path); + } + return out; +} diff --git a/plugins/opencode-mail-bridge/lib/discovery.js b/plugins/opencode-mail-bridge/lib/discovery.js new file mode 100644 index 0000000..7b81d57 --- /dev/null +++ b/plugins/opencode-mail-bridge/lib/discovery.js @@ -0,0 +1,237 @@ +/** + * 寻址发现工具 —— 所有平台插件共用的**纯逻辑**部分。 + * + * 三个 Agent 侧只读端点(`/agent/contacts`、`/agent/contacts/suggest`、 + * `/agent/sessions/{id}/participants`)的返回值怎么渲染给模型看,与平台 SDK 无关, + * 所以收进这里。各平台只负责把自己的工具定义壳套上去。 + * + * # 这一组端点解决的问题 + * + * 在它们存在之前,`send_mail` 的 `to` 是一个**只能靠记忆拼写的自由文本字段**。 + * 人类侧从来不是这样:三段式输入框逐段查候选,name / path / session 每一段都从 + * 活数据里选。Agent 只能猜,而猜错不会报错 —— 生产上 dsh 猜了 + * `opencode@/home`,地址解析通过、投递成功,但那不是 opencode 的工作目录, + * 那个错误路径静默变成了新会话的 workspace。 + * + * # 渲染的取舍 + * + * 一律输出**可直接粘进 `to` 的完整地址**,而不是把三段分开列。模型看到 + * `opencode@/home.silent-harbor` 会整串复制;看到 `name=opencode path=/home + * session=silent-harbor` 则要自己拼,而自己拼就是问题的来源。 + */ + +/** + * 渲染候选收件人清单(`kind: "name"`)。 + * + * 只给名字,不给地址:此时还不知道 path 与 session,硬拼出来的 + * 裸名字地址会投到「默认会话」—— 那不一定是调用方想要的那条。 + * 明确提示下一步该查什么,模型才会继续往下走而不是就地拼一个。 + * + * @param {string[]} names + * @returns {string} + */ +export function renderNameSuggestions(names) { + const list = Array.isArray(names) ? names.filter(Boolean) : []; + if (list.length === 0) return '当前没有可投递的收件人。'; + return [ + `可投递的收件人(${list.length} 个):`, + list.map(n => `- ${n}`).join('\n'), + '', + '下一步:用 suggest_address 带上 name 查它可用的工作目录(path 位)。', + ].join('\n'); +} + +/** + * 渲染工作目录候选(`kind: "path"`)。 + * + * 空列表要说清「这不代表不能发」:path 位允许为空(人类用户没有工作目录), + * 不解释的话模型会卡在这一步,或者编一个路径出来。 + * + * @param {string[]} paths + * @param {string} name 正在查的收件人名,用于拼下一步的提示 + * @returns {string} + */ +export function renderPathSuggestions(paths, name) { + const list = Array.isArray(paths) ? paths.filter(Boolean) : []; + if (list.length === 0) { + return [ + `${name} 没有记录在案的工作目录。`, + '这不代表不能给它发信 —— path 位可以留空(人类用户就没有工作目录)。', + `直接用 suggest_address(name="${name}", path="") 查它的会话,或直接发给 ${name}。`, + ].join('\n'); + } + return [ + `${name} 用过的工作目录(按最近使用排序):`, + list.map(p => `- ${p}`).join('\n'), + '', + `下一步:用 suggest_address(name="${name}", path="<上面某一个>") 查该目录下可续谈的会话。`, + ].join('\n'); +} + +/** + * 渲染会话候选(`kind: "session"`)。 + * + * **`addresses` 与 `suggestions` 同序**,服务端保证。这里优先用 `addresses`: + * 那是服务端拼好的完整地址,插件不必自己拼(自己拼过一次,拼错了)。 + * + * `new` 永远在最后且带一句警告:它不是一条已存在的会话。排在前面会让模型 + * 在想续谈时顺手开出一条新线索 —— 生产上已经发生过。 + * + * @param {object} data `/agent/contacts/suggest` 的返回体 + * @param {string} name + * @param {string} path + * @returns {string} + */ +export function renderSessionSuggestions(data, name, path) { + const aliases = Array.isArray(data?.suggestions) ? data.suggestions : []; + const addresses = Array.isArray(data?.addresses) ? data.addresses : []; + const candidates = Array.isArray(data?.candidates) ? data.candidates : []; + + // 只有 new 一项 = 这个 name@path 下还没有任何可续谈的会话 + const existing = aliases.filter(a => a !== 'new'); + if (existing.length === 0) { + return [ + `${name}${path ? '@' + path : ''} 下还没有可续谈的会话。`, + `要开一条新线索用 ${addressAt(addresses, aliases, 'new') || `${name}@${path}.new`},`, + '并在 send_mail 里传 session_alias 给它命名,之后就能按名字续谈。', + ].join('\n'); + } + + const lines = [`${name}${path ? '@' + path : ''} 下可续谈的会话:`]; + for (let i = 0; i < aliases.length; i++) { + const alias = aliases[i]; + const addr = addresses[i] || ''; + const c = candidates[i] || {}; + if (alias === 'new') continue; // new 单独放最后 + const bits = []; + if (c.title) bits.push(c.title); + if (typeof c.unread === 'number' && c.unread > 0) bits.push(`${c.unread} 封未读`); + if (c.source === 'platform') bits.push('平台侧会话'); + lines.push(`- ${addr || alias}${bits.length ? ` (${bits.join(',')})` : ''}`); + } + lines.push(''); + lines.push('把上面某个地址原样填进 send_mail 的 to 即可投进那条会话。'); + const newAddr = addressAt(addresses, aliases, 'new'); + if (newAddr) { + lines.push(`若确实要开一条**新**线索(而不是接着上面某条谈)才用 ${newAddr}。`); + } + return lines.join('\n'); +} + +/** 按别名在同序的 addresses 里取地址。 */ +function addressAt(addresses, aliases, alias) { + const i = aliases.indexOf(alias); + return i >= 0 ? addresses[i] || '' : ''; +} + +/** + * 渲染会话参与方清单。 + * + * 这是「发送给抄收方 / 转发方」缺的最后一块:知道有谁、**用什么地址找到他**、 + * 以及谁还没开口。`mail_count` 为 0 的那个就是还没回应的人 —— 服务端只数 + * 「作为发件人」的邮件,正是为了让这个判断成立。 + * + * @param {object} data `/agent/sessions/{id}/participants` 的返回体 + * @returns {string} + */ +export function renderParticipants(data) { + const parts = Array.isArray(data?.participants) ? data.participants : []; + if (parts.length === 0) return '该会话还没有参与方(可能是一条刚建立的空会话)。'; + + const alias = data?.session_alias || ''; + const lines = [`会话 #${alias || '未命名'} 的参与方:`]; + for (const p of parts) { + const tags = []; + if (p.is_self) tags.push('就是你'); + if (Array.isArray(p.roles) && p.roles.length) { + tags.push(p.roles.map(roleLabel).join('/')); + } + if (p.mail_count === 0 && !p.is_self) tags.push('尚未回应'); + const addr = p.address ? p.address : '(无可投递地址:该会话尚未命名)'; + lines.push(`- ${p.name} ${addr}${tags.length ? ` [${tags.join(',')}]` : ''}`); + } + lines.push(''); + lines.push('要联系其中某一方,把它的地址原样填进 send_mail 的 to。'); + return lines.join('\n'); +} + +/** + * 渲染联系人清单(本 Agent 参与过的全部会话)。 + * + * 按未读优先、其次最近活跃排序:模型问「我还有什么没处理」时, + * 有未读的那些才是答案。 + * + * @param {object} data `/agent/contacts` 的返回体 + * @param {number} limit 最多列出多少条 + * @returns {string} + */ +export function renderContacts(data, limit = 20) { + const list = Array.isArray(data?.contacts) ? data.contacts.slice() : []; + if (list.length === 0) return '还没有任何往来会话。'; + + list.sort((a, b) => { + const ua = a?.unread_count || 0; + const ub = b?.unread_count || 0; + if (ua !== ub) return ub - ua; + return String(b?.last_activity || '').localeCompare(String(a?.last_activity || '')); + }); + + const shown = list.slice(0, limit); + const lines = [`往来会话(共 ${list.length} 条${list.length > shown.length ? `,列出前 ${shown.length}` : ''}):`]; + for (const c of shown) { + const bits = []; + if (c.unread_count > 0) bits.push(`${c.unread_count} 封未读`); + if (c.subject) bits.push(c.subject); + if (c.max_rounds > 0) { + const left = Math.max(0, c.max_rounds - (c.used_rounds || 0)); + bits.push(`剩 ${left}/${c.max_rounds} 个来回`); + } + const addr = c.address || '(未命名会话,只能用 reply_to 续谈)'; + lines.push(`- ${addr}${bits.length ? ` (${bits.join(',')})` : ''}`); + } + return lines.join('\n'); +} + +/** 角色的中文标签。模型读到「抄送方」比读到 cc 更容易判对分工。 */ +function roleLabel(role) { + switch (role) { + case 'from': return '发件人'; + case 'to': return '收件人'; + case 'cc': return '抄送方'; + default: return String(role); + } +} + +/** + * 渲染对话树,回答「谁已经回了、谁还没回」。 + * + * 缩进表示层级。**detached 必须标出来**:那表示父邮件不在本次结果里 + * (无权查看或尚未加载),不标的话模型会以为这是一条独立线索。 + * + * @param {object} data `/agent/mail/{id}/thread` 的返回体 + * @param {string} [selfName] 自己的名字,用于标出哪几封是自己发的 + * @returns {string} + */ +export function renderThread(data, selfName = '') { + const nodes = Array.isArray(data?.nodes) ? data.nodes : []; + if (nodes.length === 0) return '这条线索上没有可见的邮件。'; + + const lines = [`线索共 ${data?.total ?? nodes.length} 封${data?.hidden ? `(另有 ${data.hidden} 封无权查看)` : ''}:`]; + for (const n of nodes) { + const depth = typeof n?.depth === 'number' ? Math.max(0, n.depth) : 0; + const indent = ' '.repeat(Math.min(depth, 8)); + const marks = []; + if (selfName && n?.from_name === selfName) marks.push('你发的'); + if (n?.mail_id === data?.anchor_mail_id) marks.push('当前这封'); + if (n?.detached) marks.push(n.parent_hidden ? '父邮件无权查看' : '父邮件尚未加载'); + lines.push( + `${indent}- ${n?.from_name ?? '?'} → ${n?.to_name ?? '?'}: ${n?.subject ?? '(无主题)'}` + + ` [${n?.mail_id ?? '?'}]${marks.length ? ` (${marks.join(',')})` : ''}` + ); + } + if (data?.has_more) { + lines.push(''); + lines.push(`还有更多,用 offset=${data.next_offset} 继续取。`); + } + return lines.join('\n'); +} diff --git a/plugins/opencode-mail-bridge/lib/inbox-format.js b/plugins/opencode-mail-bridge/lib/inbox-format.js index 974bd54..8f2f54b 100644 --- a/plugins/opencode-mail-bridge/lib/inbox-format.js +++ b/plugins/opencode-mail-bridge/lib/inbox-format.js @@ -6,6 +6,8 @@ * 渲染出的文本与标记已读的时机都该一致。新接一个平台时直接复用这里。 */ +import { roleOf, replyAddressFor, participantsOfMail } from './addressing.js'; + /** 人类可读的字节数,用于附件清单展示。 */ export function formatSize(n) { if (typeof n !== 'number' || !Number.isFinite(n)) return '?'; @@ -19,19 +21,41 @@ export function formatSize(n) { * * @param {any} m `/mail/inbox` 返回的一封邮件 * @param {number} bodyLimit 正文截断长度 + * @param {string} [selfName] 自己的 Agent 名。给了就能判定「我是收件人还是抄送方」 + * 并给出参与方地址;不给则退化成旧行为(兼容未传该参数的调用方)。 * @returns {string} */ -export function renderMail(m, bodyLimit = 200) { +export function renderMail(m, bodyLimit = 200, selfName = '') { + const alias = m?.session_alias || ''; const lines = [ `[${m?.status ?? 'unknown'}] ${m?.from_name ?? 'unknown'}: ${m?.subject ?? '(无主题)'}`, `邮件 ID: ${m?.mail_id ?? 'unknown'}`, - `会话: #${m?.session_alias || '未命名'}`, + `会话: #${alias || '未命名'}`, ]; + + // 收件人必须显示。不显示的后果:被抄送方既不知道主收件人是谁, + // 也无法向对方转达或汇报 —— 线上那封联调邮件要求「由收件人汇报」, + // 抄送方却看不到收件人叫什么。 + if (m?.to_name) { + let toLine = `收件人: ${m.to_name}`; + if (m?.to_workspace) toLine += `@${m.to_workspace}`; + lines.push(toLine); + } + // 抄送要显示:一封邮件为什么同时到了几个人手上,只有抄送能解释。 // 不显示的话模型会以为这是私下发给它一个人的,回信时漏掉其他参与方。 if (Array.isArray(m?.cc_list) && m.cc_list.length > 0) { lines.push('抄送: ' + m.cc_list.map(c => c?.raw || c?.name || '?').join('、')); } + + // 自己的身份。抄送方与主收件人的职责不同,不区分的话两方都会 + // 以为自己是负责人,或者都以为自己只是旁观者。 + if (selfName) { + const role = roleOf(m, selfName); + if (role === 'to') lines.push('你的身份: 收件人(主办)'); + else if (role === 'cc') lines.push('你的身份: 抄送方(配合)'); + } + // **必须给出 attachment_id**:只说「有附件」模型就无从下载。 if (Array.isArray(m?.attachments) && m.attachments.length > 0) { lines.push( @@ -42,22 +66,52 @@ export function renderMail(m, bodyLimit = 200) { ); lines.push('下载附件请用 download_attachment 工具。'); } + // 列表接口只给 body_preview(省带宽),单封接口才有 body。两者都兜住。 const body = m?.body_preview || m?.body || ''; lines.push(`内容: ${String(body).slice(0, bodyLimit)}`); + + // 可投递地址放在最后,紧贴正文 —— 模型读完内容紧接着就要决定发给谁。 + // + // 这一段是「精准发信」的关键:之前模型只能从抄送行里拄一个 + // `opencode@/home.new` 拄过去,而 `.new` 是一次性的,回过去只会再建一条 + // 平行会话。这里给的地址全部已经把 session 位换成真实别名。 + if (selfName && alias) { + const parts = participantsOfMail(m, selfName, alias); + const others = parts.filter(p => !p.is_self && p.address); + if (others.length > 0) { + lines.push( + '可投递地址: ' + + others.map(p => `${p.address}(${roleLabel(p.role)})`).join('、') + ); + lines.push(`直接回信给发件人用 ${replyAddressFor(m, alias)},或传 reply_to=${m?.mail_id ?? ''}。`); + } + } + return lines.join('\n'); } +/** 角色的中文标签。模型读到「抄送方」比读到 cc 更容易判对分工。 */ +function roleLabel(role) { + switch (role) { + case 'from': return '发件人'; + case 'to': return '收件人'; + case 'cc': return '抄送方'; + default: return role; + } +} + /** * 渲染整个收件箱。 * @param {any[]} mails * @param {number} bodyLimit + * @param {string} [selfName] 自己的 Agent 名,透传给 renderMail * @returns {string} */ -export function renderInbox(mails, bodyLimit = 200) { +export function renderInbox(mails, bodyLimit = 200, selfName = '') { const list = Array.isArray(mails) ? mails : []; if (list.length === 0) return '收件箱为空。'; - return list.map(m => renderMail(m, bodyLimit)).join('\n\n'); + return list.map(m => renderMail(m, bodyLimit, selfName)).join('\n\n'); } /** diff --git a/plugins/opencode-mail-bridge/lib/model-scope.js b/plugins/opencode-mail-bridge/lib/model-scope.js index 5ee21e0..74fbe11 100644 --- a/plugins/opencode-mail-bridge/lib/model-scope.js +++ b/plugins/opencode-mail-bridge/lib/model-scope.js @@ -65,6 +65,37 @@ export function snapshotDshModels(entries) { return dedupeAndCap(out); } +/** + * 把 pi 的模型列表整理成上报格式。 + * + * pi 侧的取法是 `await modelRuntime.getAvailable()` —— **不是** `getModels()`。 + * 两者差别很大:本机实测目录里有 1221 个模型,而带凭证、真能调起来的只有 1 个。 + * 上报 `getModels()` 的结果会让管理员在配置页选中一个注定失败的路由, + * 而失败要到真发邮件时才暴露(模型目录上报的全部意义就是避免这件事)。 + * + * pi 的 Model 对象上,provider 在 `provider` 字段、模型 id 在 `id` 字段, + * 展示名在 `name`。形状与 DSH 侧一致,但语义来源不同,因此单独一个函数 + * ——照抄 snapshotDshModels 会让「必须用 getAvailable」这条约束无处记录。 + * + * @param {any[]} models `await modelRuntime.getAvailable()` 的结果 + * @returns {object[]} + */ +export function snapshotPiModels(models) { + const list = Array.isArray(models) ? models : []; + const out = []; + for (const m of list) { + const provider = typeof m?.provider === 'string' ? m.provider : ''; + const model = typeof m?.id === 'string' ? m.id : ''; + if (!provider || !model) continue; + out.push({ + provider, + model, + display_name: typeof m?.name === 'string' ? m.name : '', + }); + } + return dedupeAndCap(out); +} + /** * 决定这一轮按什么顺序尝试模型。 * diff --git a/plugins/opencode-mail-bridge/lib/session-snapshot.js b/plugins/opencode-mail-bridge/lib/session-snapshot.js index e4cbfee..34e38a7 100644 --- a/plugins/opencode-mail-bridge/lib/session-snapshot.js +++ b/plugins/opencode-mail-bridge/lib/session-snapshot.js @@ -83,6 +83,83 @@ export function snapshotDshSessions(entries, isMailDriven = () => false) { return dedupeBySlug(sortAndCap(out)); } +/** + * 把 pi 的 `SessionManager.list()/listAll()` 结果整理成上报格式。 + * + * pi 的会话名字来自会话文件里最后一条 `session_info` 条目: + * - pi-web 在一条会话的首次 prompt 时用模型生成一个 2-6 词的标题 + * - TUI 的 `/name`、启动参数 `--name`、`/resume` 里的改名也写同一处 + * - **pi 内核(SDK)自己不生成**:桥用 createAgentSession 起的会话没有名字, + * 要由桥按「Gateway 定稿的别名」回写(见 index 的 syncNaming) + * + * 与另两个平台的差异:pi 的 SessionInfo 里**没有 subagent 标记**。 + * pi-subagents 把子会话写在自定义 sessionDir(run 根目录)下,默认会话目录 + * 列不到它们,因此这里不需要 S-2 那样的显式过滤。 + * + * @param {any[]} entries SessionInfo 列表 `[{ id, cwd, name, modified }]` + * @param {(id: string) => boolean} isMailDriven + * @returns {object[]} + */ +export function snapshotPiSessions(entries, isMailDriven = () => false) { + const list = Array.isArray(entries) ? entries : []; + const out = []; + for (const e of list) { + const id = typeof e?.id === 'string' ? e.id : ''; + if (!id) continue; + const name = typeof e?.name === 'string' ? e.name : ''; + // 没有名字的会话不报(S-1):pi 的列表在无名时显示首条消息, + // 而首条消息对邮件驱动的会话就是桥自己拼的提示词 —— 拿它当别名毫无区分度。 + if (!name) continue; + // 模型把思维链当标题写进来的那些不报(见 isUnusableName) + if (isUnusableName(name)) continue; + const slug = slugFromTitle(name); + if (!slug) continue; + out.push({ + platform_id: id, + // 老会话的 cwd 是空串(pi 的 SessionInfo 注释里写明了),照实上报, + // 服务端按空 workspace 处理,不要拿桥自己的 cwd 冒充。 + workspace: typeof e?.cwd === 'string' ? e.cwd : '', + slug, + title: name, + mail_driven: Boolean(isMailDriven(id)), + updated_at: toISO(e?.modified ?? e?.created), + }); + } + return dedupeBySlug(sortAndCap(out)); +} + +/** + * 判断一个平台侧名字是否不适合当别名。 + * + * 这条判废是 pi 特有的:pi-web 的标题生成器(`sessionNameGenerator`)只做了 + * 「取首行 + 去引号 + 截 60 字符」,没有防思维链泄漏。本机 81 条会话里实测捞到: + * + * "The user is asking me to generate a title for a coding-agent" + * "我们只需要生成标题,不包含其他内容。标题应反映请求内容:测试opencode的源。简短:…" + * + * 这类字符串派生出的别名又长又没有指代作用,填进三维地址里更是灾难。 + * 判废后调用方回退到「不上报」或「用邮件主题派生」,都比它强。 + * + * 宁可漏判也不误判:错杀一个好名字会让那条会话失去可寻址的别名, + * 而漏掉一个坏名字只是别名难看。 + * + * @param {string} name + * @returns {boolean} + */ +export function isUnusableName(name) { + const s = String(name ?? '').trim(); + if (!s) return true; + // 自指标题生成任务 = 模型把系统提示词复述了出来 + if (/生成标题|标题应|拟一个标题|generate a (short |concise )?title|session title|as a title/i.test(s)) { + return true; + } + // 以第三人称叙述用户意图开头 = 思维链的典型开场 + if (/^(the user\b|用户(想|要|在|希望)|我们只需要|我需要先|首先(,|,))/i.test(s)) return true; + // 又长又分句 = 一段话而不是一个标题(pi-web 截断上限是 60) + if (s.length >= 48 && /[。;;]|\.\s/.test(s)) return true; + return false; +} + /** 判断一条会话是否为 subagent 子会话。两个字段任一成立即算。 */ function isSubagent(e) { if (e?.origin === 'subagent') return true; @@ -131,11 +208,17 @@ export function slugFromTitle(title) { return slug; } -/** 毫秒时间戳或 ISO 串 → ISO 串;无法解析时返回 undefined。 */ +/** 毫秒时间戳、ISO 串或 Date → ISO 串;无法解析时返回 undefined。 */ function toISO(v) { if (typeof v === 'number' && Number.isFinite(v)) { return new Date(v).toISOString(); } + // pi 的 SessionInfo 给的是 Date 实例(created/modified),不是时间戳。 + // 少了这一支会让整份快照的 updated_at 全是 undefined,于是服务端只能按 + // 上报时间排序 —— 补全列表里「最近在谈的那条」不再排在前面。 + if (v instanceof Date) { + return Number.isNaN(v.getTime()) ? undefined : v.toISOString(); + } if (typeof v === 'string' && v) { const d = new Date(v); if (!Number.isNaN(d.getTime())) return d.toISOString(); diff --git a/plugins/opencode-mail-bridge/test/addressing.test.mjs b/plugins/opencode-mail-bridge/test/addressing.test.mjs new file mode 100644 index 0000000..279f80d --- /dev/null +++ b/plugins/opencode-mail-bridge/test/addressing.test.mjs @@ -0,0 +1,145 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + formatAddress, + roleOf, + replyAddressFor, + selfAddressFor, + participantsOfMail, +} from '../lib/addressing.js'; + +// 地址拼错不会报错,只会投到别处 —— 所以这一组测试全部落在 +// 「拼出来的东西还能不能被正确解析回三段」上。 + +test('formatAddress: 空 path 仍保留 @ 与 .', () => { + // 生产事故:朴素拼接得到 admin.silent-harbor,没有 @, + // 整串被 ParseAddress 当成名字,session 位静默丢失。 + assert.equal(formatAddress('admin', '', 'silent-harbor'), 'admin@.silent-harbor'); +}); + +test('formatAddress: 省略 session 位', () => { + assert.equal(formatAddress('dsh', '/home/program/agentmail', ''), 'dsh@/home/program/agentmail'); + // 名字与 path 都有但都不带会话 → 默认会话语义 + assert.equal(formatAddress('dsh', '', ''), 'dsh'); +}); + +test('formatAddress: path 含 . 与 / 时仍按最后一个 . 切', () => { + // path 里允许 . 与 /,切分靠最后一个 . —— 拼出来的必须满足这个约定 + const addr = formatAddress('bot', '/srv/app.v2', 'fix-leak'); + assert.equal(addr, 'bot@/srv/app.v2.fix-leak'); + assert.equal(addr.slice(addr.lastIndexOf('.') + 1), 'fix-leak'); +}); + +test('formatAddress: 名字为空返回空串而不是残缺地址', () => { + // 返回 "@/path.alias" 会被投递端当成缺名字报错, + // 但那是在很后面才发现;这里直接给空串让调用方立刻看出没法拼。 + assert.equal(formatAddress('', '/p', 'a'), ''); + assert.equal(formatAddress(null, '/p', 'a'), ''); +}); + +test('formatAddress: 去掉首尾空白', () => { + assert.equal(formatAddress(' dsh ', ' /home ', ' alias '), 'dsh@/home.alias'); +}); + +const ccMail = { + from_name: 'admin', + to_name: 'dsh', + to_workspace: '/home/program/llmsproxy', + cc_list: [{ name: 'opencode', path: '/home', session: 'new', raw: 'opencode@/home.new' }], + session_alias: 'silent-harbor', +}; + +test('roleOf: 区分主收件人与抄送方', () => { + // 被抄送方与主收件人职责不同:线上那封联调邮件里 dsh 负责汇报、 + // opencode 只提供信息。不区分身份两方都会以为自己是负责人。 + assert.equal(roleOf(ccMail, 'dsh'), 'to'); + assert.equal(roleOf(ccMail, 'opencode'), 'cc'); + assert.equal(roleOf(ccMail, 'someone-else'), 'unknown'); +}); + +test('roleOf: 名字为空时不猜', () => { + assert.equal(roleOf(ccMail, ''), 'unknown'); + assert.equal(roleOf(ccMail, undefined), 'unknown'); +}); + +test('replyAddressFor: 用会话别名而非原地址的 .new', () => { + // 关键回归:把 .new 原样当回信地址会再建一条平行会话。 + const addr = replyAddressFor(ccMail); + assert.equal(addr, 'admin@.silent-harbor'); + assert.ok(!addr.endsWith('.new'), '回信地址不得以 .new 结尾'); +}); + +test('replyAddressFor: 发件人一侧不带 path', () => { + // Agent 回信时 from_workspace 存的是 Agent 名而不是路径, + // 拿它拼会得到 dsh@dsh.alias —— 投不出去。 + const mail = { from_name: 'dsh', from_workspace: 'dsh', session_alias: 'x' }; + assert.equal(replyAddressFor(mail), 'dsh@.x'); +}); + +test('replyAddressFor: 无别名时退回默认会话形式', () => { + const mail = { from_name: 'admin', session_alias: '' }; + const addr = replyAddressFor(mail); + assert.equal(addr, 'admin'); + // 调用方靠有没有 . 判断这是不是「投回同一条会话」 + assert.ok(!addr.includes('.'), '默认会话形式不含 session 位'); +}); + +test('selfAddressFor: 抄送方取自己那个地址的 path', () => { + // to_workspace 是主收件人的工作目录。抄送方拿它当自己的 path, + // 「我是谁」这句话就指向了别人的目录。 + assert.equal(selfAddressFor(ccMail, 'opencode'), 'opencode@/home.silent-harbor'); + assert.equal(selfAddressFor(ccMail, 'dsh'), 'dsh@/home/program/llmsproxy.silent-harbor'); +}); + +test('participantsOfMail: 抄送方的 path 是自己那个', () => { + const parts = participantsOfMail(ccMail, 'dsh'); + const byName = Object.fromEntries(parts.map(p => [p.name, p])); + + assert.equal(byName.opencode.path, '/home'); + assert.equal(byName.opencode.address, 'opencode@/home.silent-harbor'); + assert.equal(byName.dsh.path, '/home/program/llmsproxy'); + // 发件人 path 留空,理由同 replyAddressFor + assert.equal(byName.admin.address, 'admin@.silent-harbor'); +}); + +test('participantsOfMail: 地址一律用会话别名,不带 .new', () => { + // cc_list 里原本记的是 opencode@/home.new。参与方地址必须换成别名, + // 否则「回给抄收方」这个动作每次都会新开会话。 + for (const p of participantsOfMail(ccMail, 'dsh')) { + assert.ok(!p.address.endsWith('.new'), `${p.name} 的地址仍是 .new: ${p.address}`); + } +}); + +test('participantsOfMail: 自己被标记而不是被剔除', () => { + // 剔掉的话模型无法确认这封信是不是也发给了自己, + // 也就无法判断自己该不该回。 + const parts = participantsOfMail(ccMail, 'opencode'); + const me = parts.find(p => p.name === 'opencode'); + assert.ok(me, '自己应出现在参与方列表里'); + assert.equal(me.is_self, true); + assert.equal(parts.filter(p => p.is_self).length, 1); +}); + +test('participantsOfMail: 角色齐全且顺序为 from → to → cc', () => { + // 主收件人稳定排在抄送方之前,模型据此判断谁是负责人、谁是配合方 + const parts = participantsOfMail(ccMail, 'dsh'); + assert.deepEqual(parts.map(p => p.role), ['from', 'to', 'cc']); +}); + +test('participantsOfMail: 无抄送时只有两方', () => { + const mail = { from_name: 'admin', to_name: 'dsh', to_workspace: '/w', session_alias: 'a' }; + const parts = participantsOfMail(mail, 'dsh'); + assert.equal(parts.length, 2); +}); + +test('participantsOfMail: 跳过空名字条目', () => { + // cc_list 里出现空对象(历史数据或解析残缺)不该产出一个 address 为空的参与方 + const mail = { + from_name: 'admin', to_name: 'dsh', to_workspace: '/w', + cc_list: [{ name: '', path: '/x' }, {}], + session_alias: 'a', + }; + const parts = participantsOfMail(mail, 'dsh'); + assert.equal(parts.length, 2); + for (const p of parts) assert.notEqual(p.address, ''); +}); diff --git a/plugins/opencode-mail-bridge/test/discovery.test.mjs b/plugins/opencode-mail-bridge/test/discovery.test.mjs new file mode 100644 index 0000000..3e3c75e --- /dev/null +++ b/plugins/opencode-mail-bridge/test/discovery.test.mjs @@ -0,0 +1,218 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + renderNameSuggestions, + renderPathSuggestions, + renderSessionSuggestions, + renderParticipants, + renderContacts, + renderThread, +} from '../lib/discovery.js'; + +// 这一组渲染的唯一目的是让模型**不要自己拼地址**。 +// 所以断言集中在两点:给出的地址能原样使用;以及模型知道下一步该查什么。 + +test('renderNameSuggestions 只给名字并指向下一步', () => { + // 此时还不知道 path 与 session,硬拼裸名字地址会投到「默认会话」—— + // 那不一定是调用方想要的那条。 + const got = renderNameSuggestions(['opencode', 'admin']); + assert.match(got, /opencode/); + assert.match(got, /admin/); + assert.match(got, /suggest_address/, '要告诉模型下一步查什么'); +}); + +test('renderNameSuggestions 空列表给明确文案', () => { + assert.match(renderNameSuggestions([]), /没有可投递的收件人/); + assert.match(renderNameSuggestions(undefined), /没有可投递的收件人/); +}); + +test('renderPathSuggestions 空列表要说清「仍然能发」', () => { + // 不解释的话模型会卡在这一步,或者编一个路径出来。 + const got = renderPathSuggestions([], 'admin'); + assert.match(got, /可以留空/); + assert.match(got, /admin/); +}); + +test('renderPathSuggestions 列出目录并指向下一步', () => { + const got = renderPathSuggestions(['/home', '/home/program/agentmail'], 'opencode'); + assert.match(got, /\/home\/program\/agentmail/); + assert.match(got, /最近使用/); + assert.match(got, /suggest_address\(name="opencode", path="/); +}); + +const sessionData = { + kind: 'session', + suggestions: ['silent-harbor', 'happy-tiger', 'new'], + addresses: [ + 'opencode@/home.silent-harbor', + 'opencode@/home.happy-tiger', + 'opencode@/home.new', + ], + candidates: [ + { alias: 'silent-harbor', title: '联调 llmsproxy', source: 'mail', unread: 2 }, + { alias: 'happy-tiger', title: '补投验证', source: 'mail', unread: 0 }, + { alias: 'new', title: '新建会话', source: 'new' }, + ], +}; + +test('renderSessionSuggestions 用服务端拼好的完整地址', () => { + // 插件自己拼过一次,拼错了(空 path 时漏掉 @)。addresses 与 suggestions + // 同序由服务端保证,直接用。 + const got = renderSessionSuggestions(sessionData, 'opencode', '/home'); + assert.match(got, /opencode@\/home\.silent-harbor/); + assert.match(got, /opencode@\/home\.happy-tiger/); + assert.match(got, /原样填进 send_mail 的 to/); +}); + +test('renderSessionSuggestions 带出标题与未读数', () => { + const got = renderSessionSuggestions(sessionData, 'opencode', '/home'); + assert.match(got, /联调 llmsproxy/); + assert.match(got, /2 封未读/); +}); + +test('不变量:new 不与已存在会话混列,且带警告', () => { + // new 排在前面会让模型在想续谈时顺手开出一条新线索 —— 生产上已经发生过。 + const got = renderSessionSuggestions(sessionData, 'opencode', '/home'); + const lines = got.split('\n'); + const newLineIdx = lines.findIndex(l => l.includes('.new')); + const harborIdx = lines.findIndex(l => l.includes('silent-harbor')); + assert.ok(harborIdx >= 0 && newLineIdx > harborIdx, 'new 必须排在已存在会话之后'); + assert.match(got, /新\*\*线索|新\*\*/, 'new 要带「这是开新线索」的提示'); +}); + +test('renderSessionSuggestions 无已存在会话时引导命名', () => { + // 这是关键引导:开新会话时传 session_alias,之后才能按名字续谈。 + // 不传的话服务端会自动命名,但模型不知道那个名字。 + const got = renderSessionSuggestions( + { suggestions: ['new'], addresses: ['dsh@/tmp.new'], candidates: [{ alias: 'new', source: 'new' }] }, + 'dsh', '/tmp', + ); + assert.match(got, /还没有可续谈的会话/); + assert.match(got, /session_alias/); +}); + +const participantData = { + session_id: 'f3d824ce', + session_alias: 'silent-harbor', + participants: [ + { name: 'admin', path: '', roles: ['from'], is_self: false, mail_count: 1, address: 'admin@.silent-harbor' }, + { name: 'dsh', path: '/home/program/llmsproxy', roles: ['to'], is_self: true, mail_count: 0, address: 'dsh@/home/program/llmsproxy.silent-harbor' }, + { name: 'opencode', path: '/home', roles: ['cc'], is_self: false, mail_count: 0, address: 'opencode@/home.silent-harbor' }, + ], +}; + +test('renderParticipants 给出每个参与方的地址', () => { + const got = renderParticipants(participantData); + assert.match(got, /opencode@\/home\.silent-harbor/); + assert.match(got, /admin@\.silent-harbor/); + assert.match(got, /原样填进 send_mail 的 to/); +}); + +test('不变量:标出「尚未回应」的人', () => { + // mail_count 为 0 就是还没开口的人。服务端只数「作为发件人」的邮件, + // 正是为了让这个判断成立。 + const got = renderParticipants(participantData); + const line = got.split('\n').find(l => l.includes('opencode')); + assert.match(line, /尚未回应/); + // 自己不该被标「尚未回应」—— 自己正在处理这封 + const selfLine = got.split('\n').find(l => l.includes('dsh')); + assert.ok(!selfLine.includes('尚未回应')); + assert.match(selfLine, /就是你/); +}); + +test('renderParticipants 用中文角色标签', () => { + // 模型读到「抄送方」比读到 cc 更容易判对分工。 + const got = renderParticipants(participantData); + assert.match(got, /抄送方/); + assert.match(got, /发件人/); +}); + +test('renderParticipants 无地址时说明原因', () => { + const got = renderParticipants({ + session_alias: '', + participants: [{ name: 'x', roles: ['to'], mail_count: 0, address: '' }], + }); + assert.match(got, /尚未命名/); +}); + +test('renderParticipants 空会话不崩', () => { + assert.match(renderParticipants({ participants: [] }), /还没有参与方/); + assert.match(renderParticipants({}), /还没有参与方/); +}); + +test('renderContacts 未读优先排序', () => { + // 模型问「我还有什么没处理」时,有未读的那些才是答案。 + const got = renderContacts({ + contacts: [ + { address: 'a@.x', unread_count: 0, last_activity: '2026-09-03T02:00:00Z' }, + { address: 'b@.y', unread_count: 3, last_activity: '2026-09-01T00:00:00Z' }, + ], + }); + const lines = got.split('\n').filter(l => l.startsWith('- ')); + assert.match(lines[0], /b@\.y/, '有未读的应排在最前'); + assert.match(lines[0], /3 封未读/); +}); + +test('renderContacts 带出剩余预算', () => { + const got = renderContacts({ + contacts: [{ address: 'a@.x', unread_count: 0, max_rounds: 20, used_rounds: 17 }], + }); + assert.match(got, /剩 3\/20 个来回/); +}); + +test('renderContacts 未命名会话说明只能 reply_to', () => { + const got = renderContacts({ contacts: [{ address: '', unread_count: 1 }] }); + assert.match(got, /reply_to/); +}); + +test('renderContacts 空列表', () => { + assert.match(renderContacts({ contacts: [] }), /还没有任何往来会话/); +}); + +const threadData = { + anchor_mail_id: 'm-2', + total: 3, + hidden: 1, + nodes: [ + { mail_id: 'm-1', from_name: 'admin', to_name: 'dsh', subject: '抄收联调', depth: 0 }, + { mail_id: 'm-2', from_name: 'dsh', to_name: 'opencode', subject: '[联调] 请提供部署现状', depth: 1 }, + { mail_id: 'm-3', from_name: 'opencode', to_name: 'dsh', subject: 'Re: 联调', depth: 2, detached: true, parent_hidden: true }, + ], +}; + +test('renderThread 用缩进表示层级', () => { + const got = renderThread(threadData, 'dsh'); + const lines = got.split('\n'); + const l1 = lines.find(l => l.includes('m-1')); + const l2 = lines.find(l => l.includes('m-2')); + assert.ok(l2.indexOf('- ') > l1.indexOf('- '), '子节点应更深缩进'); +}); + +test('不变量:detached 必须标出来', () => { + // 不标的话模型会以为这是一条独立线索,而它其实挂在一封看不到的邮件下面。 + const got = renderThread(threadData, 'dsh'); + const line = got.split('\n').find(l => l.includes('m-3')); + assert.match(line, /父邮件无权查看/); +}); + +test('renderThread 标出自己发的与当前这封', () => { + const got = renderThread(threadData, 'dsh'); + assert.match(got.split('\n').find(l => l.includes('m-2')), /你发的/); + assert.match(got.split('\n').find(l => l.includes('m-2')), /当前这封/); +}); + +test('renderThread 报告不可见数量', () => { + // 「共 3 封」与实际列出 3 条一致,但另有 1 封无权查看 —— + // 不说的话模型会以为自己看到了全貌。 + assert.match(renderThread(threadData), /另有 1 封无权查看/); +}); + +test('renderThread 有更多时给出 offset', () => { + const got = renderThread({ ...threadData, has_more: true, next_offset: 60 }); + assert.match(got, /offset=60/); +}); + +test('renderThread 空线索不崩', () => { + assert.match(renderThread({ nodes: [] }), /没有可见的邮件/); + assert.match(renderThread({}), /没有可见的邮件/); +}); diff --git a/plugins/opencode-mail-bridge/test/inbox-format.test.mjs b/plugins/opencode-mail-bridge/test/inbox-format.test.mjs index f32ee5d..c496a68 100644 --- a/plugins/opencode-mail-bridge/test/inbox-format.test.mjs +++ b/plugins/opencode-mail-bridge/test/inbox-format.test.mjs @@ -117,6 +117,96 @@ test('附件字段不是数组时忽略', () => { assert.ok(!got.includes('抄送')); }); +// ─── 收件人与身份(只有知道自己是谁才能判定)─── + +test('不变量:收件人要显示出来', () => { + // 不显示的后果:被抄送方不知道主收件人是谁,无法向对方转达或汇报。 + // 线上那封联调邮件要求「由收件人汇报」,而抄送方看不到收件人叫什么。 + const got = renderMail(mail({ to_name: 'dsh', to_workspace: '/home/program/llmsproxy' })); + assert.match(got, /收件人: dsh@\/home\/program\/llmsproxy/); +}); + +test('收件人无工作目录时只显名字', () => { + const got = renderMail(mail({ to_name: 'admin', to_workspace: '' })); + assert.match(got, /收件人: admin$/m); +}); + +test('不传 selfName 时不出现身份行(兼容旧调用)', () => { + const got = renderMail(mail({ to_name: 'dsh' })); + assert.ok(!got.includes('你的身份')); +}); + +test('不变量:区分收件人与抄送方身份', () => { + // 两者职责不同。不区分的话两方都会以为自己是负责人, + // 或者都以为自己只是旁观者。 + const m = mail({ + to_name: 'dsh', + to_workspace: '/home/program/llmsproxy', + cc_list: [{ name: 'opencode', path: '/home', raw: 'opencode@/home.new' }], + }); + assert.match(renderMail(m, 200, 'dsh'), /你的身份: 收件人/); + assert.match(renderMail(m, 200, 'opencode'), /你的身份: 抄送方/); + // 不相关的名字不编造身份 + assert.ok(!renderMail(m, 200, 'someone').includes('你的身份')); +}); + +// ─── 可投递地址(「精准发信」的关键)─── + +const joint = () => mail({ + mail_id: 'm-7', + from_name: 'admin', + to_name: 'dsh', + to_workspace: '/home/program/llmsproxy', + cc_list: [{ name: 'opencode', path: '/home', session: 'new', raw: 'opencode@/home.new' }], + session_alias: 'silent-harbor', +}); + +test('不变量:给出每个参与方的可投递地址', () => { + // 之前模型只能从抄送行里拄一个 `opencode@/home.new`, + // 而那个地址回过去只会再建一条平行会话。 + const got = renderMail(joint(), 200, 'dsh'); + assert.match(got, /可投递地址/); + assert.match(got, /opencode@\/home\.silent-harbor(抄送方)/); + assert.match(got, /admin@\.silent-harbor(发件人)/); +}); + +test('不变量:可投递地址里绝不出现 .new', () => { + // 这是本轮修的根因的直接回归:`.new` 是一次性动作, + // 把它当回信地址会让双方各说各话。 + const got = renderMail(joint(), 200, 'dsh'); + const line = got.split('\n').find(l => l.startsWith('可投递地址')); + assert.ok(line, '应有可投递地址行'); + assert.ok(!line.includes('.new'), `地址行仍含 .new: ${line}`); +}); + +test('可投递地址不列自己', () => { + const got = renderMail(joint(), 200, 'dsh'); + const line = got.split('\n').find(l => l.startsWith('可投递地址')); + assert.ok(!line.includes('dsh@'), `不该把自己当成收件人选项: ${line}`); +}); + +test('同时给出 reply_to 这条更稳的路', () => { + // 地址可能拼错,reply_to 不会 —— 两条路都告诉模型。 + const got = renderMail(joint(), 200, 'dsh'); + assert.match(got, /reply_to=m-7/); +}); + +test('无会话别名时不给地址(宁可不给不可给错)', () => { + // 别名为空时拼不出「投回这条会话」的地址。给一个看着能用 + // 实际指向默认会话的地址,比不给危险。 + const got = renderMail(mail({ + to_name: 'dsh', session_alias: '', + cc_list: [{ name: 'opencode', path: '/home' }], + }), 200, 'dsh'); + assert.ok(!got.includes('可投递地址')); +}); + +test('renderInbox 透传 selfName', () => { + const got = renderInbox([joint()], 200, 'opencode'); + assert.match(got, /你的身份: 抄送方/); + assert.match(got, /dsh@\/home\/program\/llmsproxy\.silent-harbor(收件人)/); +}); + // ─── renderInbox ─── test('renderInbox 空收件箱给明确文案', () => { diff --git a/plugins/opencode-mail-bridge/test/model-scope.test.mjs b/plugins/opencode-mail-bridge/test/model-scope.test.mjs index 58f0980..ae15c29 100644 --- a/plugins/opencode-mail-bridge/test/model-scope.test.mjs +++ b/plugins/opencode-mail-bridge/test/model-scope.test.mjs @@ -12,6 +12,7 @@ import assert from 'node:assert/strict'; import { snapshotOpencodeModels, snapshotDshModels, + snapshotPiModels, modelAttemptOrder, renderFailureReport, MAX_CATALOG, @@ -106,6 +107,46 @@ test('目录截断到 MAX_CATALOG', () => { assert.equal(snapshotDshModels(many).length, MAX_CATALOG); }); +// ─── pi 目录 ─── + +test('pi 目录用 provider + id', () => { + const got = snapshotPiModels([ + { provider: 'llmsproxy', id: 'AUTO', name: 'AUTO' }, + { provider: 'anthropic', id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6' }, + ]); + assert.equal(got.length, 2); + assert.deepEqual(got[1], { + provider: 'anthropic', + model: 'claude-sonnet-4-6', + display_name: 'Claude Sonnet 4.6', + }); +}); + +test('pi 目录跳过缺 provider 或 id 的条目', () => { + const got = snapshotPiModels([ + { provider: '', id: 'x' }, + { provider: 'p' }, + { provider: 'p', id: 'ok' }, + ]); + assert.equal(got.length, 1); + assert.equal(got[0].model, 'ok'); +}); + +test('pi 目录容错:非数组不崩', () => { + assert.deepEqual(snapshotPiModels(undefined), []); + assert.deepEqual(snapshotPiModels(null), []); + assert.deepEqual(snapshotPiModels('oops'), []); +}); + +test('pi 目录同样受 MAX_CATALOG 截断', () => { + // 本机 pi 的完整目录有 1221 个模型(getModels),远超上限。 + // 桥实际上报的是 getAvailable() 的结果(只有带凭证的),但截断仍要生效。 + const many = Array.from({ length: MAX_CATALOG + 50 }, (_, i) => ({ + provider: 'p', id: `m${i}`, name: `M${i}`, + })); + assert.equal(snapshotPiModels(many).length, MAX_CATALOG); +}); + // ─── modelAttemptOrder ─── test('管理员划定范围时按 rank 顺序尝试', () => { diff --git a/plugins/opencode-mail-bridge/test/session-snapshot.test.mjs b/plugins/opencode-mail-bridge/test/session-snapshot.test.mjs index d714441..18f2e09 100644 --- a/plugins/opencode-mail-bridge/test/session-snapshot.test.mjs +++ b/plugins/opencode-mail-bridge/test/session-snapshot.test.mjs @@ -12,6 +12,8 @@ import assert from 'node:assert/strict'; import { snapshotOpencodeSessions, snapshotDshSessions, + snapshotPiSessions, + isUnusableName, slugFromTitle, MAX_REPORTED, } from '../lib/session-snapshot.js'; @@ -226,3 +228,99 @@ test('不同标题不受去重影响', () => { ]); assert.equal(got.length, 2); }); + +// ─── pi:名字来自会话文件的 session_info ─── + +const piSession = (over = {}) => ({ + id: '01a064cc-df57-7b2d-bebb-736776105485', + cwd: '/home/program/agentmail', + name: '重构导入路径', + messageCount: 6, + created: new Date(1788300000000), + modified: new Date(1788344476744), + ...over, +}); + +test('pi 快照取 cwd 与 session_info 名字', () => { + const [got] = snapshotPiSessions([piSession()]); + assert.equal(got.workspace, '/home/program/agentmail'); + assert.equal(got.title, '重构导入路径'); + assert.equal(got.slug, '重构导入路径'); + assert.equal(got.platform_id, '01a064cc-df57-7b2d-bebb-736776105485'); +}); + +test('不变量:pi 无名会话不上报', () => { + // pi 的列表在无名时显示首条消息,而邮件驱动会话的首条消息是桥自己拼的提示词 + // (「你收到一封新邮件(AgentMail)…」)—— 拿它当别名毫无区分度,且条条撞名。 + const got = snapshotPiSessions([ + piSession({ id: 'named', name: '有名字' }), + piSession({ id: 'anon', name: undefined }), + piSession({ id: 'blank', name: '' }), + ]); + assert.deepEqual(got.map(s => s.platform_id), ['named']); +}); + +test('不变量:pi 老会话的空 cwd 照实上报', () => { + // SessionInfo 的注释写明老会话 cwd 是空串。拿桥自己的 cwd 冒充会让 + // 那条会话在补全里挂到一个它其实不属于的工作区下。 + const [got] = snapshotPiSessions([piSession({ cwd: '' })]); + assert.equal(got.workspace, ''); +}); + +test('不变量:pi 的 updated_at 取 modified(文件 mtime)', () => { + const [got] = snapshotPiSessions([piSession()]); + assert.equal(got.updated_at, new Date(1788344476744).toISOString()); +}); + +test('pi 快照按最近活跃排序并对撞名 slug 去重', () => { + const got = snapshotPiSessions([ + piSession({ id: 'old', name: '同一个标题', modified: new Date(1000) }), + piSession({ id: 'new', name: '同一个标题', modified: new Date(9000) }), + ]); + assert.equal(got.length, 1); + assert.equal(got[0].platform_id, 'new'); +}); + +test('pi 快照标记邮件驱动的会话', () => { + const got = snapshotPiSessions( + [piSession({ id: 'mail-one' }), piSession({ id: 'human', name: '人开的' })], + (id) => id === 'mail-one' + ); + assert.equal(got.find(s => s.platform_id === 'mail-one').mail_driven, true); + assert.equal(got.find(s => s.platform_id === 'human').mail_driven, false); +}); + +// ─── isUnusableName:pi-web 标题生成器的思维链泄漏 ─── + +test('不变量:思维链泄漏的标题判废', () => { + // 都是本机 ~/.pi/agent/sessions 里实测捞到的真实 session_info 名字。 + // pi-web 的 cleanSessionName 只做「取首行 + 去引号 + 截 60 字符」,不防这个。 + assert.equal(isUnusableName('The user is asking me to generate a title for a coding-agent'), true); + assert.equal( + isUnusableName('我们只需要生成标题,不包含其他内容。标题应反映请求内容:测试opencode的源。简短:Opencode源测试。或者更简'), + true + ); +}); + +test('isUnusableName 放过正常标题', () => { + // 宁可漏判也不误判:错杀一个好名字会让那条会话失去可寻址的别名。 + assert.equal(isUnusableName('查看Agent接入群聊'), false); + assert.equal(isUnusableName('你应该知道内网拓扑结构吧'), false); + assert.equal(isUnusableName('homeagent-gateway'), false); + assert.equal(isUnusableName('重构导入路径'), false); + assert.equal(isUnusableName('Fix flaky auth test'), false); +}); + +test('isUnusableName 判废空名字', () => { + assert.equal(isUnusableName(''), true); + assert.equal(isUnusableName(' '), true); + assert.equal(isUnusableName(undefined), true); +}); + +test('判废的名字不进快照', () => { + const got = snapshotPiSessions([ + piSession({ id: 'leaked', name: 'The user is asking me to generate a title for a coding-agent' }), + piSession({ id: 'clean', name: '正常标题' }), + ]); + assert.deepEqual(got.map(s => s.platform_id), ['clean']); +}); diff --git a/plugins/pi-mail-bridge/lib/addressing.js b/plugins/pi-mail-bridge/lib/addressing.js new file mode 100644 index 0000000..f80ea11 --- /dev/null +++ b/plugins/pi-mail-bridge/lib/addressing.js @@ -0,0 +1,141 @@ +/** + * 三维寻址的构造与判读 —— 所有平台插件共用。 + * + * 为什么这些函数必须共用、且必须是纯函数: + * + * 地址拼错不会报错。`name@path.session` 的每一段都可以省略,任何组合都能被 + * `ParseAddress` 解析出**某个**结果,于是拼错的代价不是失败而是**投到别处**。 + * 生产上真实发生过两次: + * + * 1. 插件把 `.new` 原样当作回信地址 —— `.new` 是一次性动作,回过去只会 + * 再建一条平行会话,双方从此各说各话。 + * 2. path 为空时朴素拼接得到 `admin.silent-harbor` —— 没有 `@`, + * 整串被当成名字,session 位静默丢失。 + * + * 两次都是「拼字符串」造成的,所以拼地址这件事收进这里,各平台不再自己拼。 + */ + +/** + * 拼一个可寻址的 `name@path.session`。 + * + * **空 path 也必须留下 `@` 与 `.`**:`admin@.silent-harbor` 才解析成 + * name=admin path="" session=silent-harbor。省掉 `@` 得到的 + * `admin.silent-harbor` 会被整串当作名字。 + * + * session 省略时不写那一位(默认会话语义)。 + * + * @param {string} name 收件方名(Agent 名或人类用户名) + * @param {string} [path] 工作目录,可为空 + * @param {string} [session] 会话别名;空则省略该位 + * @returns {string} 地址,name 为空时返回空串 + */ +export function formatAddress(name, path, session) { + const n = String(name ?? '').trim(); + const p = String(path ?? '').trim(); + const s = String(session ?? '').trim(); + if (!n) return ''; + if (!s) return p ? `${n}@${p}` : n; + return `${n}@${p}.${s}`; +} + +/** + * 判断自己在这封邮件里是收件人还是抄送方。 + * + * 为什么需要它:被抄送方与主收件人的**职责不同**。线上那封联调邮件里, + * admin 主发 dsh、抄送 opencode,分工是「dsh 提供源码解读、opencode 提供部署 + * 现状、最后由 dsh 汇报」。收件箱若不区分身份,两方都会以为自己是负责人, + * 或者都以为自己只是旁观者。 + * + * @param {any} mail `/mail/inbox` 返回的一封邮件 + * @param {string} selfName 自己的 Agent 名 + * @returns {'to'|'cc'|'unknown'} + */ +export function roleOf(mail, selfName) { + const self = String(selfName ?? '').trim(); + if (!self) return 'unknown'; + if (mail?.to_name === self) return 'to'; + if (Array.isArray(mail?.cc_list) && mail.cc_list.some(c => c?.name === self)) { + return 'cc'; + } + return 'unknown'; +} + +/** + * 给出「把回信发回这条会话」的地址。 + * + * 发件人一侧**不带 path**:Agent 回信时 `from_workspace` 存的是 Agent 名而不是 + * 路径(历史遗留),拿它拼会得到 `dsh@dsh.alias` 这种投不出去的东西。 + * 人类发件人本来就没有工作目录。 + * + * 别名为空时退回 `name`(默认会话)而不是编一个 —— 但注意这与「投回同一条会话」 + * 不等价,默认会话是该 name 当前最活跃的那条。调用方要区分时看返回值有没有 `.`。 + * + * @param {any} mail 一封邮件 + * @param {string} [alias] 会话别名,缺省取 mail.session_alias + * @returns {string} + */ +export function replyAddressFor(mail, alias) { + const a = alias ?? mail?.session_alias ?? ''; + return formatAddress(mail?.from_name, '', a); +} + +/** + * 给出自己在这条会话里的地址,供转发说明或向第三方引用时使用。 + * + * 用 `to_workspace`(自己那个地址的 path 位)而不是发件人的: + * 抄送给 `opencode@/a` 与主发给 `dsh@/b` 是两个不同的工作区。 + * + * @param {any} mail 一封邮件 + * @param {string} selfName 自己的 Agent 名 + * @param {string} [alias] 会话别名,缺省取 mail.session_alias + * @returns {string} + */ +export function selfAddressFor(mail, selfName, alias) { + const a = alias ?? mail?.session_alias ?? ''; + // 抄送方拿到的 to_workspace 是主收件人的,自己的 path 在 cc_list 里。 + // 不取对的那个会让「我是谁」这句话指向别人的工作目录。 + let path = mail?.to_workspace ?? ''; + if (mail?.to_name !== selfName && Array.isArray(mail?.cc_list)) { + const mine = mail.cc_list.find(c => c?.name === selfName); + if (mine) path = mine.path ?? ''; + } + return formatAddress(selfName, path, a); +} + +/** + * 列出这封邮件的全部参与方及各自可投递的地址。 + * + * 这是「回给抄收方」缺的那块信息:知道有谁,**以及用什么地址找到他**。 + * 抄送方的 path 取它自己那个地址的 path 位。 + * + * 自己会被标 `is_self`,而不是从列表里剔掉 —— 剔掉的话模型无法确认 + * 「这封信是不是也发给了我」,也就无法判断自己是不是该回。 + * + * @param {any} mail 一封邮件 + * @param {string} [selfName] 自己的名字,用于标记 is_self + * @param {string} [alias] 会话别名,缺省取 mail.session_alias + * @returns {{role: string, name: string, path: string, address: string, is_self: boolean}[]} + */ +export function participantsOfMail(mail, selfName, alias) { + const a = alias ?? mail?.session_alias ?? ''; + const self = String(selfName ?? '').trim(); + const out = []; + const add = (role, name, path) => { + const n = String(name ?? '').trim(); + if (!n) return; + out.push({ + role, + name: n, + path: String(path ?? ''), + address: formatAddress(n, path, a), + is_self: !!self && n === self, + }); + }; + // 发件人一侧 path 留空,理由同 replyAddressFor + add('from', mail?.from_name, ''); + add('to', mail?.to_name, mail?.to_workspace); + if (Array.isArray(mail?.cc_list)) { + for (const c of mail.cc_list) add('cc', c?.name, c?.path); + } + return out; +} diff --git a/plugins/pi-mail-bridge/lib/catchup.js b/plugins/pi-mail-bridge/lib/catchup.js new file mode 100644 index 0000000..a0a4a6d --- /dev/null +++ b/plugins/pi-mail-bridge/lib/catchup.js @@ -0,0 +1,74 @@ +/** + * 启动补拉:把插件离线期间到的邮件变成与 SSE 事件同形的投递任务。 + * + * 为什么需要它:**SSE 只推连上之后的事件**。插件重启前发来的邮件不会再推一次, + * 心跳响应的 `pending_mails` 是唯一线索。不补拉的后果是那封邮件永远躺在 + * 收件箱里,而发件人以为 Agent 收到了 —— 这比明确的失败更难排查。 + * + * 两个平台共用,必须逐字节相同(deploy/check-shared-libs.sh 校验)。 + */ + +/** + * 一次补拉最多处理几封。 + * + * 上限存在的理由:每封都要起一轮模型。攒了 80 封的时候一次性全放出去, + * 等于对上游打 80 个并发请求,且最后那几封要等前面全部跑完。 + * 超出的部分留在收件箱里,下次重启或人工触发时再处理。 + */ +export const MAX_CATCHUP = 5; + +/** + * 把收件箱里的一封邮件转成 SSE `new_mail` 那个形状。 + * + * 补拉与 SSE 走同一条投递路径(deliverMail),因此形状必须一致 —— + * 两条路径各写一遍投递逻辑的话,某一条上的修复会漏掉另一条。 + * + * @param {any} mail `/mail/inbox` 返回的一行 + * @returns {{mail_id: string, session_id: string, from_name: string, + * subject: string, mail_type: string, role: string, + * to_workspace: string, catchup: true}} + */ +export function mailToEvent(mail) { + return { + mail_id: mail?.mail_id || '', + session_id: mail?.session_id || '', + from_name: mail?.from_name || '', + subject: mail?.subject || '', + mail_type: mail?.mail_type || 'normal', + role: 'to', + to_workspace: mail?.to_workspace || '', + // 标记来源,投递侧可据此决定是否在提示词里说明「这是积压的邮件」 + catchup: true, + }; +} + +/** + * 从收件箱挑出该补投的邮件。 + * + * @param {any[]} mails `/mail/inbox?status=unread` 的结果 + * @param {Set} seen 已经通过 SSE 投过的 mail_id(避免重复投递) + * @param {number} [max] 上限,默认 MAX_CATCHUP + * @returns {any[]} 与 SSE 事件同形的投递任务,按时间正序(老的先处理) + */ +export function selectCatchup(mails, seen, max = MAX_CATCHUP) { + if (!Array.isArray(mails) || mails.length === 0) return []; + + const picked = []; + for (const m of mails) { + const id = m?.mail_id; + if (!id) continue; + // 心跳与 SSE 建连之间有个窗口:那期间到的邮件既在 pending_mails 里、 + // 也会被 SSE 推一次。不去重就会投两遍,模型回两封信。 + if (seen && seen.has(id)) continue; + // permission 类邮件不补投:它是给人看的询问,Agent 侧没有可恢复的上下文 + // (原来的工具调用早随进程一起没了),投过去只会让模型困惑。 + if (m?.mail_type && m.mail_type !== 'normal') continue; + picked.push(m); + } + + // 收件箱按时间倒序返回,补投要按正序 —— 先来的先处理, + // 否则同一会话里的多封邮件会被倒着塞进去,上下文顺序是乱的。 + picked.reverse(); + + return picked.slice(0, Math.max(0, max)).map(mailToEvent); +} diff --git a/plugins/pi-mail-bridge/lib/discovery.js b/plugins/pi-mail-bridge/lib/discovery.js new file mode 100644 index 0000000..7b81d57 --- /dev/null +++ b/plugins/pi-mail-bridge/lib/discovery.js @@ -0,0 +1,237 @@ +/** + * 寻址发现工具 —— 所有平台插件共用的**纯逻辑**部分。 + * + * 三个 Agent 侧只读端点(`/agent/contacts`、`/agent/contacts/suggest`、 + * `/agent/sessions/{id}/participants`)的返回值怎么渲染给模型看,与平台 SDK 无关, + * 所以收进这里。各平台只负责把自己的工具定义壳套上去。 + * + * # 这一组端点解决的问题 + * + * 在它们存在之前,`send_mail` 的 `to` 是一个**只能靠记忆拼写的自由文本字段**。 + * 人类侧从来不是这样:三段式输入框逐段查候选,name / path / session 每一段都从 + * 活数据里选。Agent 只能猜,而猜错不会报错 —— 生产上 dsh 猜了 + * `opencode@/home`,地址解析通过、投递成功,但那不是 opencode 的工作目录, + * 那个错误路径静默变成了新会话的 workspace。 + * + * # 渲染的取舍 + * + * 一律输出**可直接粘进 `to` 的完整地址**,而不是把三段分开列。模型看到 + * `opencode@/home.silent-harbor` 会整串复制;看到 `name=opencode path=/home + * session=silent-harbor` 则要自己拼,而自己拼就是问题的来源。 + */ + +/** + * 渲染候选收件人清单(`kind: "name"`)。 + * + * 只给名字,不给地址:此时还不知道 path 与 session,硬拼出来的 + * 裸名字地址会投到「默认会话」—— 那不一定是调用方想要的那条。 + * 明确提示下一步该查什么,模型才会继续往下走而不是就地拼一个。 + * + * @param {string[]} names + * @returns {string} + */ +export function renderNameSuggestions(names) { + const list = Array.isArray(names) ? names.filter(Boolean) : []; + if (list.length === 0) return '当前没有可投递的收件人。'; + return [ + `可投递的收件人(${list.length} 个):`, + list.map(n => `- ${n}`).join('\n'), + '', + '下一步:用 suggest_address 带上 name 查它可用的工作目录(path 位)。', + ].join('\n'); +} + +/** + * 渲染工作目录候选(`kind: "path"`)。 + * + * 空列表要说清「这不代表不能发」:path 位允许为空(人类用户没有工作目录), + * 不解释的话模型会卡在这一步,或者编一个路径出来。 + * + * @param {string[]} paths + * @param {string} name 正在查的收件人名,用于拼下一步的提示 + * @returns {string} + */ +export function renderPathSuggestions(paths, name) { + const list = Array.isArray(paths) ? paths.filter(Boolean) : []; + if (list.length === 0) { + return [ + `${name} 没有记录在案的工作目录。`, + '这不代表不能给它发信 —— path 位可以留空(人类用户就没有工作目录)。', + `直接用 suggest_address(name="${name}", path="") 查它的会话,或直接发给 ${name}。`, + ].join('\n'); + } + return [ + `${name} 用过的工作目录(按最近使用排序):`, + list.map(p => `- ${p}`).join('\n'), + '', + `下一步:用 suggest_address(name="${name}", path="<上面某一个>") 查该目录下可续谈的会话。`, + ].join('\n'); +} + +/** + * 渲染会话候选(`kind: "session"`)。 + * + * **`addresses` 与 `suggestions` 同序**,服务端保证。这里优先用 `addresses`: + * 那是服务端拼好的完整地址,插件不必自己拼(自己拼过一次,拼错了)。 + * + * `new` 永远在最后且带一句警告:它不是一条已存在的会话。排在前面会让模型 + * 在想续谈时顺手开出一条新线索 —— 生产上已经发生过。 + * + * @param {object} data `/agent/contacts/suggest` 的返回体 + * @param {string} name + * @param {string} path + * @returns {string} + */ +export function renderSessionSuggestions(data, name, path) { + const aliases = Array.isArray(data?.suggestions) ? data.suggestions : []; + const addresses = Array.isArray(data?.addresses) ? data.addresses : []; + const candidates = Array.isArray(data?.candidates) ? data.candidates : []; + + // 只有 new 一项 = 这个 name@path 下还没有任何可续谈的会话 + const existing = aliases.filter(a => a !== 'new'); + if (existing.length === 0) { + return [ + `${name}${path ? '@' + path : ''} 下还没有可续谈的会话。`, + `要开一条新线索用 ${addressAt(addresses, aliases, 'new') || `${name}@${path}.new`},`, + '并在 send_mail 里传 session_alias 给它命名,之后就能按名字续谈。', + ].join('\n'); + } + + const lines = [`${name}${path ? '@' + path : ''} 下可续谈的会话:`]; + for (let i = 0; i < aliases.length; i++) { + const alias = aliases[i]; + const addr = addresses[i] || ''; + const c = candidates[i] || {}; + if (alias === 'new') continue; // new 单独放最后 + const bits = []; + if (c.title) bits.push(c.title); + if (typeof c.unread === 'number' && c.unread > 0) bits.push(`${c.unread} 封未读`); + if (c.source === 'platform') bits.push('平台侧会话'); + lines.push(`- ${addr || alias}${bits.length ? ` (${bits.join(',')})` : ''}`); + } + lines.push(''); + lines.push('把上面某个地址原样填进 send_mail 的 to 即可投进那条会话。'); + const newAddr = addressAt(addresses, aliases, 'new'); + if (newAddr) { + lines.push(`若确实要开一条**新**线索(而不是接着上面某条谈)才用 ${newAddr}。`); + } + return lines.join('\n'); +} + +/** 按别名在同序的 addresses 里取地址。 */ +function addressAt(addresses, aliases, alias) { + const i = aliases.indexOf(alias); + return i >= 0 ? addresses[i] || '' : ''; +} + +/** + * 渲染会话参与方清单。 + * + * 这是「发送给抄收方 / 转发方」缺的最后一块:知道有谁、**用什么地址找到他**、 + * 以及谁还没开口。`mail_count` 为 0 的那个就是还没回应的人 —— 服务端只数 + * 「作为发件人」的邮件,正是为了让这个判断成立。 + * + * @param {object} data `/agent/sessions/{id}/participants` 的返回体 + * @returns {string} + */ +export function renderParticipants(data) { + const parts = Array.isArray(data?.participants) ? data.participants : []; + if (parts.length === 0) return '该会话还没有参与方(可能是一条刚建立的空会话)。'; + + const alias = data?.session_alias || ''; + const lines = [`会话 #${alias || '未命名'} 的参与方:`]; + for (const p of parts) { + const tags = []; + if (p.is_self) tags.push('就是你'); + if (Array.isArray(p.roles) && p.roles.length) { + tags.push(p.roles.map(roleLabel).join('/')); + } + if (p.mail_count === 0 && !p.is_self) tags.push('尚未回应'); + const addr = p.address ? p.address : '(无可投递地址:该会话尚未命名)'; + lines.push(`- ${p.name} ${addr}${tags.length ? ` [${tags.join(',')}]` : ''}`); + } + lines.push(''); + lines.push('要联系其中某一方,把它的地址原样填进 send_mail 的 to。'); + return lines.join('\n'); +} + +/** + * 渲染联系人清单(本 Agent 参与过的全部会话)。 + * + * 按未读优先、其次最近活跃排序:模型问「我还有什么没处理」时, + * 有未读的那些才是答案。 + * + * @param {object} data `/agent/contacts` 的返回体 + * @param {number} limit 最多列出多少条 + * @returns {string} + */ +export function renderContacts(data, limit = 20) { + const list = Array.isArray(data?.contacts) ? data.contacts.slice() : []; + if (list.length === 0) return '还没有任何往来会话。'; + + list.sort((a, b) => { + const ua = a?.unread_count || 0; + const ub = b?.unread_count || 0; + if (ua !== ub) return ub - ua; + return String(b?.last_activity || '').localeCompare(String(a?.last_activity || '')); + }); + + const shown = list.slice(0, limit); + const lines = [`往来会话(共 ${list.length} 条${list.length > shown.length ? `,列出前 ${shown.length}` : ''}):`]; + for (const c of shown) { + const bits = []; + if (c.unread_count > 0) bits.push(`${c.unread_count} 封未读`); + if (c.subject) bits.push(c.subject); + if (c.max_rounds > 0) { + const left = Math.max(0, c.max_rounds - (c.used_rounds || 0)); + bits.push(`剩 ${left}/${c.max_rounds} 个来回`); + } + const addr = c.address || '(未命名会话,只能用 reply_to 续谈)'; + lines.push(`- ${addr}${bits.length ? ` (${bits.join(',')})` : ''}`); + } + return lines.join('\n'); +} + +/** 角色的中文标签。模型读到「抄送方」比读到 cc 更容易判对分工。 */ +function roleLabel(role) { + switch (role) { + case 'from': return '发件人'; + case 'to': return '收件人'; + case 'cc': return '抄送方'; + default: return String(role); + } +} + +/** + * 渲染对话树,回答「谁已经回了、谁还没回」。 + * + * 缩进表示层级。**detached 必须标出来**:那表示父邮件不在本次结果里 + * (无权查看或尚未加载),不标的话模型会以为这是一条独立线索。 + * + * @param {object} data `/agent/mail/{id}/thread` 的返回体 + * @param {string} [selfName] 自己的名字,用于标出哪几封是自己发的 + * @returns {string} + */ +export function renderThread(data, selfName = '') { + const nodes = Array.isArray(data?.nodes) ? data.nodes : []; + if (nodes.length === 0) return '这条线索上没有可见的邮件。'; + + const lines = [`线索共 ${data?.total ?? nodes.length} 封${data?.hidden ? `(另有 ${data.hidden} 封无权查看)` : ''}:`]; + for (const n of nodes) { + const depth = typeof n?.depth === 'number' ? Math.max(0, n.depth) : 0; + const indent = ' '.repeat(Math.min(depth, 8)); + const marks = []; + if (selfName && n?.from_name === selfName) marks.push('你发的'); + if (n?.mail_id === data?.anchor_mail_id) marks.push('当前这封'); + if (n?.detached) marks.push(n.parent_hidden ? '父邮件无权查看' : '父邮件尚未加载'); + lines.push( + `${indent}- ${n?.from_name ?? '?'} → ${n?.to_name ?? '?'}: ${n?.subject ?? '(无主题)'}` + + ` [${n?.mail_id ?? '?'}]${marks.length ? ` (${marks.join(',')})` : ''}` + ); + } + if (data?.has_more) { + lines.push(''); + lines.push(`还有更多,用 offset=${data.next_offset} 继续取。`); + } + return lines.join('\n'); +} diff --git a/plugins/pi-mail-bridge/lib/inbox-format.js b/plugins/pi-mail-bridge/lib/inbox-format.js new file mode 100644 index 0000000..8f2f54b --- /dev/null +++ b/plugins/pi-mail-bridge/lib/inbox-format.js @@ -0,0 +1,144 @@ +/** + * 收件箱渲染与已读策略 —— 所有平台插件共用。 + * + * 提到 lib/ 是因为这几条规则每一条都对应过一次真实的错误行为,而它们与 + * 平台 SDK 无关:无论 opencode 的 zod 工具还是 DSH 的 defineTool, + * 渲染出的文本与标记已读的时机都该一致。新接一个平台时直接复用这里。 + */ + +import { roleOf, replyAddressFor, participantsOfMail } from './addressing.js'; + +/** 人类可读的字节数,用于附件清单展示。 */ +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 正文截断长度 + * @param {string} [selfName] 自己的 Agent 名。给了就能判定「我是收件人还是抄送方」 + * 并给出参与方地址;不给则退化成旧行为(兼容未传该参数的调用方)。 + * @returns {string} + */ +export function renderMail(m, bodyLimit = 200, selfName = '') { + const alias = m?.session_alias || ''; + const lines = [ + `[${m?.status ?? 'unknown'}] ${m?.from_name ?? 'unknown'}: ${m?.subject ?? '(无主题)'}`, + `邮件 ID: ${m?.mail_id ?? 'unknown'}`, + `会话: #${alias || '未命名'}`, + ]; + + // 收件人必须显示。不显示的后果:被抄送方既不知道主收件人是谁, + // 也无法向对方转达或汇报 —— 线上那封联调邮件要求「由收件人汇报」, + // 抄送方却看不到收件人叫什么。 + if (m?.to_name) { + let toLine = `收件人: ${m.to_name}`; + if (m?.to_workspace) toLine += `@${m.to_workspace}`; + lines.push(toLine); + } + + // 抄送要显示:一封邮件为什么同时到了几个人手上,只有抄送能解释。 + // 不显示的话模型会以为这是私下发给它一个人的,回信时漏掉其他参与方。 + if (Array.isArray(m?.cc_list) && m.cc_list.length > 0) { + lines.push('抄送: ' + m.cc_list.map(c => c?.raw || c?.name || '?').join('、')); + } + + // 自己的身份。抄送方与主收件人的职责不同,不区分的话两方都会 + // 以为自己是负责人,或者都以为自己只是旁观者。 + if (selfName) { + const role = roleOf(m, selfName); + if (role === 'to') lines.push('你的身份: 收件人(主办)'); + else if (role === 'cc') lines.push('你的身份: 抄送方(配合)'); + } + + // **必须给出 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)}`); + + // 可投递地址放在最后,紧贴正文 —— 模型读完内容紧接着就要决定发给谁。 + // + // 这一段是「精准发信」的关键:之前模型只能从抄送行里拄一个 + // `opencode@/home.new` 拄过去,而 `.new` 是一次性的,回过去只会再建一条 + // 平行会话。这里给的地址全部已经把 session 位换成真实别名。 + if (selfName && alias) { + const parts = participantsOfMail(m, selfName, alias); + const others = parts.filter(p => !p.is_self && p.address); + if (others.length > 0) { + lines.push( + '可投递地址: ' + + others.map(p => `${p.address}(${roleLabel(p.role)})`).join('、') + ); + lines.push(`直接回信给发件人用 ${replyAddressFor(m, alias)},或传 reply_to=${m?.mail_id ?? ''}。`); + } + } + + return lines.join('\n'); +} + +/** 角色的中文标签。模型读到「抄送方」比读到 cc 更容易判对分工。 */ +function roleLabel(role) { + switch (role) { + case 'from': return '发件人'; + case 'to': return '收件人'; + case 'cc': return '抄送方'; + default: return role; + } +} + +/** + * 渲染整个收件箱。 + * @param {any[]} mails + * @param {number} bodyLimit + * @param {string} [selfName] 自己的 Agent 名,透传给 renderMail + * @returns {string} + */ +export function renderInbox(mails, bodyLimit = 200, selfName = '') { + const list = Array.isArray(mails) ? mails : []; + if (list.length === 0) return '收件箱为空。'; + return list.map(m => renderMail(m, bodyLimit, selfName)).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/pi-mail-bridge/lib/model-scope.js b/plugins/pi-mail-bridge/lib/model-scope.js new file mode 100644 index 0000000..74fbe11 --- /dev/null +++ b/plugins/pi-mail-bridge/lib/model-scope.js @@ -0,0 +1,169 @@ +/** + * 平台模型目录的整理与降级选择 —— 所有平台插件共用。 + * + * 两个职责: + * 1. 把各平台的 provider/model 结构整理成统一的上报格式(随心跳发给 Gateway) + * 2. 按管理员划定的范围决定「先试哪个、再试哪个」 + * + * 为什么随心跳上报而不是只在注册时报一次:模型清单会在运行中变(换 provider + * 配置、上游上下线、换 API key)。只在注册时报的话目录会静静变陈,而管理员 + * 在配置页上看到的是上次重启时的快照 —— 选中一个平台已经调不到的模型, + * 失败要到真发邮件时才暴露。 + */ + +/** 单次上报的模型数上限。与服务端的 maxCatalogModels 一致。 */ +export const MAX_CATALOG = 300; + +/** + * 把 opencode 的 `/config/providers` 响应整理成上报格式。 + * + * @param {any} config `client.config.providers()` 的结果 + * @returns {object[]} `[{ provider, model, display_name }]` + */ +export function snapshotOpencodeModels(config) { + const providers = Array.isArray(config?.providers) ? config.providers : []; + const out = []; + for (const p of providers) { + const provider = typeof p?.id === 'string' ? p.id : ''; + if (!provider) continue; + // models 是对象而非数组:键是 model id,值是元数据 + const models = p?.models && typeof p.models === 'object' ? p.models : {}; + for (const [id, meta] of Object.entries(models)) { + if (!id) continue; + out.push({ + provider, + model: id, + display_name: typeof meta?.name === 'string' ? meta.name : '', + }); + } + } + return dedupeAndCap(out); +} + +/** + * 把 DSH 的 provider/model 列表整理成上报格式。 + * + * DSH 侧要先 `ctx.llm.listProviders()` 再对每个 provider `listModels()`, + * 因此这里收的是已经拍平的结果。 + * + * @param {any[]} entries `[{ provider, id, name }]` + * @returns {object[]} + */ +export function snapshotDshModels(entries) { + const list = Array.isArray(entries) ? entries : []; + const out = []; + for (const m of list) { + const provider = typeof m?.provider === 'string' ? m.provider : ''; + const model = typeof m?.id === 'string' ? m.id : ''; + if (!provider || !model) continue; + out.push({ + provider, + model, + display_name: typeof m?.name === 'string' ? m.name : '', + }); + } + return dedupeAndCap(out); +} + +/** + * 把 pi 的模型列表整理成上报格式。 + * + * pi 侧的取法是 `await modelRuntime.getAvailable()` —— **不是** `getModels()`。 + * 两者差别很大:本机实测目录里有 1221 个模型,而带凭证、真能调起来的只有 1 个。 + * 上报 `getModels()` 的结果会让管理员在配置页选中一个注定失败的路由, + * 而失败要到真发邮件时才暴露(模型目录上报的全部意义就是避免这件事)。 + * + * pi 的 Model 对象上,provider 在 `provider` 字段、模型 id 在 `id` 字段, + * 展示名在 `name`。形状与 DSH 侧一致,但语义来源不同,因此单独一个函数 + * ——照抄 snapshotDshModels 会让「必须用 getAvailable」这条约束无处记录。 + * + * @param {any[]} models `await modelRuntime.getAvailable()` 的结果 + * @returns {object[]} + */ +export function snapshotPiModels(models) { + const list = Array.isArray(models) ? models : []; + const out = []; + for (const m of list) { + const provider = typeof m?.provider === 'string' ? m.provider : ''; + const model = typeof m?.id === 'string' ? m.id : ''; + if (!provider || !model) continue; + out.push({ + provider, + model, + display_name: typeof m?.name === 'string' ? m.name : '', + }); + } + return dedupeAndCap(out); +} + +/** + * 决定这一轮按什么顺序尝试模型。 + * + * 三种情形: + * + * 1. **管理员划定了范围** → 按 rank 顺序(服务端已排好),逐个降级 + * 2. **没划定范围**(`allowed` 为空)→ 返回 `[undefined]`, + * 表示「用平台自己的默认模型试一次」。**不是**空数组: + * 空数组会让调用方一次都不试,等于让 Agent 彻底哑掉, + * 而「管理员没配」的正确含义是不限定。 + * 3. **插件配了 `AGENTMAIL_REPLY_PROVIDER`/`MODEL`** → 那是部署方的显式指定, + * 优先于「平台默认」,但**不优先于管理员划定的范围**: + * 范围是运行时可改的策略,环境变量是部署时的兜底。 + * + * @param {readonly {provider: string, model: string}[]} allowed 管理员划定的范围(按 rank) + * @param {{provider?: string, model?: string}|undefined} envDefault 环境变量指定的模型 + * @returns {(({provider: string, model: string})|undefined)[]} 依次尝试的候选; + * `undefined` 表示这一次不指定模型、交给平台 + */ +export function modelAttemptOrder(allowed, envDefault) { + const list = Array.isArray(allowed) ? allowed.filter(m => m?.provider && m?.model) : []; + if (list.length > 0) return list.map(m => ({ provider: m.provider, model: m.model })); + if (envDefault?.provider && envDefault?.model) { + return [{ provider: envDefault.provider, model: envDefault.model }]; + } + return [undefined]; +} + +/** + * 把多次尝试的失败原因整理成一封邮件正文。 + * + * 全部失败时必须发这封信:模型一次都没跑起来,会话里没有任何 assistant 消息, + * 自动转发因此什么也不会发 —— 发件人只会看到邮件发出去后再无音讯。 + * + * @param {{provider?: string, model?: string, error: string}[]} failures 每次尝试的失败 + * @param {string} subject 原邮件主题 + * @returns {string} Markdown 正文 + */ +export function renderFailureReport(failures, subject) { + const list = Array.isArray(failures) ? failures : []; + const lines = [ + `本次未能处理「${subject || '(无主题)'}」:划定范围内的模型全部调用失败。`, + '', + `已尝试 ${list.length} 个:`, + '', + ]; + list.forEach((f, i) => { + const route = f?.provider && f?.model ? `${f.provider}/${f.model}` : '(平台默认模型)'; + lines.push(`${i + 1}. **${route}**`); + // 缩进四格让报错原文成为代码块,避免其中的 Markdown 字符影响排版 + lines.push(` ${String(f?.error ?? '未知错误').replace(/\n/g, '\n ')}`); + }); + lines.push(''); + lines.push('可能的原因:模型已下线、API key 失效、上游限流,或该 provider 未在平台侧配置。'); + lines.push('调整可用模型范围:配置页 → Agent 模型范围。'); + return lines.join('\n'); +} + +/** 去重(provider/model 组合)并截断。 */ +function dedupeAndCap(list) { + const seen = new Set(); + const out = []; + for (const m of list) { + const key = `${m.provider}/${m.model}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(m); + if (out.length >= MAX_CATALOG) break; + } + return out; +} diff --git a/plugins/pi-mail-bridge/lib/relay-dedup.js b/plugins/pi-mail-bridge/lib/relay-dedup.js new file mode 100644 index 0000000..bccb65a --- /dev/null +++ b/plugins/pi-mail-bridge/lib/relay-dedup.js @@ -0,0 +1,58 @@ +// 自动转发去重的纯逻辑。 +// +// 单独一个文件而不是放在 index.js 里导出:**opencode 会把插件入口模块的 +// 每一个导出都当成插件工厂**(`Object.values(mod)` 逐个检查是不是函数), +// 多导出一个 Map 就会让整个插件加载失败: +// ERROR message="failed to load plugin" error="Plugin export is not a function" +// 实测踩过 —— 插件静默不加载,邮件全都投不进去。 +// 因此入口文件只能 `export default`,其余东西一律搁在这里。 + +/** 取三维地址的名字段:admin@root.alias -> admin */ +export function addrName(addr) { + return String(addr || "").split("@")[0].trim(); +} + +/** + * 本轮内模型**自己调 send_mail** 发出去的信(按 opencode 会话)。 + * + * session.idle 的自动转发要据此让位:模型已经亲手回过这条线索了, + * 再把它最后那段话转一遍,收件箱里就是两封内容几乎一样的邮件。 + * 生产实测过这个后果 —— 同一轮里 311 字节和 342 字节各一封, + * 说的是同一件事,其中带附件的那封才是模型真正想发的。 + * + * 为什么不靠 relay_key 幂等:那个键是 assistant message id, + * 保证的是「同一条消息不被转两次」,管不了「模型已经自己发过了」。 + * + * 窗口是「一轮」:deliverMail 投递新邮件时清空(新一轮开始), + * relaySummary 用完即清。 + */ +export const explicitSends = new Map(); // opencode session id -> { names:Set, replyTos:Set } + +/** 记下模型这一轮主动发了信,给谁、回的哪封。 */ +export function noteExplicitSend(sessionID, to, replyTo) { + if (!sessionID) return; + let rec = explicitSends.get(sessionID); + if (!rec) { + rec = { names: new Set(), replyTos: new Set() }; + explicitSends.set(sessionID, rec); + } + const name = addrName(to); + if (name) rec.names.add(name); + if (replyTo) rec.replyTos.add(String(replyTo)); +} + +/** + * 本轮是否该跳过自动转发。 + * + * @param sent 该会话本轮的主动发信记录 { names:Set, replyTos:Set },可为空 + * @param replyTo 自动转发本来要发给谁(三维地址或纯名字) + * @param mailID 自动转发本来要 reply_to 的邮件 id + */ +export function shouldSkipAutoRelay(sent, replyTo, mailID) { + if (!sent) return false; + // 收件人同名:模型已经跟这个人说过了 + if (sent.names.has(addrName(replyTo))) return true; + // 同一封信已被回过:即使收件人写法不同(别名/路径不同)也算回过 + if (mailID && sent.replyTos.has(String(mailID))) return true; + return false; +} diff --git a/plugins/pi-mail-bridge/lib/session-snapshot.js b/plugins/pi-mail-bridge/lib/session-snapshot.js new file mode 100644 index 0000000..34e38a7 --- /dev/null +++ b/plugins/pi-mail-bridge/lib/session-snapshot.js @@ -0,0 +1,234 @@ +/** + * 平台会话快照:把 harness 自己的会话列表整理成 Gateway 的上报格式。 + * + * 为什么需要它:写信时想续谈某条会话,得先知道那个工作区下有哪些会话可续。 + * Gateway 只看得见邮件驱动的那部分 —— 人直接在 opencode/DSH 界面上开的会话 + * 它一无所知,于是那些会话的别名在补全里根本不出现,无法选择。 + * + * 为什么是插件上报而不是 Gateway 拉取:当前架构是单向的(Agent 持密钥主动连 + * Gateway,Gateway 从不外呼)。反向拉取需要 Gateway 保存各平台的地址与凭证, + * 那是另一套信任模型。 + */ + +/** 单次上报的会话数上限。与服务端的 maxPlatformSessions 一致。 */ +export const MAX_REPORTED = 200; + +/** + * 把 opencode 的 session 列表整理成上报格式。 + * + * @param {any[]} sessions client.session.list() 的结果 + * @param {(id: string) => boolean} isMailDriven 该平台会话是否由邮件驱动 + * @returns {object[]} 按最近活跃排序、截断到 MAX_REPORTED 的上报项 + */ +export function snapshotOpencodeSessions(sessions, isMailDriven = () => false) { + const list = Array.isArray(sessions) ? sessions : []; + const out = []; + for (const s of list) { + const id = typeof s?.id === 'string' ? s.id : ''; + if (!id) continue; + // 没有 slug 的会话不报:slug 是填进 session 位的值, + // 没有它这一项在补全里点下去只能得到一个空的 session 段。 + const slug = typeof s?.slug === 'string' ? s.slug : ''; + if (!slug) continue; + out.push({ + platform_id: id, + // opencode 的工作目录在 directory 上(path 是项目内的子路径,不是 cwd) + workspace: typeof s?.directory === 'string' ? s.directory : '', + slug, + title: typeof s?.title === 'string' ? s.title : '', + mail_driven: Boolean(isMailDriven(id)), + updated_at: toISO(s?.time?.updated ?? s?.time?.created), + }); + } + return sortAndCap(out); +} + +/** + * 把 DSH 的 agent 列表整理成上报格式。 + * + * DSH 没有 opencode 那样的 slug,别名由**模型生成的会话标题**派生 + * (与「别名复用平台命名」的既定决策一致)。占位标题不派生别名: + * DSH 在模型生成真标题前会先落一个 fallback 标题,内容是用户第一句话的截断, + * 而那句话是插件自己拼的提示词。 + * + * @param {any[]} entries [{ id, cwd, title, updatedAt }] + * @param {(id: string) => boolean} isMailDriven + * @returns {object[]} + */ +export function snapshotDshSessions(entries, isMailDriven = () => false) { + const list = Array.isArray(entries) ? entries : []; + const out = []; + for (const e of list) { + const id = typeof e?.id === 'string' ? e.id : ''; + if (!id) continue; + // subagent 子会话不上报:它们是父 agent 内部的工作单元,人往里发邮件毫无意义。 + // 而且它们的标题就是派活时的提示词前缀(实测九条会话都叫 + // "You are auditing ONE file"),派生出的 slug 全都撞名、毫无区分度。 + if (isSubagent(e)) continue; + const title = typeof e?.title === 'string' ? e.title : ''; + const slug = slugFromTitle(title); + if (!slug) continue; + out.push({ + platform_id: id, + workspace: typeof e?.cwd === 'string' ? e.cwd : '', + slug, + title, + mail_driven: Boolean(isMailDriven(id)), + updated_at: toISO(e?.updatedAt), + }); + } + // slug 撞名的只留最近那条:别名是**寻址**用的, + // 同一个 slug 对应多条会话时服务端只能取其中一条(updated_at DESC LIMIT 1), + // 上报一堆同名项只会让人在补全列表里看到几个一模一样、点哪个都不确定的候选。 + return dedupeBySlug(sortAndCap(out)); +} + +/** + * 把 pi 的 `SessionManager.list()/listAll()` 结果整理成上报格式。 + * + * pi 的会话名字来自会话文件里最后一条 `session_info` 条目: + * - pi-web 在一条会话的首次 prompt 时用模型生成一个 2-6 词的标题 + * - TUI 的 `/name`、启动参数 `--name`、`/resume` 里的改名也写同一处 + * - **pi 内核(SDK)自己不生成**:桥用 createAgentSession 起的会话没有名字, + * 要由桥按「Gateway 定稿的别名」回写(见 index 的 syncNaming) + * + * 与另两个平台的差异:pi 的 SessionInfo 里**没有 subagent 标记**。 + * pi-subagents 把子会话写在自定义 sessionDir(run 根目录)下,默认会话目录 + * 列不到它们,因此这里不需要 S-2 那样的显式过滤。 + * + * @param {any[]} entries SessionInfo 列表 `[{ id, cwd, name, modified }]` + * @param {(id: string) => boolean} isMailDriven + * @returns {object[]} + */ +export function snapshotPiSessions(entries, isMailDriven = () => false) { + const list = Array.isArray(entries) ? entries : []; + const out = []; + for (const e of list) { + const id = typeof e?.id === 'string' ? e.id : ''; + if (!id) continue; + const name = typeof e?.name === 'string' ? e.name : ''; + // 没有名字的会话不报(S-1):pi 的列表在无名时显示首条消息, + // 而首条消息对邮件驱动的会话就是桥自己拼的提示词 —— 拿它当别名毫无区分度。 + if (!name) continue; + // 模型把思维链当标题写进来的那些不报(见 isUnusableName) + if (isUnusableName(name)) continue; + const slug = slugFromTitle(name); + if (!slug) continue; + out.push({ + platform_id: id, + // 老会话的 cwd 是空串(pi 的 SessionInfo 注释里写明了),照实上报, + // 服务端按空 workspace 处理,不要拿桥自己的 cwd 冒充。 + workspace: typeof e?.cwd === 'string' ? e.cwd : '', + slug, + title: name, + mail_driven: Boolean(isMailDriven(id)), + updated_at: toISO(e?.modified ?? e?.created), + }); + } + return dedupeBySlug(sortAndCap(out)); +} + +/** + * 判断一个平台侧名字是否不适合当别名。 + * + * 这条判废是 pi 特有的:pi-web 的标题生成器(`sessionNameGenerator`)只做了 + * 「取首行 + 去引号 + 截 60 字符」,没有防思维链泄漏。本机 81 条会话里实测捞到: + * + * "The user is asking me to generate a title for a coding-agent" + * "我们只需要生成标题,不包含其他内容。标题应反映请求内容:测试opencode的源。简短:…" + * + * 这类字符串派生出的别名又长又没有指代作用,填进三维地址里更是灾难。 + * 判废后调用方回退到「不上报」或「用邮件主题派生」,都比它强。 + * + * 宁可漏判也不误判:错杀一个好名字会让那条会话失去可寻址的别名, + * 而漏掉一个坏名字只是别名难看。 + * + * @param {string} name + * @returns {boolean} + */ +export function isUnusableName(name) { + const s = String(name ?? '').trim(); + if (!s) return true; + // 自指标题生成任务 = 模型把系统提示词复述了出来 + if (/生成标题|标题应|拟一个标题|generate a (short |concise )?title|session title|as a title/i.test(s)) { + return true; + } + // 以第三人称叙述用户意图开头 = 思维链的典型开场 + if (/^(the user\b|用户(想|要|在|希望)|我们只需要|我需要先|首先(,|,))/i.test(s)) return true; + // 又长又分句 = 一段话而不是一个标题(pi-web 截断上限是 60) + if (s.length >= 48 && /[。;;]|\.\s/.test(s)) return true; + return false; +} + +/** 判断一条会话是否为 subagent 子会话。两个字段任一成立即算。 */ +function isSubagent(e) { + if (e?.origin === 'subagent') return true; + const depth = e?.delegationDepth; + return typeof depth === 'number' && depth > 0; +} + +/** 同 slug 只保留第一条(调用前已按最近活跃排序)。 */ +function dedupeBySlug(list) { + const seen = new Set(); + const out = []; + for (const item of list) { + if (seen.has(item.slug)) continue; + seen.add(item.slug); + out.push(item); + } + return out; +} + +/** + * 把模型生成的会话标题转成可寻址的 slug。 + * + * 保留中文而不转拼音:标题「缓存层选型评估」转成 huancunceng-xuanxing 之后 + * 既不好读也不好打,而 AgentMail 的别名校验本来就允许中文(三维地址按最后一个 + * `.` 切分,中文不影响解析)。 + * + * 处理:空白 → `-`,去掉会干扰寻址的字符(`.` 是 session 位的分隔符, + * `@` 是 path 位的分隔符,`/` 会被当成路径),压缩连续 `-`,截断到 48 字符。 + * + * @param {string} title + * @returns {string} slug,无法派生时为空串 + */ +export function slugFromTitle(title) { + const raw = String(title ?? '').trim(); + if (!raw) return ''; + const slug = raw + .replace(/[\s\u3000]+/g, '-') + // 寻址相关的分隔符必须去掉,否则别名本身会被解析器切开 + .replace(/[.@/\\:,;'"`?#[\]{}()<>|*!$&=+%^~]/g, '') + .replace(/-{2,}/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48) + // 截断可能又切出尾部的 - + .replace(/-+$/g, ''); + // 纯符号标题清干净后会剩空串 + return slug; +} + +/** 毫秒时间戳、ISO 串或 Date → ISO 串;无法解析时返回 undefined。 */ +function toISO(v) { + if (typeof v === 'number' && Number.isFinite(v)) { + return new Date(v).toISOString(); + } + // pi 的 SessionInfo 给的是 Date 实例(created/modified),不是时间戳。 + // 少了这一支会让整份快照的 updated_at 全是 undefined,于是服务端只能按 + // 上报时间排序 —— 补全列表里「最近在谈的那条」不再排在前面。 + if (v instanceof Date) { + return Number.isNaN(v.getTime()) ? undefined : v.toISOString(); + } + if (typeof v === 'string' && v) { + const d = new Date(v); + if (!Number.isNaN(d.getTime())) return d.toISOString(); + } + return undefined; +} + +/** 按最近活跃降序排列并截断。上千条会话对补全列表毫无用处。 */ +function sortAndCap(list) { + return list + .sort((a, b) => String(b.updated_at ?? '').localeCompare(String(a.updated_at ?? ''))) + .slice(0, MAX_REPORTED); +} diff --git a/plugins/pi-mail-bridge/lib/workspace.js b/plugins/pi-mail-bridge/lib/workspace.js new file mode 100644 index 0000000..a1b8745 --- /dev/null +++ b/plugins/pi-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/pi-mail-bridge/package.json b/plugins/pi-mail-bridge/package.json new file mode 100644 index 0000000..2b58a04 --- /dev/null +++ b/plugins/pi-mail-bridge/package.json @@ -0,0 +1,17 @@ +{ + "name": "pi-mail-bridge", + "version": "0.1.0", + "description": "pi (@earendil-works/pi-coding-agent) 桥:邮件驱动多智能体协作平台接入", + "type": "module", + "main": "src/index.mjs", + "bin": { + "pi-mail-bridge": "src/index.mjs" + }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": ">=0.84.0" + }, + "scripts": { + "start": "node src/index.mjs", + "test": "node --test 'test/*.test.mjs'" + } +} diff --git a/plugins/pi-mail-bridge/src/gateway.mjs b/plugins/pi-mail-bridge/src/gateway.mjs new file mode 100644 index 0000000..314f40a --- /dev/null +++ b/plugins/pi-mail-bridge/src/gateway.mjs @@ -0,0 +1,235 @@ +/** + * AgentMail Gateway 客户端 —— HTTP + SSE。 + * + * 与另两个插件同构(同样的认证头、同样的手写 SSE 解析),区别只在这里是 + * 独立守护进程,所以密钥解析与 Last-Event-ID 的状态都归它自己管。 + */ + +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { randomBytes } from 'node:crypto'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +const CONFIG_DIR = process.env.AGENTMAIL_CONFIG_DIR || join(homedir(), '.agentmail'); +const KEY_FILE = join(CONFIG_DIR, 'agent.key'); +const CONFIG_FILE = join(CONFIG_DIR, 'config.json'); + +/** 读取本地密钥文件;不存在或损坏时返回 null。 */ +export function readLocalKey() { + try { + if (!existsSync(KEY_FILE)) return null; + const raw = JSON.parse(readFileSync(KEY_FILE, 'utf8')); + return typeof raw?.key_token === 'string' && raw.key_token ? raw.key_token : null; + } catch { + return null; + } +} + +/** + * 首次安装时本地生成密钥并落盘(0600),**并把全文打印到日志**(B-1.1)。 + * + * 打印是必须的:密钥要管理员在后台登记之后才能接入,不打印就没人知道登记什么。 + * 走 console.error 而不是任何结构化日志 —— 它一定进 journalctl(契约 9.8)。 + */ +export function generateLocalKey(log = console.error) { + const token = randomBytes(32).toString('hex'); + mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); + writeFileSync( + KEY_FILE, + JSON.stringify({ key_token: token, created_at: new Date().toISOString() }, null, 2), + { mode: 0o600 }, + ); + // 调用方传进来的 log 已经带 [pi-mail-bridge] 前缀,这里不再自己加 + log(`已在 ${KEY_FILE} 生成本地密钥。`); + log(`该密钥需管理员在 AgentMail 后台登记后才能接入:`); + log(` ${token}`); + return token; +} + +/** 把 gateway 地址与身份记到 config.json,便于换机时人工核对。 */ +export function saveConfig(extra) { + try { + mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); + let cur = {}; + if (existsSync(CONFIG_FILE)) { + try { cur = JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); } catch { /* 损坏就重写 */ } + } + writeFileSync(CONFIG_FILE, JSON.stringify({ ...cur, ...extra }, null, 2), { mode: 0o600 }); + } catch (e) { + console.error('[pi-mail-bridge] 写 config.json 失败:', e?.message || e); + } +} + +export class GatewayClient { + /** + * @param {{url: string, agentName: string, agentKey: string, agentSecret: string}} opts + */ + constructor({ url, agentName, agentKey, agentSecret }) { + this.baseURL = String(url || 'http://127.0.0.1:8180').replace(/\/+$/, ''); + this.agentName = agentName; + this.agentKey = agentKey || ''; + this.agentSecret = agentSecret || ''; + this.sseAbort = null; + // SSE 重连时带上,首次连接**不带**(B-1.4 / N-11): + // 带上会收到一批已处理过的旧事件,插件重启一次就把历史邮件重投一遍。 + this.lastEventID = ''; + } + + /** 认证头:有密钥走 Bearer,否则退回 name/secret。 */ + authHeaders() { + if (this.agentKey) { + return { Authorization: `Bearer ${this.agentKey}`, 'X-Agent-Name': this.agentName }; + } + return { 'X-Agent-Name': this.agentName, 'X-Agent-Secret': this.agentSecret }; + } + + async get(path) { + const res = await fetch(`${this.baseURL}/api/v1${path}`, { headers: this.authHeaders() }); + if (!res.ok) throw new Error(`GET ${path} 失败: HTTP ${res.status}`); + return res.json(); + } + + async post(path, body) { + const res = await fetch(`${this.baseURL}/api/v1${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...this.authHeaders() }, + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const err = new Error(data?.error || `POST ${path} 失败: HTTP ${res.status}`); + err.status = res.status; + throw err; + } + return data; + } + + /** 注册。workspaces 传 [](B-1.2)—— 工作目录由每封邮件的 to_workspace 决定。 */ + async register() { + return this.post('/agent/register', { + name: this.agentName, + secret: this.agentSecret || '', + workspaces: [], + platform: 'pi', + }); + } + + /** + * 上传附件。 + * + * 必须走 multipart 的 `file` 字段:服务端是 `r.FormFile("file")`, + * 且**不认 `X-Filename` 头**(grep 过 handler/attachments.go,没有这个分支)。 + * 直接 POST 二进制体会得到 400「缺少 file 字段」。 + * + * 不手动设 Content-Type:让 undici 按 FormData 自己生成 boundary。 + */ + async uploadFile(buf, filename) { + const form = new FormData(); + form.append('file', new Blob([buf]), filename); + const res = await fetch(`${this.baseURL}/api/v1/attachments`, { + method: 'POST', + headers: this.authHeaders(), + body: form, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error || `上传失败: HTTP ${res.status}`); + return data.attachment; + } + + async downloadFile(attachmentID) { + const res = await fetch(`${this.baseURL}/api/v1/attachments/${attachmentID}`, { + headers: this.authHeaders(), + }); + if (!res.ok) throw new Error(`下载失败: HTTP ${res.status}`); + return Buffer.from(await res.arrayBuffer()); + } + + /** + * 建立 SSE 长连并自动重连。 + * + * 手写解析而不用 EventSource:Node 内建的那个不支持自定义请求头, + * 而认证头是必须的。协议这一小块(`id:` / `event:` / `data:` + 空行分隔) + * 比引一个依赖划算。 + * + * 断线重连带 `Last-Event-ID`(D-7.2):服务端有 per-agent 环形缓冲, + * 能把断连期间的事件回放出来 —— 否则那段时间的邮件只能等下次重启补拉。 + */ + startSSE(onEvent, log = console.error) { + this.sseAbort?.abort(); + this.sseAbort = new AbortController(); + const signal = this.sseAbort.signal; + + const reconnect = (delay) => { + if (signal.aborted) return; + setTimeout(() => this.#connect(onEvent, reconnect, log), delay); + }; + this.#connect(onEvent, reconnect, log); + } + + #connect(onEvent, reconnect, log) { + const signal = this.sseAbort?.signal; + if (!signal || signal.aborted) return; + + const headers = { ...this.authHeaders(), Accept: 'text/event-stream' }; + // 重连时带上断点(D-7.2)。**首次连接必须不带**(N-11):那会让服务端 + // 把缓冲区里的旧事件全回放一遍,插件重启后重复处理一批已处理的邮件。 + // 只有 lastEventID 非空(= 已经收过事件)时才是重连。 + if (this.lastEventID) { + headers['Last-Event-ID'] = this.lastEventID; + log(`SSE 重连,从事件 ${this.lastEventID} 之后续传`); + } + + fetch(`${this.baseURL}/api/v1/events/stream`, { headers, signal }) + .then((res) => { + if (!res.ok || !res.body) { + log(`SSE 建连失败: HTTP ${res.status}`); + return reconnect(5000); + } + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buf = ''; + let id = ''; + let evt = ''; + let data = ''; + + const read = () => { + reader.read().then(({ done, value }) => { + if (done) return reconnect(3000); + buf += decoder.decode(value, { stream: true }); + const lines = buf.split('\n'); + buf = lines.pop() || ''; + for (const line of lines) { + if (line.startsWith('id: ')) id = line.slice(4).trim(); + else if (line.startsWith('event: ')) evt = line.slice(7).trim(); + else if (line.startsWith('data: ')) data = line.slice(6); + else if (line === '' && evt) { + // 事件 id 要在**分发之前**记下:分发里抛异常也不该让它丢, + // 否则重连会从更早的位置回放,已处理的邮件再来一遍。 + if (id) this.lastEventID = id; + try { onEvent(evt, JSON.parse(data)); } catch (e) { + log(`SSE 事件处理失败: ${e?.message || e}`); + } + id = ''; evt = ''; data = ''; + } + } + read(); + }).catch((e) => { + if (signal.aborted) return; + log(`SSE 读取中断: ${e?.message || e}`); + reconnect(5000); + }); + }; + read(); + }) + .catch((e) => { + if (signal.aborted) return; + log(`SSE 连接错误: ${e?.message || e}`); + reconnect(5000); + }); + } + + stopSSE() { + this.sseAbort?.abort(); + this.sseAbort = null; + } +} diff --git a/plugins/pi-mail-bridge/src/index.mjs b/plugins/pi-mail-bridge/src/index.mjs new file mode 100644 index 0000000..70794dd --- /dev/null +++ b/plugins/pi-mail-bridge/src/index.mjs @@ -0,0 +1,693 @@ +#!/usr/bin/env node +/** + * AgentMail ↔ pi 桥(pi-mail-bridge) + * + * 形态是**常驻守护进程**,不是 pi 扩展。原因见 src/session-pool.mjs 顶部: + * 扩展被加载进一条已存在的会话,cwd 由启动 pi 的人决定;而 B-3.1 要求每封邮件的 + * to_workspace 成为会话 cwd。桥用 SDK 的 createAgentSession 按邮件起会话, + * 一个进程里并存多条不同 cwd 的会话(实测可行)。 + * + * 契约实现对照(docs/PLUGIN-CONTRACT.md): + * B-1 启动 → main() + * B-2 心跳 → beat(),30 秒 + * B-3 new_mail → deliverMail() + * B-4 决策 → handlePermissionDecision() + * B-5 转发 → relaySummary(),挂在 agent_end 上 + * B-6 失败回信 → deliverMail() 末尾的 renderFailureReport + * B-7 补拉 → catchUp() + * B-8 权限 → permissionExtension() 的 tool_call 钩子 + * B-9 关停 → shutdown() + */ + +import { mkdirSync, openSync, closeSync, unlinkSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { ModelRuntime } from '@earendil-works/pi-coding-agent'; + +import { GatewayClient, readLocalKey, generateLocalKey, saveConfig } from './gateway.mjs'; +import { createMailTools } from './tools.mjs'; +import { openSession, runTurn } from './session-pool.mjs'; +import { buildMailPrompt, lastAssistantText, replySubject, relayKeyFor, describeError } from './turn.mjs'; +import { planNamingSync, planWriteBack } from './naming.mjs'; +import { resolveWorkspaceCwd, ensureCwd } from '../lib/workspace.js'; +import { modelAttemptOrder, renderFailureReport, snapshotPiModels } from '../lib/model-scope.js'; +import { snapshotPiSessions } from '../lib/session-snapshot.js'; +import { selectCatchup } from '../lib/catchup.js'; +import { explicitSends, shouldSkipAutoRelay } from '../lib/relay-dedup.js'; + +// ─── 配置 ─── + +const GATEWAY_URL = process.env.AGENTMAIL_GATEWAY_URL || 'http://127.0.0.1:8180'; +const AGENT_NAME = process.env.AGENTMAIL_AGENT_NAME || 'pi'; +const AGENT_SECRET = process.env.AGENTMAIL_AGENT_SECRET || ''; +const REPLY_PROVIDER = process.env.AGENTMAIL_REPLY_PROVIDER || ''; +const REPLY_MODEL = process.env.AGENTMAIL_REPLY_MODEL || ''; +const TURN_TIMEOUT_MS = Number(process.env.AGENTMAIL_TURN_TIMEOUT_MS || 60_000); +const LOCK_FILE = join(process.env.AGENTMAIL_CONFIG_DIR || join(homedir(), '.agentmail'), 'pi-bridge.lock'); + +/** 日志一律 console.error:它一定进 journalctl(契约 9.8)。 */ +const log = (...args) => console.error('[pi-mail-bridge]', ...args); + +// ─── 进程内状态 ─── +// +// 全部只在内存,重启即丢 —— 这是契约第六节列明的已知取舍。 +// 要持久化的话该落在 pi 的会话元数据里,而不是桥自己的文件。 + +const sessions = new Map(); // agentmail session_id -> { session, sessionManager, cwd } +const reverseMap = new Map(); // pi session id -> agentmail session_id +const mailDriven = new Set(); // pi session id +const mailContexts = new Map(); // agentmail session_id -> { replyTo, subject, mailID } +const relayedSummaries = new Map(); // pi session id -> 已转发过的 relay_key +const syncedNames = new Map(); // pi session id -> 上次提交给 Gateway 的名字 +const pendingPermissions = new Map(); // relay_key -> { resolve, piSessionId } +const deliveredMails = new Set(); // 已投过的 mail_id(SSE 与补拉共用,B-7.3) + +let allowedModels = []; +let modelRuntime = null; +let client = null; +let heartbeatTimer = null; +let shuttingDown = false; + +// ─── 单实例锁 ─── +// +// 两个桥同时跑的后果不是「慢一点」而是错的:两条 SSE 各收到同一封邮件, +// 各起一条 pi 会话,发件人收到两封回信;而 deliveredMails 在各自内存里,去重不了。 + +function acquireLock() { + mkdirSync(join(LOCK_FILE, '..'), { recursive: true, mode: 0o700 }); + try { + // O_EXCL 原子创建。存在则说明有别的实例(或上次崩溃留下的陈锁)。 + const fd = openSync(LOCK_FILE, 'wx'); + writeFileSync(fd, String(process.pid)); + closeSync(fd); + return true; + } catch (e) { + if (e?.code !== 'EEXIST') throw e; + } + // 陈锁判定:文件里的 pid 还活着吗 + let pid = 0; + try { pid = Number(readFileSync(LOCK_FILE, 'utf8').trim()); } catch { /* 读不到当陈锁 */ } + if (pid > 0) { + try { + // signal 0 只探测存在性,不真的发信号 + process.kill(pid, 0); + log(`已有实例在运行(pid ${pid}),本进程退出。`); + return false; + } catch { + // ESRCH:进程没了,是陈锁 + } + } + log(`清理陈锁 ${LOCK_FILE}(原 pid ${pid || '未知'} 已不存在)`); + try { unlinkSync(LOCK_FILE); } catch { /* 竞态下别人清掉了也行 */ } + return acquireLock(); +} + +function releaseLock() { + try { + // 只删自己的锁:pid 不符说明这把锁已被别的实例接管 + if (Number(readFileSync(LOCK_FILE, 'utf8').trim()) === process.pid) unlinkSync(LOCK_FILE); + } catch { /* 已经没了 */ } +} + +// ─── 权限钩子(B-8)─── + +/** + * 内联 pi 扩展:把 pi 拦下的危险工具调用转成一封邮件问人。 + * + * 这是 `I-1` 最直接的体现 —— 被平台真正拦下的那一次才是事实, + * 不依赖模型「记得」调 request_permission(它会忘,也会在不需要时乱调)。 + * + * pi 的 `tool_call` 钩子**可以 await**(C-9 实测成立:处理器里 await 300ms + * 再返回 {block:true},pi 会等),所以这里能真的等人做决定, + * 不必走「先拒一次再重试」的退化路径。 + * + * @param {string} piSessionIdRef 用一个 getter 拿会话 id:扩展工厂在 + * createAgentSession **内部**被调用,那时 session 对象还没返回给桥。 + */ +function permissionExtension(getMailContext) { + // pi 默认放行内建工具;桥只拦真正有副作用的那几个。 + // read/grep/ls 之类不拦:每一步都问人会让 Agent 什么也做不成, + // 而人也会很快开始无脑点同意(那比不问更危险)。 + const GUARDED = new Set(['bash', 'write', 'edit']); + + return (pi) => { + pi.on('tool_call', async (event, ctx) => { + if (!GUARDED.has(event.toolName)) return; + + const piSessionId = ctx?.sessionManager?.getSessionId?.() || ''; + const mailSessionId = reverseMap.get(piSessionId); + // 不是邮件驱动的会话 → 让位给 pi 自己的本地 UI(B-8.2)。 + // 占着钩子不放会让人在 TUI 里干活时每一步都卡住等邮件。 + if (!mailSessionId) return; + + // relay_key 用 pi 给的 toolCallId(B-8.1):服务端会随决策事件回传它, + // 桥重启丢了 pendingPermissions 也能对上(B-4.2)。自造随机 id 做不到。 + const relayKey = `${piSessionId}:${event.toolCallId}`; + const ctxInfo = getMailContext(mailSessionId); + + try { + await client.post('/permission/request', { + question: `是否允许执行 ${event.toolName}?`, + options: ['同意', '一直同意', '拒绝'], + context: describeToolCall(event), + session_id: mailSessionId, + to: ctxInfo?.replyTo || '', + relay_key: relayKey, + }); + } catch (e) { + // 转发失败 → 让位给 pi 本地 UI(B-8.2)。返回 undefined 表示 + // 「这个钩子不表态」,pi 会走它自己的批准流程。 + log(`权限转发失败,让位给本地决策: ${describeError(e)}`); + return; + } + + log(`权限询问已发出(${event.toolName},key=${relayKey}),等待决策…`); + const decision = await new Promise((resolve) => { + pendingPermissions.set(relayKey, { resolve, piSessionId }); + }); + + // fail closed(B-9.2 / N-9):只有明确的同意才放行。 + // 关停时 shutdown() 会用 'shutdown' 唤醒所有等待者,落到这里的 else。 + if (/^(同意|一直同意|allow|approve|always|yes)/i.test(decision)) { + log(`权限 ${relayKey} 获批(${decision}),放行 ${event.toolName}`); + return; + } + return { block: true, reason: `用户${decision === 'shutdown' ? '未及决策(桥已关停)' : `拒绝了这次 ${event.toolName} 调用`}` }; + }); + }; +} + +/** 把一次工具调用摘要成人能判断的文本(B-8.4)。 */ +function describeToolCall(event) { + const input = event?.input ?? {}; + if (event.toolName === 'bash') { + return `命令:\n${String(input.command ?? '').slice(0, 800)}`; + } + if (event.toolName === 'write' || event.toolName === 'edit') { + return `文件:${input.file_path ?? input.path ?? '(未给出)'}`; + } + return JSON.stringify(input).slice(0, 800); +} + +// ─── 会话解析(B-3)─── + +/** + * 没有可用 `to_workspace` 时的兜底目录。 + * + * 与 DSH 的 `mailSessionFallback` 同构,但目录名是 `.pi`:那个函数在 + * lib/ 下(三平台逐字节相同),写死了 `.dsh`,不能为 pi 改。 + * 让 pi 的会话落进 `~/.dsh/` 会让人以为是 DSH 在干活。 + */ +function piMailFallback(sessionKey) { + return join(homedir(), '.pi', 'mail-sessions', String(sessionKey || 'default')); +} + +/** + * 找到(或建立)这封邮件该落进的 pi 会话。 + * + * Gateway 已经按三维地址的 session 位做完了「复用默认 / 新建 / 具名必须存在」 + * 的判定,推来的 session_id 就是判定结果 —— 桥只负责忠实映射, + * 不自己决定开不开新会话(N-8:404 后自动改用 .new 是禁止的)。 + */ +async function resolveSession(data, mailTools) { + const mailSessionID = data.session_id; + const bound = mailSessionID ? sessions.get(mailSessionID) : undefined; + if (bound) return { ...bound, reused: true }; + + // cwd 取寻址里的 path 位(B-3.1)。校验走共用模块:目录不存在时**不创建** + // (N-2:笔误会在磁盘上落下真目录,而 Agent 在里面一无所获),拒绝相对路径(N-3)。 + // + // 兜底用 `~/.pi/mail-sessions/<会话>` 而不是共用模块里的 mailSessionFallback —— + // 后者写死了 `.dsh` 目录名(那是 DSH 的家),pi 的会话落进去会让人以为 + // DSH 在干活。lib/ 里的函数三平台逐字节相同,不能为 pi 改它。 + const { cwd, grouped } = resolveWorkspaceCwd(data.to_workspace, piMailFallback(mailSessionID)); + if (!grouped && data.to_workspace) { + log(`工作目录 ${data.to_workspace} 不可用,回退到 ${cwd}`); + } + ensureCwd(cwd, grouped); + + const opened = await openSession({ + cwd, + modelRuntime, + customTools: mailTools, + extension: permissionExtension((id) => mailContexts.get(id)), + }); + for (const d of opened.diagnostics) { + log(`扩展诊断: ${d?.message ?? JSON.stringify(d)}`); + } + + const piSessionId = opened.session.sessionId; + const entry = { session: opened.session, sessionManager: opened.sessionManager, cwd }; + + if (mailSessionID) { + sessions.set(mailSessionID, entry); + reverseMap.set(piSessionId, mailSessionID); + mailDriven.add(piSessionId); + } + + // 一轮结束就转发总结(B-5)。挂 agent_end 而不是 message_end: + // 后者在流式生成中反复触发,转出去的是半截话。 + // subscribe 收的是一个普通函数(AgentSessionEventListener),不是 {onEvent}。 + opened.session.subscribe((event) => { + if (event?.type === 'agent_end') { + // willRetry 为真表示 pi 自己要重试(auto_retry),这一轮还没定论 —— 不转。 + if (event.willRetry) return; + relaySummary(piSessionId).catch((e) => log(`自动转发失败: ${describeError(e)}`)); + } + // pi 侧改名(pi-web 生成标题、人在 TUI 里 /name)→ 同步给 Gateway + if (event?.type === 'session_info_changed') { + syncNaming(piSessionId, event.name).catch((e) => log(`命名同步失败: ${describeError(e)}`)); + } + }); + + log(`新建 pi 会话 ${piSessionId}(cwd=${cwd})`); + return { ...entry, reused: false }; +} + +// ─── 命名一致(C-11 / W-7)─── + +/** + * pi 的名字 → Gateway → 定稿别名回写进 pi。 + * + * 完整推理见 src/naming.mjs 顶部。这里只是把那套决策接上 I/O。 + */ +async function syncNaming(piSessionId, platformName) { + const mailSessionID = reverseMap.get(piSessionId); + if (!mailSessionID) return; // 不是邮件驱动的会话,不碰 + + const plan = planNamingSync({ + platformName, + mailSubject: mailContexts.get(mailSessionID)?.subject, + lastSynced: syncedNames.get(piSessionId), + }); + if (plan.skip) return; + + // 先记下指纹再发请求:响应回来时 setSessionName 会再次触发 + // session_info_changed,这一步是防自激循环的关键。 + syncedNames.set(piSessionId, plan.signature); + + const res = await client.post(`/sessions/${mailSessionID}/sync`, { + alias: plan.alias, + title: plan.title, + }); + + const entry = sessions.get(mailSessionID); + const back = planWriteBack({ + finalAlias: res?.alias, + currentPiName: entry?.session?.sessionName, + }); + log(`命名同步 ${piSessionId}: alias=${res?.alias || '(未变)'} 来源=${plan.source}`); + + if (back.write && entry?.session) { + // 顺序要紧:先更新指纹,再改名。 + // + // setSessionName **同步**触发 session_info_changed(实测),于是本函数会在 + // 这一行里被重入。指纹在改名之后才更新的话,重入那次看到的还是旧指纹, + // 于是又打一次 sync —— 每条会话两次请求,内容完全相同。 + // + // 记的是「把定稿别名当作平台名字」会算出的指纹:重入那次的 platformName + // 正是 back.name,来源判定成 platform,算出来的就是这个值。 + syncedNames.set(piSessionId, `platform:${back.name}|${back.name}`); + // 只用 setSessionName(走 pi 自己的写入路径)。绝不自己拼路径写会话文件: + // 首条 assistant 消息落盘前文件还不存在,pi 首次落盘用 openSync(file,"wx"), + // 抢先创建会让它抛 EEXIST(实测)。 + entry.session.setSessionName(back.name); + log(`别名回写 pi:${back.name}(${back.reason})`); + } +} + +// ─── 自动转发(B-5)─── + +async function relaySummary(piSessionId) { + const mailSessionID = reverseMap.get(piSessionId); + if (!mailSessionID) return; + // 只对邮件驱动的会话转发(B-5.5):人在 pi 里正常干活时不该往邮箱灌总结 + if (!mailDriven.has(piSessionId)) return; + + const entry = sessions.get(mailSessionID); + if (!entry) return; + + // 一轮结束是命名的自然时机(C-11 / D-5)。 + // + // 这一步不能只挂在 session_info_changed 上:桥用 SDK 起的会话**永远不会** + // 触发那个事件 —— pi 的标题生成器在 pi-web 里,不在内核里,SDK 路径上没有它。 + // 只等事件的话别名永远是空的,于是 `name@path.<别名>` 续谈无从下手 + // (实测过:第一封邮件跑通了,sessions.session_alias 仍是空串)。 + // + // 放在转发**之前**:回信里会带上会话别名,收件人看到的第一封回信就能用它续谈。 + await syncNaming(piSessionId, entry.session.sessionName) + .catch((e) => log(`命名同步失败: ${describeError(e)}`)); + + // 只取 type==='text' 的块(B-5.1 / N-6):thinking 是思考过程,不是结论 + const text = lastAssistantText(entry.session.messages); + if (!text) return; // 空文本不发空邮件(B-5.4) + + const ctx = mailContexts.get(mailSessionID); + if (!ctx?.replyTo) return; // 不知道回给谁 + + // 幂等键用 pi 的会话 id + 会话树叶子 id:两者都落盘,重启重放也是同一个键。 + const relayKey = relayKeyFor(piSessionId, entry.sessionManager.getLeafId?.()); + if (relayedSummaries.get(piSessionId) === relayKey) return; + + // 模型这一轮已亲手回过这条线索 → 让位(B-5.3)。 + // 否则收件箱里是两封说同一件事的邮件(生产实测过)。 + if (shouldSkipAutoRelay(explicitSends.get(piSessionId), ctx.replyTo, ctx.mailID)) { + explicitSends.delete(piSessionId); + relayedSummaries.set(piSessionId, relayKey); + log(`本轮模型已主动回信 ${ctx.replyTo},跳过自动转发`); + return; + } + + await client.post('/mail/send', { + to: ctx.replyTo, + subject: replySubject(ctx.subject), + body: text, + reply_to: ctx.mailID || '', + // relay + relay_key 走免配额通道(I-2):模型已经把话说完了, + // 桥只是把它搬到邮件里。对搬运收费会让配额用尽时 Agent 连交代都做不了。 + relay: 'summary', + relay_key: relayKey, + }); + relayedSummaries.set(piSessionId, relayKey); + explicitSends.delete(piSessionId); // 一轮结束,窗口关闭 + log(`已转发本轮总结给 ${ctx.replyTo}(${text.length} 字)`); +} + +// ─── 投递(B-3 / B-6)─── + +async function deliverMail(data, kind, mailTools) { + const { session, reused } = await resolveSession(data, mailTools); + const piSessionId = session.sessionId; + + // 新一轮开始:清掉上一轮「模型主动发过信」的记录。不清的话, + // 上一轮亲手回过信会永久压掉这个会话之后所有的自动转发。 + explicitSends.delete(piSessionId); + + if (kind === 'mail' && data.session_id) { + // 一个会话里可能来过多封信,只留最近那封 —— 回信要落回最新的线索 + mailContexts.set(data.session_id, { + replyTo: data.from_name || '', + subject: data.subject || '', + mailID: data.mail_id || '', + }); + } + + const prompt = buildMailPrompt({ agentName: AGENT_NAME, data, kind, reused }); + + // 续谈:会话已经存在,模型也已经定了(pi 的模型在 createAgentSession 时绑定), + // 所以这一支不做模型降级。runTurn 内部按 isStreaming 分流: + // 空闲就直接起一轮,正在跑就排到当轮之后(不打断上一封邮件的工作)。 + if (reused) { + const outcome = await runTurn(session, prompt, TURN_TIMEOUT_MS); + log(`续谈 ${piSessionId}(mail ${data.mail_id}${outcome.queued ? ',已排队' : ''})`); + // 续谈失败不换模型重试(换模型要换会话,会丢掉整条上下文 —— + // 而上下文正是发件人指定这条会话的原因),但要让失败可见。 + if (!outcome.ok) throw new Error(`续谈失败: ${outcome.error}`); + return; + } + + // 按管理员划定的范围逐个尝试(D-3)。 + // 关键点:`prompt()` resolve **不代表模型跑成功了** —— 无凭证的 provider + // 会让它 reject(实测 `No API key found for amazon-bedrock.`), + // 而上游报错走 stopReason==='error'。判定交给 classifyTurnOutcome。 + const attempts = modelAttemptOrder(allowedModels, { + provider: REPLY_PROVIDER, + model: REPLY_MODEL, + }); + const failures = []; + + for (const route of attempts) { + const label = route ? `${route.provider}/${route.model}` : '(平台默认)'; + if (route) { + const model = modelRuntime.getModel(route.provider, route.model); + if (!model) { + // 目录里根本没有这个路由:同步就能判定,不必起一轮 + failures.push({ ...route, error: `平台目录里没有 ${label}` }); + log(`模型 ${label} 不存在,跳过`); + continue; + } + // 换模型要换会话:pi 的模型在 createAgentSession 时绑定。 + // 上一次尝试失败的会话没有任何 assistant 消息,丢掉不损失内容。 + const cwd = sessions.get(data.session_id)?.cwd; + const current = sessions.get(data.session_id)?.session; + current?.dispose?.(); + const retried = await openSession({ + cwd, + modelRuntime, + model, + customTools: mailTools, + extension: permissionExtension((id) => mailContexts.get(id)), + }); + rebind(data.session_id, current?.sessionId ?? piSessionId, retried, cwd); + const outcome = await runTurn(retried.session, prompt, TURN_TIMEOUT_MS); + if (outcome.ok) { + if (failures.length) log(`${label} 成功(前 ${failures.length} 个失败)`); + return; + } + failures.push({ ...route, error: outcome.error }); + log(`模型 ${label} 失败: ${outcome.error}`); + continue; + } + + const outcome = await runTurn(session, prompt, TURN_TIMEOUT_MS); + if (outcome.ok) { + if (failures.length) log(`${label} 成功(前 ${failures.length} 个失败)`); + return; + } + failures.push({ error: outcome.error }); + log(`模型 ${label} 失败: ${outcome.error}`); + } + + // 全部失败 → 必须回信(B-6):模型一次都没跑起来,会话里没有任何 + // assistant 消息,自动转发因此什么也不会发 —— 发件人只会看到再无音讯。 + if (kind === 'mail' && data.from_name) { + try { + await client.post('/mail/send', { + to: data.from_name, + subject: `处理失败: ${data.subject || '(无主题)'}`, + body: renderFailureReport(failures, data.subject), + reply_to: data.mail_id || '', + relay: 'summary', + relay_key: `model-failure:${data.mail_id || piSessionId}`, + }); + log(`已回报模型调用失败给 ${data.from_name}`); + } catch (e) { + log(`失败回报也发不出去: ${describeError(e)}`); + } + } + // 发完仍要 throw(B-6.4):静默会让这次失败只存在于邮件里,日志上看不出来 + throw new Error(`范围内 ${failures.length} 个模型全部失败:${failures.map(f => f.error).join(' | ')}`); +} + +/** 换模型重开会话后,把三张映射表指向新会话。 */ +function rebind(mailSessionID, oldPiId, opened, cwd) { + reverseMap.delete(oldPiId); + mailDriven.delete(oldPiId); + const piSessionId = opened.session.sessionId; + // cwd 由调用方传:AgentSession 上没有 cwd getter(只有 sessionId / + // sessionFile / sessionName),从 sessionManager.getCwd() 也行, + // 但这里本来就有那个值,多绕一层没有意义。 + const entry = { session: opened.session, sessionManager: opened.sessionManager, cwd }; + if (mailSessionID) { + sessions.set(mailSessionID, entry); + reverseMap.set(piSessionId, mailSessionID); + mailDriven.add(piSessionId); + } + opened.session.subscribe((event) => { + if (event?.type === 'agent_end' && !event.willRetry) { + relaySummary(piSessionId).catch((e) => log(`自动转发失败: ${describeError(e)}`)); + } + if (event?.type === 'session_info_changed') { + syncNaming(piSessionId, event.name).catch((e) => log(`命名同步失败: ${describeError(e)}`)); + } + }); +} + +// ─── 权限决策回来(B-4)─── + +async function handlePermissionDecision(data, mailTools) { + const relayKey = data.relay_key || ''; + const pending = relayKey ? pendingPermissions.get(relayKey) : undefined; + + if (pending) { + pendingPermissions.delete(relayKey); + pending.resolve(String(data.decision || '拒绝')); + log(`权限 ${relayKey} 决策 ${data.decision}(决策人 ${data.decided_by || '?'})`); + return; + } + + // 找不到挂起项(桥重启丢了内存映射)→ 退化为把决策当一封通知投进原会话(B-4.2)。 + // 此时 pi 侧那次工具调用早已随进程消失,但人刚刚点了「同意」—— + // 什么都不做的话人以为自己批准了、Agent 却毫无反应。 + if (!data.session_id || !sessions.has(data.session_id)) { + // **不得凭空新开会话**(B-4.3) + log(`权限决策 ${relayKey} 无对应会话,忽略`); + return; + } + log(`权限 ${relayKey} 无挂起项,退化为通知投递`); + await deliverMail(data, 'permission', mailTools); +} + +// ─── 心跳(B-2)─── + +async function reportSessions() { + try { + const { SessionManager } = await import('@earendil-works/pi-coding-agent'); + // 不传参数:`listAll(dir)` 把字符串当**自定义会话目录**,传 getAgentDir() + // 会去 ~/.pi/agent 下直接找 .jsonl(那里没有),得到空列表。 + // 不传时它用默认的 ~/.pi/agent/sessions,逐个 cwd 子目录扫。 + // + // 用 listAll 而不是 list(cwd):桥的进程 cwd 与会话 cwd 无关, + // 按前者过滤会漏掉所有真正在干活的会话。 + const all = await SessionManager.listAll(); + return snapshotPiSessions(all, (id) => mailDriven.has(id)); + } catch (e) { + // 拉不到就**省略字段**而不是传 [](N-7 / W-3): + // 空数组的语义是「平台确实一条会话都没有」,会把服务端镜像抹掉。 + log(`会话列表读取失败: ${describeError(e)}`); + return undefined; + } +} + +async function reportModels() { + try { + // getAvailable 而不是 getModels:后者本机有 1221 条,其中真能调起来的只有 1 条。 + // 上报目录的全部意义就是让管理员别选中一个注定失败的路由。 + const available = await modelRuntime.getAvailable(); + return snapshotPiModels(available); + } catch (e) { + log(`模型目录读取失败: ${describeError(e)}`); + return undefined; + } +} + +async function catchUp(pending, mailTools) { + if (!pending) return; + try { + const box = await client.get('/mail/inbox?status=unread&limit=20'); + const tasks = selectCatchup(box?.mails ?? box, deliveredMails); + if (!tasks.length) return; + log(`补投 ${tasks.length} 封离线期间的邮件(共 ${pending} 封未读)`); + // 串行(B-7.2):每封都要起一轮模型,并发放出去等于对上游打 N 个并发请求 + for (const ev of tasks) { + if (deliveredMails.has(ev.mail_id)) continue; // 逐封再查(B-7.6) + deliveredMails.add(ev.mail_id); + try { + await deliverMail(ev, 'mail', mailTools); + } catch (e) { + log(`补投 ${ev.mail_id} 失败: ${describeError(e)}`); + } + } + } catch (e) { + log(`补投失败: ${describeError(e)}`); + } +} + +// ─── 启动 / 关停 ─── + +async function main() { + if (!acquireLock()) process.exit(0); + + // B-1.1:环境变量 → ~/.agentmail/agent.key → 本地生成并打印全文 + let agentKey = process.env.AGENTMAIL_AGENT_KEY || readLocalKey(); + if (!agentKey && !AGENT_SECRET) agentKey = generateLocalKey(log); + + client = new GatewayClient({ + url: GATEWAY_URL, + agentName: AGENT_NAME, + agentKey, + agentSecret: AGENT_SECRET, + }); + + // ModelRuntime 建一次全进程共用:它要读 auth.json / models.json 并做 + // 可用性探测,每条会话建一个既慢又会重复打 provider 的探测请求。 + // + // allowModelNetwork 保持默认的 false:桥启动时不去网上拉模型目录。 + // 拉了也没用 —— 上报给 Gateway 的是 getAvailable()(有凭证、真能调起来的), + // 而那取决于本机 auth.json,不取决于目录里有多少条。开着只会让 + // 启动多等一个网络往返,而且断网时启动路径上多一个可失败点。 + modelRuntime = await ModelRuntime.create(); + const runtimeErr = modelRuntime.getError?.(); + if (runtimeErr) log(`模型运行时告警: ${runtimeErr}`); + + const mailTools = createMailTools({ client, log, agentName: AGENT_NAME }); + + try { + await client.register(); // B-1.2 + saveConfig({ gateway_url: GATEWAY_URL, agent_name: AGENT_NAME, registered_at: new Date().toISOString() }); + log(`已接入 ${GATEWAY_URL},身份 ${AGENT_NAME}(${agentKey ? '密钥认证' : 'name/secret 认证'})。`); + } catch (e) { + // 密钥未登记时这里报「密钥无效」—— 必须说清该做什么, + // 否则用户只看到一句 401,不知道要拿密钥去后台登记。 + log(`注册失败: ${describeError(e)}`); + if (agentKey) log(`若提示密钥无效,请让管理员在 AgentMail 后台登记这把密钥。`); + } + + let caughtUp = false; + const beat = async () => { + const [platform_sessions, models] = await Promise.all([reportSessions(), reportModels()]); + const body = {}; + if (platform_sessions) body.platform_sessions = platform_sessions; + if (models) body.models = models; + try { + const res = await client.post('/agent/heartbeat', body); + if (Array.isArray(res?.allowed_models)) allowedModels = res.allowed_models; // B-2.2 + if (!caughtUp) { // B-7.1:只在首个成功心跳后补一次 + caughtUp = true; + await catchUp(res?.pending_mails, mailTools); + } + } catch { + // B-2.1:心跳失败不重试不报错。真连不上时 Gateway 会把它判成离线, + // 那才是可见的信号;桥自己打一串错误日志只会淹掉真正的问题。 + } + }; + await beat(); // B-1.3:不等第一个 30 秒周期 + heartbeatTimer = setInterval(beat, 30_000); // B-1.5 + + client.startSSE((type, data) => { // B-1.4:首次不带 Last-Event-ID + if (type === 'permission_decision') { + handlePermissionDecision(data, mailTools).catch((e) => + log(`权限决策处理失败: ${describeError(e)}`)); + return; + } + if (type !== 'new_mail') return; + if (data?.role && data.role !== 'to' && data.role !== 'cc') return; + const id = data?.mail_id; + if (!id || deliveredMails.has(id)) return; // B-3 第 1 步:去重 + deliveredMails.add(id); + deliverMail(data, 'mail', mailTools).catch((e) => log(`投递 ${id} 失败: ${describeError(e)}`)); + }, log); + + for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => shutdown(sig)); +} + +function shutdown(reason) { + if (shuttingDown) return; + shuttingDown = true; + log(`收到 ${reason},关停中…`); + + if (heartbeatTimer) clearInterval(heartbeatTimer); // B-9.1 + client?.stopSSE(); + + // B-9.2 / N-9:所有未决权限询问 fail closed。 + // 不唤醒的话 pi 侧那些 await 永不返回,整条会话挂死; + // 而默认放行一个没人批准的危险操作,比让它失败严重得多。 + for (const [key, p] of pendingPermissions) { + log(`未决权限 ${key} fail closed`); + p.resolve('shutdown'); + } + pendingPermissions.clear(); + + for (const { session } of sessions.values()) { + try { session.dispose?.(); } catch { /* 关停期的报错没有价值 */ } + } + releaseLock(); + // B-9.3:不发「插件下线」通知邮件 + process.exit(0); +} + +main().catch((e) => { + log(`启动失败: ${describeError(e)}`); + releaseLock(); + process.exit(1); +}); diff --git a/plugins/pi-mail-bridge/src/naming.mjs b/plugins/pi-mail-bridge/src/naming.mjs new file mode 100644 index 0000000..e21b10f --- /dev/null +++ b/plugins/pi-mail-bridge/src/naming.mjs @@ -0,0 +1,115 @@ +/** + * 会话命名的双向一致(C-11 / W-7 / D-5)。 + * + * 一句话:**Gateway 定稿,pi 接受定稿**。 + * + * pi/pi-web 生成名字 ──①观测──▶ 桥 ──②POST /sessions/{id}/sync──▶ Gateway + * pi 的 session_info ◀──④回写──── ③响应里的 final alias + * + * 为什么不能各自命名然后指望撞上:别名在 AgentMail 侧负有寻址唯一性义务 + * (partial unique index + 撞名自动追 -2/-3),pi 侧没有这个约束。 + * 而 `alias_source='manual'` 的会话(人在界面上改过名)永远不接受平台同步, + * `SyncSessionAlias` 会把**当前别名原样返回**。所以只有用响应里的值回写, + * 两边看到的才是同一个名字。单向推送做不到这一点。 + * + * ④ 必须判「与上次写入的值不同」才执行,否则 setSessionName 触发 + * session_info_changed,钩子又去 sync,成自激循环。 + * + * 三个实测出来的约束(探针脚本验证过,见 test/naming.test.mjs 里的注释): + * - pi 首条 assistant 消息落盘前会话文件**不存在**,SessionManager 首次落盘用 + * `openSync(file, "wx")`;桥抢先按路径写会让 pi 侧 flush 抛 EEXIST。 + * → 回写只用 `session.setSessionName()`(走 pi 自己的写入路径), + * 绝不自己拼路径写文件。 + * - 活着的 SessionManager 不 watch 文件;外部改名它看不见,之后它自己 + * append 一条 session_info 反而会盖掉外部的("最后一条生效")。 + * - 空名字是**清除**语义(`appendSessionInfo(" ")` 之后 getSessionName() 变 + * undefined),因此不能用空串表达「无变化」。 + */ + +import { slugFromTitle, isUnusableName } from '../lib/session-snapshot.js'; + +/** + * 决定这一轮要不要向 Gateway 同步命名,以及同步什么。 + * + * 别名的降级阶梯(D-5): + * 1. 平台生成的名字派生的 slug + * 2. 名字不可用(pi-web 的思维链泄漏、纯符号)或**根本没有名字** + * → 退到邮件主题派生 + * 3. 两者都没有 → **不写回**(W-7.2:绝不写占位别名) + * + * 第 2 步里的「根本没有名字」是 pi 的常态而非例外:桥用 SDK 起的会话不经过 + * pi-web 的标题生成器(那个生成器在 pi-web 包里,不在 pi 内核里), + * 因此 `session.sessionName` 一直是 undefined。只等平台命名的话别名永远是空的, + * `name@path.<别名>` 续谈无从下手 —— 实测过这个后果。 + * + * 标题一律用平台原文(不派生、不清洗):`I-4` 说插件只搬运。 + * 唯一的例外是判废 —— 判废的结果是「不写」,不是「改写成别的」。 + * + * 返回值里的 `signature` 是「本次提交内容的指纹」,调用方存下它并在下一轮 + * 作为 `lastSynced` 传回,用来判「没变化就别重复提交」。**不能用平台名字本身** + * 充当这个角色:名字为空时(上面那个常态)它无法区分「还没提交过」与 + * 「提交过、内容没变」,于是每轮心跳都白打一次 sync。 + * + * @param {object} input + * @param {string} input.platformName pi 侧 session_info 里的名字 + * @param {string} input.mailSubject 该会话最近一封来信的主题(兜底用) + * @param {string} input.lastSynced 上一次提交的 signature + * @returns {{skip: true, reason: string} | {skip: false, alias: string, title: string, source: string, signature: string}} + */ +export function planNamingSync({ platformName, mailSubject, lastSynced }) { + const name = String(platformName ?? '').trim(); + const prev = String(lastSynced ?? '').trim(); + + const decide = () => { + if (name && !isUnusableName(name)) { + const alias = slugFromTitle(name); + // 名字看着正常但全是分隔符("..." / "@@@")→ 派生不出别名, + // 但**标题仍然值得写**:subject 那一列不负责寻址,没有字符限制。 + if (alias) return { alias, title: name, source: 'platform' }; + return { alias: '', title: name, source: 'platform-title-only' }; + } + + // 平台名字不可用或不存在:退到邮件主题。它是人写的, + // 天然比模型的思维链靠谱,而 SDK 起的会话本来就没有平台名字。 + const subject = String(mailSubject ?? '').trim(); + if (subject) { + const alias = slugFromTitle(subject); + if (alias) return { alias, title: '', source: 'mail-subject' }; + } + return null; + }; + + const plan = decide(); + + // 什么都没有:不写。宁可让会话保持无别名(数据库允许 NULL), + // 也不要写一个 "session-123" 这样的占位值 —— 那种别名对人毫无指代作用, + // 而且一旦落库就把 alias 位占住了,真正的名字来了也只能追 -2 后缀。 + if (!plan) return { skip: true, reason: 'no-usable-name' }; + + const signature = `${plan.source}:${plan.alias}|${plan.title}`; + if (signature === prev) return { skip: true, reason: 'unchanged' }; + return { ...plan, skip: false, signature }; +} + +/** + * 决定要不要把 Gateway 定稿的别名回写进 pi。 + * + * 回写的三种触发情形: + * - 撞名:提议 `fix-leak`,Gateway 给了 `fix-leak-2` + * - manual 保护:人在界面上改成了 `紧急排查`,Gateway 原样返回它 + * - 规范化:提议里含 `.` `@` `/` 空白,被 normalizeAlias 换成了 `-` + * + * @param {object} input + * @param {string} input.finalAlias Gateway 响应里的 alias + * @param {string} input.currentPiName pi 侧当前的名字 + * @returns {{write: boolean, name: string, reason: string}} + */ +export function planWriteBack({ finalAlias, currentPiName }) { + const final = String(finalAlias ?? '').trim(); + // 服务端没回别名(本次只同步了标题)→ 没有定稿值可写 + if (!final) return { write: false, name: '', reason: 'no-alias-in-response' }; + const cur = String(currentPiName ?? '').trim(); + if (cur === final) return { write: false, name: '', reason: 'already-equal' }; + // 空名字是清除语义,这里 final 非空,所以安全 + return { write: true, name: final, reason: cur ? 'diverged' : 'pi-unnamed' }; +} diff --git a/plugins/pi-mail-bridge/src/session-pool.mjs b/plugins/pi-mail-bridge/src/session-pool.mjs new file mode 100644 index 0000000..6a5a5eb --- /dev/null +++ b/plugins/pi-mail-bridge/src/session-pool.mjs @@ -0,0 +1,137 @@ +/** + * pi 会话池 —— 每条 AgentMail 会话对应一条 pi 会话。 + * + * 为什么桥必须自己持有 pi 会话(而不是写成一个 pi 扩展): + * 扩展被加载进**一条已经存在的**会话里,cwd 由启动 pi 的人决定;而 B-3.1 要求 + * 每封邮件的 to_workspace 成为会话 cwd。扩展做不到「按邮件新开一条 cwd 不同的 + * 会话」,所以桥是一个常驻进程(C-7),用 SDK 的 createAgentSession 起会话。 + * + * 每条会话一套 SettingsManager / ResourceLoader / SessionManager:它们都按 cwd + * 解析项目级配置(.pi/、skills、prompts),共用一份会把 A 项目的配置带进 B 项目。 + */ + +import { createAgentSession, SessionManager, SettingsManager, DefaultResourceLoader, getAgentDir } + from '@earendil-works/pi-coding-agent'; + +/** + * 起一条 pi 会话。 + * + * @param {object} opts + * @param {string} opts.cwd 会话工作目录(已由 resolveWorkspaceCwd 校验过存在) + * @param {any} opts.modelRuntime 共享的 ModelRuntime(建一次很贵,池外传进来) + * @param {any} [opts.model] 指定模型;省略则用 settings 里的默认 + * @param {any[]} opts.customTools 邮件工具(send_mail / read_inbox / …) + * @param {(pi: any) => void} [opts.extension] 内联扩展工厂,用来挂 tool_call 权限钩子 + * @returns {Promise<{session: any, sessionManager: any, diagnostics: any[]}>} + */ +export async function openSession({ cwd, modelRuntime, model, customTools, extension }) { + const agentDir = getAgentDir(); + const settingsManager = SettingsManager.create(cwd, agentDir); + + const resourceLoader = new DefaultResourceLoader({ + cwd, + agentDir, + settingsManager, + // 关掉磁盘上的全局扩展。两个理由: + // 1. 本机的 pi-a2a / pi-acp 在加载时 listen 固定端口(12010/12011), + // 守护进程里加载会 EADDRINUSE,把整条会话拖死。 + // 2. 桥起的会话是给邮件用的,不该继承人类交互用的那套扩展(TUI 命令、 + // 快捷键、状态栏都没有意义)。 + // 邮件工具走 customTools,权限钩子走下面的 extensionFactories。 + noExtensions: true, + extensionFactories: extension + ? [{ name: 'agentmail-bridge', factory: extension }] + : [], + }); + await resourceLoader.reload(); + + const sessionManager = SessionManager.create(cwd); + const created = await createAgentSession({ + cwd, + agentDir, + modelRuntime, + // model 为 undefined 时 SDK 用 settings 里的默认模型,正好对应 + // modelAttemptOrder 里那个 `undefined`(= 不指定、交给平台)。 + ...(model ? { model } : {}), + sessionManager, + settingsManager, + resourceLoader, + customTools, + }); + + return { + session: created.session, + sessionManager, + diagnostics: created.extensionsResult?.diagnostics ?? [], + }; +} + +/** + * 跑一轮并等到真正的结论(C-4 / D-3)。 + * + * `session.prompt()` 的 promise 在**这一轮彻底结束**时才 resolve,所以不需要 + * 额外订阅 agent_end 去等。但它 resolve 了**不代表模型跑成功了** —— + * 判定交给 classifyTurnOutcome(三条互不重叠的失败信号,见那里的注释)。 + * + * 60 秒超时算成功(与另两个插件同一取舍):长任务很正常,把它判成失败会 + * 换模型重跑一遍,等于同一封邮件跑两次。超时只是「不再等着上报结论」, + * 会话仍在跑,轮次结束后 agent_end 会照常触发自动转发。 + * + * 会话正在跑时走排队(返回 queued),**不能**在那种情况下判结论: + * prompt 排完队就 resolve,此时 session.messages 里最后一条是**上一轮**的, + * 拿它判定会把上一轮的成败当成这一轮的。 + * + * @param {any} session + * @param {string} promptText + * @param {number} timeoutMs + * @returns {Promise<{ok: boolean, error: string, aborted: boolean, timedOut: boolean, queued: boolean}>} + */ +export async function runTurn(session, promptText, timeoutMs = 60_000) { + const { classifyTurnOutcome } = await import('./turn.mjs'); + + // 排队分支:模型还在说话时又来一封邮件。 + // + // streamingBehavior 必选,缺了 prompt 直接抛 + // "Agent is already processing. Specify streamingBehavior…"。 + // 取 followUp 而不是 steer:steer 会把当前这一轮打断, + // 而当前这一轮正在处理**上一封邮件** —— 那封邮件的发件人也在等回信。 + if (session.isStreaming) { + await session.prompt(promptText, { streamingBehavior: 'followUp' }); + return { ok: true, error: '', aborted: false, timedOut: false, queued: true }; + } + + let timer = null; + const timeout = new Promise((resolve) => { + timer = setTimeout( + () => resolve({ ok: true, error: '', aborted: false, timedOut: true, queued: false }), + timeoutMs, + ); + }); + + const run = session.prompt(promptText) + .then(() => ({ ...classifyTurnOutcome({ messages: session.messages }), timedOut: false, queued: false })) + .catch((e) => ({ ...classifyTurnOutcome({ error: e }), timedOut: false, queued: false })); + + try { + return await Promise.race([run, timeout]); + } finally { + if (timer) clearTimeout(timer); + } +} + +/** + * 续谈:往一条已经存在的会话里追加一轮。 + * + * 这就是 `runTurn` —— 不需要第二个函数。 + * + * **不能**用 `session.followUp()`:那个方法只往 followUpQueue 里塞消息, + * 队列**只在运行中的轮次末尾**被 drain(pi-agent-core/agent.js 的 run 循环, + * 以及 `continue()`)。会话空闲时(上一轮早已结束)塞进去的消息永远没人取, + * 于是这封邮件既没有回信也没有报错 —— 实测踩过:日志打了「续谈」, + * 收件箱里只有来信没有回复。 + * + * `runTurn` 按 `isStreaming` 分流,两种状态都正确: + * - 空闲 → `prompt()` 直接起一轮 + * - 正在跑 → `prompt(text, {streamingBehavior:'followUp'})` 排到当轮之后 + */ +export { runTurn as followUpTurn }; diff --git a/plugins/pi-mail-bridge/src/tools.mjs b/plugins/pi-mail-bridge/src/tools.mjs new file mode 100644 index 0000000..b427182 --- /dev/null +++ b/plugins/pi-mail-bridge/src/tools.mjs @@ -0,0 +1,355 @@ +/** + * 邮件工具(T-1..T-6)—— 注册给 pi 里的模型。 + * + * pi 的工具定义用 TypeBox schema,这里直接写等价的 JSON Schema 字面量: + * TypeBox 的 `Type.Object({...})` 产出的就是这个形状,而桥是 .mjs(无编译步骤), + * 少一个运行时依赖。 + * + * `execute(toolCallId, params, signal, onUpdate, ctx)` 的 ctx 是 ExtensionContext, + * 由此可以拿到 `ctx.sessionManager.getSessionId()` —— 这就是 C-6 要求的 + * 「工具能拿到当前会话 id」,自动转发去重(B-5.3)靠它把发信记到正确的会话上。 + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import { basename } from 'node:path'; +import { + renderInbox, + idsToMarkRead, + formatSize, + DEFAULT_INBOX_STATUS, + DEFAULT_INBOX_LIMIT, +} from '../lib/inbox-format.js'; +import { + renderNameSuggestions, + renderPathSuggestions, + renderSessionSuggestions, + renderParticipants, + renderContacts, + renderThread, +} from '../lib/discovery.js'; +import { noteExplicitSend } from '../lib/relay-dedup.js'; + +const text = (s) => ({ content: [{ type: 'text', text: s }] }); + +/** + * @param {object} deps + * @param {import('./gateway.mjs').GatewayClient} deps.client + * @param {(msg: string) => void} deps.log + * @param {string} [deps.agentName] 自己的 Agent 名。收件箱渲染靠它判定 + * 「我是收件人还是抄送方」并给出可投递地址。 + */ +export function createMailTools({ client, log, agentName = '' }) { + const sendMail = { + name: 'send_mail', + label: 'SendMail', + description: + '发送邮件。三维地址 name@path.session:省略 session 投递到默认会话,' + + '.new 强制新建,.具体别名 必须已存在。回复来信请传 reply_to。', + parameters: { + type: 'object', + properties: { + to: { type: 'string', description: '收件人三维地址,如 admin@/home/program/x' }, + subject: { type: 'string', description: '邮件主题' }, + body: { type: 'string', description: '邮件正文(Markdown)' }, + cc: { type: 'string', description: '抄送,逗号分隔多个三维地址' }, + reply_to: { type: 'string', description: '回复某封邮件时传其 mail_id' }, + session_alias: { type: 'string', description: '给新会话命名(仅 .new 时生效)' }, + attachment_ids: { + type: 'array', + items: { type: 'string' }, + description: '附件 ID 列表(先用 upload_attachment 取得)', + }, + }, + required: ['to', 'subject', 'body'], + additionalProperties: false, + }, + async execute(_id, params, _signal, _onUpdate, ctx) { + const result = await client.post('/mail/send', { + to: params.to, + subject: params.subject, + body: params.body, + cc: params.cc || '', + reply_to: params.reply_to || '', + session_alias: params.session_alias || '', + attachment_ids: params.attachment_ids || [], + // 这里**不带 relay**(N-5):模型的自主发信要计配额, + // 免配额通道只给插件代劳的转发(总结、权限询问、故障报告)。 + }); + // 记下「模型这一轮亲手发过信」,供 B-5.3 让位判定。 + // 会话 id 从 ctx 取:工具不知道自己被哪条会话调用,就没法正确归属。 + noteExplicitSend(ctx?.sessionManager?.getSessionId?.(), params.to, params.reply_to); + const budget = typeof result.budget_remaining === 'number' + ? ` 本任务剩余 ${result.budget_remaining}/${result.budget_max} 个来回。` + : ''; + return text(`邮件已发送(ID: ${result.mail_id})${budget}`); + }, + }; + + const readInbox = { + name: 'read_inbox', + label: 'ReadInbox', + description: + '查阅收件箱中的邮件。收到新邮件通知后应立即调用此工具。' + + '每封含 mail_id、发件人、主题、正文与附件清单(带 attachment_id)。', + parameters: { + type: 'object', + properties: { + status: { type: 'string', description: '过滤条件 unread|all,默认 unread' }, + limit: { type: 'number', description: '返回数量,默认 5' }, + }, + additionalProperties: false, + }, + async execute(_id, params) { + const status = params.status || DEFAULT_INBOX_STATUS; + const { mails } = await client.get( + `/mail/inbox?status=${encodeURIComponent(status)}&limit=${params.limit || DEFAULT_INBOX_LIMIT}`, + ); + + // 渲染与已读策略走共用模块:与另两个平台必须一致, + // 每条规则对应过一次真实的错误行为(见 lib/inbox-format.js)。 + // + // 传 agentName 才能判定身份并给出可投递地址 —— 不传的话模型只能 + // 从抄送行里抄一个 `.new`,而那是一次性的,回过去只会再建一条平行会话。 + const listed = renderInbox(mails, 200, agentName); + + const ids = idsToMarkRead(params.status, mails); + if (ids.length) { + // 标记失败不该让 read_inbox 失败:正文已经取到了, + // 代价只是下次重复看到,比丢掉这次读取轻。 + client.post('/mail/read', { mail_ids: ids }).catch((e) => + log(`[pi-mail-bridge] 标记已读失败: ${e?.message || e}`)); + } + return text(listed); + }, + }; + + const forwardMail = { + name: 'forward_mail', + label: 'ForwardMail', + description: + '转发一封邮件给新的收件人(引用原文)。与回复不同:回复落回原会话,' + + '转发按目标地址另行定位会话。只能转发自己参与过的邮件。', + parameters: { + type: 'object', + properties: { + mail_id: { type: 'string', description: '要转发的邮件 ID(从 read_inbox 获得)' }, + to: { type: 'string', description: '新收件人的三维地址' }, + comment: { type: 'string', description: '转发说明,置于引用原文之前' }, + cc: { type: 'string', description: '抄送,逗号分隔多个三维地址' }, + subject: { type: 'string', description: '自定义主题;留空则自动加 Fwd: 前缀' }, + session_alias: { type: 'string', description: '仅在目标地址以 .new 结尾时生效:给新会话命名' }, + }, + required: ['mail_id', 'to'], + additionalProperties: false, + }, + async execute(_id, params, _signal, _onUpdate, ctx) { + // 路径带 mail_id(POST /mail/{id}/forward),不是请求体里的字段 + const result = await client.post(`/mail/${params.mail_id}/forward`, { + to: params.to, + comment: params.comment || '', + cc: params.cc || '', + subject: params.subject || '', + session_alias: params.session_alias || '', + }); + noteExplicitSend(ctx?.sessionManager?.getSessionId?.(), params.to, ''); + return text(`已转发。新 Mail ID: ${result.mail_id},Session: ${result.session_id}`); + }, + }; + + const uploadAttachment = { + name: 'upload_attachment', + label: 'UploadAttachment', + description: '上传本地文件作为邮件附件,返回 attachment_id。', + parameters: { + type: 'object', + properties: { + file_path: { type: 'string', description: '本地文件的绝对路径' }, + }, + required: ['file_path'], + additionalProperties: false, + }, + async execute(_id, params) { + const buf = await readFile(params.file_path); + const a = await client.uploadFile(buf, basename(params.file_path) || 'file'); + return text( + `已上传 ${a.filename}(${formatSize(a.size_bytes)})。attachment_id: ${a.attachment_id}`, + ); + }, + }; + + const downloadAttachment = { + name: 'download_attachment', + label: 'DownloadAttachment', + description: '下载邮件附件到本地文件。', + parameters: { + type: 'object', + properties: { + attachment_id: { type: 'string', description: '附件 ID(read_inbox 的清单里给出)' }, + save_path: { type: 'string', description: '保存路径' }, + }, + required: ['attachment_id', 'save_path'], + additionalProperties: false, + }, + async execute(_id, params) { + const buf = await client.downloadFile(params.attachment_id); + await writeFile(params.save_path, buf); + return text(`已保存到 ${params.save_path}(${formatSize(buf.length)})`); + }, + }; + + // ─── 寻址发现工具(读 Agent 侧只读端点)─── + // + // 在这一组之前,send_mail 的 to 是个只能靠记忆拼写的自由文本字段, + // 而拼错不报错:生产上另一个平台猜了 `opencode@/home`,投递成功, + // 但那不是 opencode 的工作目录,静默变成了新会话的 workspace。 + // + // 渲染逻辑在 lib/discovery.js(三平台共用)。 + + const suggestAddress = { + name: 'suggest_address', + label: 'SuggestAddress', + description: + '查询可用的收件人地址,用于精准发信。不带参数给候选收件人名;带 name 给它可用的' + + '工作目录;name+path 都带则给该目录下可续谈的会话与现成地址。' + + '**发信前应先用它确认地址**,不要凭记忆拼写 —— 拼错不会报错,只会投到别的会话。', + parameters: { + type: 'object', + properties: { + name: { type: 'string', description: '收件人名;留空则列出所有候选收件人' }, + path: { type: 'string', description: '工作目录;与 name 同时给出才列会话' }, + }, + additionalProperties: false, + }, + async execute(_id, params) { + const name = String(params.name || '').trim(); + const path = String(params.path || '').trim(); + const qs = new URLSearchParams(); + if (name) qs.set('name', name); + if (path) qs.set('path', path); + const data = await client.get(`/agent/contacts/suggest?${qs.toString()}`); + // 按服务端回的 kind 分派而不是按本地参数:省略与传空串在服务端 + // 是同一个意思,但「哪一段该渲染成什么」只有服务端知道。 + switch (data?.kind) { + case 'name': return text(renderNameSuggestions(data.suggestions)); + case 'path': return text(renderPathSuggestions(data.suggestions, name)); + default: return text(renderSessionSuggestions(data, name, path)); + } + }, + }; + + const listContacts = { + name: 'list_contacts', + label: 'ListContacts', + description: + '列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。' + + '用于回答「我还有什么没处理」与「上次跟某人聊的那条线索地址是什么」。', + parameters: { + type: 'object', + properties: { + limit: { type: 'number', description: '最多列出多少条,默认 20' }, + }, + additionalProperties: false, + }, + async execute(_id, params) { + const data = await client.get('/agent/contacts'); + return text(renderContacts(data, params.limit || 20)); + }, + }; + + const sessionParticipants = { + name: 'session_participants', + label: 'SessionParticipants', + description: + '列出某条会话的全部参与方(发件人/收件人/抄送方)及各自的可投递地址,' + + '并标出谁还没回应。**要回给抄收方或向第三方转达时先用它拿地址**。', + parameters: { + type: 'object', + properties: { + session_id: { type: 'string', description: '会话 ID' }, + }, + required: ['session_id'], + additionalProperties: false, + }, + async execute(_id, params) { + const data = await client.get(`/agent/sessions/${params.session_id}/participants`); + return text(renderParticipants(data)); + }, + }; + + const readThread = { + name: 'read_thread', + label: 'ReadThread', + description: + '查看一封邮件所在线索的完整往来(谁回了谁、谁还没回)。多方抄送协作时' + + '用它确认别人已经说了什么,避免重复提问或重复汇报。', + parameters: { + type: 'object', + properties: { + mail_id: { type: 'string', description: '线索中任一封邮件的 ID' }, + offset: { type: 'number', description: '分页偏移,续取时传上次返回的 next_offset' }, + }, + required: ['mail_id'], + additionalProperties: false, + }, + async execute(_id, params) { + const qs = params.offset ? `?offset=${params.offset}` : ''; + const data = await client.get(`/agent/mail/${params.mail_id}/thread${qs}`); + return text(renderThread(data, agentName)); + }, + }; + + const readMail = { + name: 'read_mail', + label: 'ReadMail', + description: + '读一封邮件的完整内容,含收件人、抄送清单、附件与每个参与方的可投递地址。' + + '收件箱只给摘要;要回给抄收方就得先看清这封信发给了谁。', + parameters: { + type: 'object', + properties: { + mail_id: { type: 'string', description: '邮件 ID' }, + }, + required: ['mail_id'], + additionalProperties: false, + }, + async execute(_id, params) { + const data = await client.get(`/agent/mail/${params.mail_id}`); + const m = data?.mail || {}; + const lines = [ + `发件人: ${m.from_name || '?'}`, + `收件人: ${m.to_name || '?'}${m.to_workspace ? '@' + m.to_workspace : ''}`, + `主题: ${m.subject || '(无主题)'}`, + `会话: #${data.session_alias || '未命名'}(session_id: ${m.session_id || '?'})`, + ]; + if (Array.isArray(m.cc_list) && m.cc_list.length) { + lines.push(`抄送: ${m.cc_list.map(c => c?.raw || c?.name).join('、')}`); + } + if (Array.isArray(m.attachments) && m.attachments.length) { + lines.push(`附件: ${m.attachments + .map(a => `${a.filename}(${formatSize(a.size_bytes)}, id=${a.attachment_id})`) + .join('、')}`); + } + lines.push('', m.body || '(空正文)', ''); + if (Array.isArray(data.participants) && data.participants.length) { + lines.push('可投递地址: ' + data.participants + .filter(p => p.address && p.name !== agentName) + .map(p => `${p.address}(${p.role})`) + .join('、')); + } + if (data.reply_address) { + lines.push(`回信给发件人用 ${data.reply_address},或传 reply_to=${m.mail_id}。`); + } + return text(lines.join('\n')); + }, + }; + + // 故意**没有** request_permission(N-1 / T-7): + // 权限询问由 tool_call 钩子接管 —— 模型可能忘了调,也可能在不需要时乱调, + // 而真正被 pi 拦下的那一次才是事实。 + return [ + sendMail, readInbox, readMail, forwardMail, + uploadAttachment, downloadAttachment, + // 寻址发现:让模型选地址而不是拼地址 + suggestAddress, listContacts, sessionParticipants, readThread, + ]; +} diff --git a/plugins/pi-mail-bridge/src/turn.mjs b/plugins/pi-mail-bridge/src/turn.mjs new file mode 100644 index 0000000..7fa660a --- /dev/null +++ b/plugins/pi-mail-bridge/src/turn.mjs @@ -0,0 +1,187 @@ +/** + * pi 侧的纯逻辑:提示词、轮次结论判定、消息文本提取、回信主题。 + * + * 单独一个文件而不是塞进 index.mjs:这几件事每一件都对应过一次真实的错误行为, + * 而它们都不需要 pi SDK —— 因此可以直接用 node --test 钉住,不必起模型。 + * + * 与 lib/ 的区别:lib/ 下的文件三个平台**逐字节相同**(deploy/check-shared-libs.sh + * 校验),这里的东西是 pi 专属的(消息形状、stopReason 语义),不参与那个约束。 + */ + +/** 去掉已有的 Re: 前缀,避免 Re: Re: Re: 叠加。 */ +export function stripRe(subject) { + return String(subject ?? '').replace(/^(\s*Re:\s*)+/i, ''); +} + +/** 自动转发时的回信主题。 */ +export function replySubject(subject) { + const base = stripRe(subject).trim(); + return base ? `Re: ${base}` : '本轮工作总结'; +} + +/** + * 取最后一条 assistant 消息里的纯文本。 + * + * pi 的消息形状:`{ role, content: [{ type: 'text'|'thinking'|'toolCall', ... }] }`。 + * + * **只取 `type === 'text'`**(B-5.1):thinking 块是思考过程,转进邮件对收件人 + * 没有意义,而且经常包含「我先假设…」这类会被误读为结论的话。 + * + * 从后往前找第一条**有文本**的 assistant 消息,而不是「最后一条 assistant 消息」: + * 一轮的收尾常常是纯工具调用消息(content 里只有 toolCall), + * 取到它会得到空字符串,于是 B-5.4 判成「无话可说」而漏掉真正的结论。 + * + * @param {any[]} messages `session.messages` 或 `agent_end` 事件里的 messages + * @returns {string} 纯文本,找不到时为空串 + */ +export function lastAssistantText(messages) { + const list = Array.isArray(messages) ? messages : []; + for (let i = list.length - 1; i >= 0; i--) { + const m = list[i]; + if (m?.role !== 'assistant') continue; + const blocks = Array.isArray(m.content) ? m.content : []; + const text = blocks + .filter((b) => b?.type === 'text' && typeof b.text === 'string') + .map((b) => b.text) + .join('\n') + .trim(); + if (text) return text; + } + return ''; +} + +/** + * 判定这一轮到底跑起来了没有(C-4 / D-3)。 + * + * 「submit 返回了」不等于「模型跑了」—— 这是两次适配都踩过的坑(契约 9.2)。 + * pi 侧有三条互不重叠的失败信号,必须全查: + * + * 1. `prompt()` 直接 reject。凭证缺失就是这条:实测无 API key 的 provider + * 抛 `No API key found for amazon-bedrock.`,一个事件都不发。 + * 2. 最后一条 assistant 消息 `stopReason === 'error'`,原因在 `errorMessage`。 + * 模型请求发出去了但上游报错走这条。 + * 3. 一条 assistant 消息都没有。既没抛也没报错却什么都没产出, + * 当成功处理会让 B-5 转发一个空字符串回去 —— 发件人收到一封空邮件。 + * + * `stopReason: 'aborted'` **算失败**但要区别对待:那是有人主动打断 + * (Esc / dispose),不是模型故障,因此不该触发换模型重试。 + * + * @param {{error?: any, messages?: any[]}} input + * @returns {{ok: boolean, error: string, aborted: boolean}} + */ +export function classifyTurnOutcome({ error, messages } = {}) { + if (error) { + return { ok: false, error: describeError(error), aborted: false }; + } + const list = Array.isArray(messages) ? messages : []; + let lastAssistant = null; + for (let i = list.length - 1; i >= 0; i--) { + if (list[i]?.role === 'assistant') { lastAssistant = list[i]; break; } + } + if (!lastAssistant) { + return { ok: false, error: '模型没有产出任何回复(一条 assistant 消息都没有)', aborted: false }; + } + const stop = lastAssistant.stopReason; + if (stop === 'error') { + return { + ok: false, + error: describeError(lastAssistant.errorMessage) || '模型报错但未给出原因', + aborted: false, + }; + } + if (stop === 'aborted') { + return { ok: false, error: '本轮被中断(aborted)', aborted: true }; + } + // 'stop' 正常收尾;'length' 是被 max tokens 截断 —— 内容不完整但**是模型的产出**, + // 判成失败会让一封「说了一半」的回信变成「换个模型重试」,那更糟。 + // 'toolUse' 出现在这里说明轮次在等工具,正常流程下 agent_end 时不会是它。 + return { ok: true, error: '', aborted: false }; +} + +/** 把各种形态的错误拼成一行可读文本。 */ +export function describeError(err) { + if (!err) return ''; + if (typeof err === 'string') return err.split('\n')[0].trim(); + const parts = [err.code, err.message ?? String(err)].filter(Boolean); + return parts.join(': ').split('\n')[0].trim() || '未知错误'; +} + +/** + * 投递一封邮件时给模型的提示词。 + * + * 三条硬要求(B-3.4 / B-3.5): + * - 写明「回信由插件自动发」。不说的话模型会自己调 send_mail, + * 而插件在轮次结束时也会转发一次 —— 同一件事两封邮件(生产里真实发生过)。 + * - 带上 mail_id,让模型能自己定位这一封。 + * - 让它先调 read_inbox:事件里只有主题,正文和附件清单都在收件箱里。 + * + * @param {{agentName: string, data: any, kind: string, reused: boolean}} input + * @returns {string} + */ +export function buildMailPrompt({ agentName, data, kind, reused }) { + if (kind === 'permission') { + return [ + `你之前发起的权限请求已有结论:${data?.decision ?? '(未给出)'}` + + `(决策人:${data?.decided_by || '用户'})。`, + `请据此继续后续工作。`, + ].join('\n'); + } + + const head = reused + ? '本会话收到一封新邮件(AgentMail 续谈)。' + : '你收到一封新邮件(AgentMail)。'; + const lines = [ + head, + '', + `发件人:${data?.from_name || 'unknown'}`, + `主题:${data?.subject || '(无主题)'}`, + `邮件 ID:${data?.mail_id || 'unknown'}`, + ]; + if (!reused) lines.push(`身份:你是 ${agentName}`); + // 服务端算好的回信地址(`new_mail` 的 reply_address)。带上它是因为模型 + // **确实会**自己发信 —— 尤其是要抄送第三方、或分多封交代不同的事时。 + // 让它自己拼三维地址的话,`.new` 会被拼进去,于是回信静默开出一条新会话, + // 原来的线索里再无下文。 + if (data?.reply_address) { + lines.push(`回信地址:${data.reply_address}(如需自己发信,用这个地址)`); + } + if (data?.catchup) { + // 补投的邮件要说明,否则模型会以为这是刚到的、按「立即响应」的语气回 + lines.push('说明:这是插件离线期间积压的邮件,现在补投给你。'); + } + lines.push( + '', + '请先调用 read_inbox 读取完整正文(附带附件清单,如有附件可用 download_attachment 取回),', + '然后处理其中的请求。', + '回信不用你自己发:把这一轮做完、把结论说出来就行,插件会把你最后那段话作为回信发出去。', + ); + return lines.join('\n'); +} + +/** + * 自动转发的幂等键(W-6 / B-5.2)。 + * + * 用 pi 侧的会话 id + 会话树叶子条目 id:两者都由 pi 生成且落盘, + * 插件重启后重放同一轮也会得到同一个键。用「消息条数」之类的派生量不行 —— + * 压缩(compaction)会改变条数,于是同一轮结论换了个键,被当成新消息再转一次。 + * + * @param {string} piSessionId + * @param {string} leafId + * @returns {string} + */ +export function relayKeyFor(piSessionId, leafId) { + return `${piSessionId || 'unknown'}:${leafId || 'noleaf'}`; +} + +/** + * pi 会话文件名里的 cwd 编码(`/home/x` → `--home-x--`)。 + * + * 只用于日志与排查提示,不参与任何决策 —— 真正的路径一律用 SDK 给的 + * `session.sessionFile`。自己拼路径去读会话文件是错的:编码规则属于 pi。 + * + * @param {string} cwd + * @returns {string} + */ +export function sessionDirLabel(cwd) { + return `--${String(cwd ?? '').replace(/\//g, '-')}--`; +} diff --git a/plugins/pi-mail-bridge/test/addressing.test.mjs b/plugins/pi-mail-bridge/test/addressing.test.mjs new file mode 100644 index 0000000..279f80d --- /dev/null +++ b/plugins/pi-mail-bridge/test/addressing.test.mjs @@ -0,0 +1,145 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + formatAddress, + roleOf, + replyAddressFor, + selfAddressFor, + participantsOfMail, +} from '../lib/addressing.js'; + +// 地址拼错不会报错,只会投到别处 —— 所以这一组测试全部落在 +// 「拼出来的东西还能不能被正确解析回三段」上。 + +test('formatAddress: 空 path 仍保留 @ 与 .', () => { + // 生产事故:朴素拼接得到 admin.silent-harbor,没有 @, + // 整串被 ParseAddress 当成名字,session 位静默丢失。 + assert.equal(formatAddress('admin', '', 'silent-harbor'), 'admin@.silent-harbor'); +}); + +test('formatAddress: 省略 session 位', () => { + assert.equal(formatAddress('dsh', '/home/program/agentmail', ''), 'dsh@/home/program/agentmail'); + // 名字与 path 都有但都不带会话 → 默认会话语义 + assert.equal(formatAddress('dsh', '', ''), 'dsh'); +}); + +test('formatAddress: path 含 . 与 / 时仍按最后一个 . 切', () => { + // path 里允许 . 与 /,切分靠最后一个 . —— 拼出来的必须满足这个约定 + const addr = formatAddress('bot', '/srv/app.v2', 'fix-leak'); + assert.equal(addr, 'bot@/srv/app.v2.fix-leak'); + assert.equal(addr.slice(addr.lastIndexOf('.') + 1), 'fix-leak'); +}); + +test('formatAddress: 名字为空返回空串而不是残缺地址', () => { + // 返回 "@/path.alias" 会被投递端当成缺名字报错, + // 但那是在很后面才发现;这里直接给空串让调用方立刻看出没法拼。 + assert.equal(formatAddress('', '/p', 'a'), ''); + assert.equal(formatAddress(null, '/p', 'a'), ''); +}); + +test('formatAddress: 去掉首尾空白', () => { + assert.equal(formatAddress(' dsh ', ' /home ', ' alias '), 'dsh@/home.alias'); +}); + +const ccMail = { + from_name: 'admin', + to_name: 'dsh', + to_workspace: '/home/program/llmsproxy', + cc_list: [{ name: 'opencode', path: '/home', session: 'new', raw: 'opencode@/home.new' }], + session_alias: 'silent-harbor', +}; + +test('roleOf: 区分主收件人与抄送方', () => { + // 被抄送方与主收件人职责不同:线上那封联调邮件里 dsh 负责汇报、 + // opencode 只提供信息。不区分身份两方都会以为自己是负责人。 + assert.equal(roleOf(ccMail, 'dsh'), 'to'); + assert.equal(roleOf(ccMail, 'opencode'), 'cc'); + assert.equal(roleOf(ccMail, 'someone-else'), 'unknown'); +}); + +test('roleOf: 名字为空时不猜', () => { + assert.equal(roleOf(ccMail, ''), 'unknown'); + assert.equal(roleOf(ccMail, undefined), 'unknown'); +}); + +test('replyAddressFor: 用会话别名而非原地址的 .new', () => { + // 关键回归:把 .new 原样当回信地址会再建一条平行会话。 + const addr = replyAddressFor(ccMail); + assert.equal(addr, 'admin@.silent-harbor'); + assert.ok(!addr.endsWith('.new'), '回信地址不得以 .new 结尾'); +}); + +test('replyAddressFor: 发件人一侧不带 path', () => { + // Agent 回信时 from_workspace 存的是 Agent 名而不是路径, + // 拿它拼会得到 dsh@dsh.alias —— 投不出去。 + const mail = { from_name: 'dsh', from_workspace: 'dsh', session_alias: 'x' }; + assert.equal(replyAddressFor(mail), 'dsh@.x'); +}); + +test('replyAddressFor: 无别名时退回默认会话形式', () => { + const mail = { from_name: 'admin', session_alias: '' }; + const addr = replyAddressFor(mail); + assert.equal(addr, 'admin'); + // 调用方靠有没有 . 判断这是不是「投回同一条会话」 + assert.ok(!addr.includes('.'), '默认会话形式不含 session 位'); +}); + +test('selfAddressFor: 抄送方取自己那个地址的 path', () => { + // to_workspace 是主收件人的工作目录。抄送方拿它当自己的 path, + // 「我是谁」这句话就指向了别人的目录。 + assert.equal(selfAddressFor(ccMail, 'opencode'), 'opencode@/home.silent-harbor'); + assert.equal(selfAddressFor(ccMail, 'dsh'), 'dsh@/home/program/llmsproxy.silent-harbor'); +}); + +test('participantsOfMail: 抄送方的 path 是自己那个', () => { + const parts = participantsOfMail(ccMail, 'dsh'); + const byName = Object.fromEntries(parts.map(p => [p.name, p])); + + assert.equal(byName.opencode.path, '/home'); + assert.equal(byName.opencode.address, 'opencode@/home.silent-harbor'); + assert.equal(byName.dsh.path, '/home/program/llmsproxy'); + // 发件人 path 留空,理由同 replyAddressFor + assert.equal(byName.admin.address, 'admin@.silent-harbor'); +}); + +test('participantsOfMail: 地址一律用会话别名,不带 .new', () => { + // cc_list 里原本记的是 opencode@/home.new。参与方地址必须换成别名, + // 否则「回给抄收方」这个动作每次都会新开会话。 + for (const p of participantsOfMail(ccMail, 'dsh')) { + assert.ok(!p.address.endsWith('.new'), `${p.name} 的地址仍是 .new: ${p.address}`); + } +}); + +test('participantsOfMail: 自己被标记而不是被剔除', () => { + // 剔掉的话模型无法确认这封信是不是也发给了自己, + // 也就无法判断自己该不该回。 + const parts = participantsOfMail(ccMail, 'opencode'); + const me = parts.find(p => p.name === 'opencode'); + assert.ok(me, '自己应出现在参与方列表里'); + assert.equal(me.is_self, true); + assert.equal(parts.filter(p => p.is_self).length, 1); +}); + +test('participantsOfMail: 角色齐全且顺序为 from → to → cc', () => { + // 主收件人稳定排在抄送方之前,模型据此判断谁是负责人、谁是配合方 + const parts = participantsOfMail(ccMail, 'dsh'); + assert.deepEqual(parts.map(p => p.role), ['from', 'to', 'cc']); +}); + +test('participantsOfMail: 无抄送时只有两方', () => { + const mail = { from_name: 'admin', to_name: 'dsh', to_workspace: '/w', session_alias: 'a' }; + const parts = participantsOfMail(mail, 'dsh'); + assert.equal(parts.length, 2); +}); + +test('participantsOfMail: 跳过空名字条目', () => { + // cc_list 里出现空对象(历史数据或解析残缺)不该产出一个 address 为空的参与方 + const mail = { + from_name: 'admin', to_name: 'dsh', to_workspace: '/w', + cc_list: [{ name: '', path: '/x' }, {}], + session_alias: 'a', + }; + const parts = participantsOfMail(mail, 'dsh'); + assert.equal(parts.length, 2); + for (const p of parts) assert.notEqual(p.address, ''); +}); diff --git a/plugins/pi-mail-bridge/test/catchup.test.mjs b/plugins/pi-mail-bridge/test/catchup.test.mjs new file mode 100644 index 0000000..23d38ab --- /dev/null +++ b/plugins/pi-mail-bridge/test/catchup.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { MAX_CATCHUP, mailToEvent, selectCatchup } from '../lib/catchup.js'; + +const mail = (over = {}) => ({ + mail_id: 'm1', + session_id: 's1', + from_name: 'admin', + subject: '主题', + mail_type: 'normal', + to_workspace: '/tmp/ws', + ...over, +}); + +test('mailToEvent 产出与 SSE new_mail 同形的对象', () => { + const ev = mailToEvent(mail()); + // 投递侧读的就是这几个键,形状不一致会让补拉那条路径静默地少带信息 + for (const k of ['mail_id', 'session_id', 'from_name', 'subject', 'mail_type', 'to_workspace']) { + assert.ok(k in ev, `缺少 ${k}`); + } + assert.equal(ev.role, 'to'); + assert.equal(ev.catchup, true); +}); + +test('mailToEvent 对缺字段的行给出空串而非 undefined', () => { + const ev = mailToEvent({}); + assert.equal(ev.mail_id, ''); + assert.equal(ev.to_workspace, ''); + assert.equal(ev.mail_type, 'normal'); +}); + +test('已经通过 SSE 投过的不再补投', () => { + const mails = [mail({ mail_id: 'a' }), mail({ mail_id: 'b' })]; + const got = selectCatchup(mails, new Set(['a'])); + assert.deepEqual(got.map(e => e.mail_id), ['b']); +}); + +test('按时间正序补投(收件箱是倒序返回的)', () => { + // 收件箱:新的在前 + const mails = [mail({ mail_id: 'new' }), mail({ mail_id: 'mid' }), mail({ mail_id: 'old' })]; + const got = selectCatchup(mails, new Set()); + assert.deepEqual( + got.map(e => e.mail_id), + ['old', 'mid', 'new'], + '先来的邮件必须先处理,否则同一会话里的上下文顺序是乱的', + ); +}); + +test('permission 类邮件不补投', () => { + const mails = [mail({ mail_id: 'p', mail_type: 'permission' }), mail({ mail_id: 'n' })]; + const got = selectCatchup(mails, new Set()); + assert.deepEqual(got.map(e => e.mail_id), ['n']); +}); + +test('超过上限的部分留在收件箱里', () => { + const mails = Array.from({ length: MAX_CATCHUP + 4 }, (_, i) => mail({ mail_id: 'm' + i })); + const got = selectCatchup(mails, new Set()); + assert.equal(got.length, MAX_CATCHUP, '一次补拉不该把几十封邮件同时放出去'); +}); + +test('上限可显式压到 0(用于禁用补拉)', () => { + const got = selectCatchup([mail()], new Set(), 0); + assert.deepEqual(got, []); +}); + +test('空输入与非数组不炸', () => { + assert.deepEqual(selectCatchup([], new Set()), []); + assert.deepEqual(selectCatchup(undefined, new Set()), []); + assert.deepEqual(selectCatchup(null, new Set()), []); +}); + +test('没有 mail_id 的行跳过', () => { + const got = selectCatchup([mail({ mail_id: '' }), mail({ mail_id: 'ok' })], new Set()); + assert.deepEqual(got.map(e => e.mail_id), ['ok']); +}); + +test('seen 传 undefined 时不去重也不报错', () => { + const got = selectCatchup([mail({ mail_id: 'x' })], undefined); + assert.deepEqual(got.map(e => e.mail_id), ['x']); +}); diff --git a/plugins/pi-mail-bridge/test/discovery.test.mjs b/plugins/pi-mail-bridge/test/discovery.test.mjs new file mode 100644 index 0000000..3e3c75e --- /dev/null +++ b/plugins/pi-mail-bridge/test/discovery.test.mjs @@ -0,0 +1,218 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + renderNameSuggestions, + renderPathSuggestions, + renderSessionSuggestions, + renderParticipants, + renderContacts, + renderThread, +} from '../lib/discovery.js'; + +// 这一组渲染的唯一目的是让模型**不要自己拼地址**。 +// 所以断言集中在两点:给出的地址能原样使用;以及模型知道下一步该查什么。 + +test('renderNameSuggestions 只给名字并指向下一步', () => { + // 此时还不知道 path 与 session,硬拼裸名字地址会投到「默认会话」—— + // 那不一定是调用方想要的那条。 + const got = renderNameSuggestions(['opencode', 'admin']); + assert.match(got, /opencode/); + assert.match(got, /admin/); + assert.match(got, /suggest_address/, '要告诉模型下一步查什么'); +}); + +test('renderNameSuggestions 空列表给明确文案', () => { + assert.match(renderNameSuggestions([]), /没有可投递的收件人/); + assert.match(renderNameSuggestions(undefined), /没有可投递的收件人/); +}); + +test('renderPathSuggestions 空列表要说清「仍然能发」', () => { + // 不解释的话模型会卡在这一步,或者编一个路径出来。 + const got = renderPathSuggestions([], 'admin'); + assert.match(got, /可以留空/); + assert.match(got, /admin/); +}); + +test('renderPathSuggestions 列出目录并指向下一步', () => { + const got = renderPathSuggestions(['/home', '/home/program/agentmail'], 'opencode'); + assert.match(got, /\/home\/program\/agentmail/); + assert.match(got, /最近使用/); + assert.match(got, /suggest_address\(name="opencode", path="/); +}); + +const sessionData = { + kind: 'session', + suggestions: ['silent-harbor', 'happy-tiger', 'new'], + addresses: [ + 'opencode@/home.silent-harbor', + 'opencode@/home.happy-tiger', + 'opencode@/home.new', + ], + candidates: [ + { alias: 'silent-harbor', title: '联调 llmsproxy', source: 'mail', unread: 2 }, + { alias: 'happy-tiger', title: '补投验证', source: 'mail', unread: 0 }, + { alias: 'new', title: '新建会话', source: 'new' }, + ], +}; + +test('renderSessionSuggestions 用服务端拼好的完整地址', () => { + // 插件自己拼过一次,拼错了(空 path 时漏掉 @)。addresses 与 suggestions + // 同序由服务端保证,直接用。 + const got = renderSessionSuggestions(sessionData, 'opencode', '/home'); + assert.match(got, /opencode@\/home\.silent-harbor/); + assert.match(got, /opencode@\/home\.happy-tiger/); + assert.match(got, /原样填进 send_mail 的 to/); +}); + +test('renderSessionSuggestions 带出标题与未读数', () => { + const got = renderSessionSuggestions(sessionData, 'opencode', '/home'); + assert.match(got, /联调 llmsproxy/); + assert.match(got, /2 封未读/); +}); + +test('不变量:new 不与已存在会话混列,且带警告', () => { + // new 排在前面会让模型在想续谈时顺手开出一条新线索 —— 生产上已经发生过。 + const got = renderSessionSuggestions(sessionData, 'opencode', '/home'); + const lines = got.split('\n'); + const newLineIdx = lines.findIndex(l => l.includes('.new')); + const harborIdx = lines.findIndex(l => l.includes('silent-harbor')); + assert.ok(harborIdx >= 0 && newLineIdx > harborIdx, 'new 必须排在已存在会话之后'); + assert.match(got, /新\*\*线索|新\*\*/, 'new 要带「这是开新线索」的提示'); +}); + +test('renderSessionSuggestions 无已存在会话时引导命名', () => { + // 这是关键引导:开新会话时传 session_alias,之后才能按名字续谈。 + // 不传的话服务端会自动命名,但模型不知道那个名字。 + const got = renderSessionSuggestions( + { suggestions: ['new'], addresses: ['dsh@/tmp.new'], candidates: [{ alias: 'new', source: 'new' }] }, + 'dsh', '/tmp', + ); + assert.match(got, /还没有可续谈的会话/); + assert.match(got, /session_alias/); +}); + +const participantData = { + session_id: 'f3d824ce', + session_alias: 'silent-harbor', + participants: [ + { name: 'admin', path: '', roles: ['from'], is_self: false, mail_count: 1, address: 'admin@.silent-harbor' }, + { name: 'dsh', path: '/home/program/llmsproxy', roles: ['to'], is_self: true, mail_count: 0, address: 'dsh@/home/program/llmsproxy.silent-harbor' }, + { name: 'opencode', path: '/home', roles: ['cc'], is_self: false, mail_count: 0, address: 'opencode@/home.silent-harbor' }, + ], +}; + +test('renderParticipants 给出每个参与方的地址', () => { + const got = renderParticipants(participantData); + assert.match(got, /opencode@\/home\.silent-harbor/); + assert.match(got, /admin@\.silent-harbor/); + assert.match(got, /原样填进 send_mail 的 to/); +}); + +test('不变量:标出「尚未回应」的人', () => { + // mail_count 为 0 就是还没开口的人。服务端只数「作为发件人」的邮件, + // 正是为了让这个判断成立。 + const got = renderParticipants(participantData); + const line = got.split('\n').find(l => l.includes('opencode')); + assert.match(line, /尚未回应/); + // 自己不该被标「尚未回应」—— 自己正在处理这封 + const selfLine = got.split('\n').find(l => l.includes('dsh')); + assert.ok(!selfLine.includes('尚未回应')); + assert.match(selfLine, /就是你/); +}); + +test('renderParticipants 用中文角色标签', () => { + // 模型读到「抄送方」比读到 cc 更容易判对分工。 + const got = renderParticipants(participantData); + assert.match(got, /抄送方/); + assert.match(got, /发件人/); +}); + +test('renderParticipants 无地址时说明原因', () => { + const got = renderParticipants({ + session_alias: '', + participants: [{ name: 'x', roles: ['to'], mail_count: 0, address: '' }], + }); + assert.match(got, /尚未命名/); +}); + +test('renderParticipants 空会话不崩', () => { + assert.match(renderParticipants({ participants: [] }), /还没有参与方/); + assert.match(renderParticipants({}), /还没有参与方/); +}); + +test('renderContacts 未读优先排序', () => { + // 模型问「我还有什么没处理」时,有未读的那些才是答案。 + const got = renderContacts({ + contacts: [ + { address: 'a@.x', unread_count: 0, last_activity: '2026-09-03T02:00:00Z' }, + { address: 'b@.y', unread_count: 3, last_activity: '2026-09-01T00:00:00Z' }, + ], + }); + const lines = got.split('\n').filter(l => l.startsWith('- ')); + assert.match(lines[0], /b@\.y/, '有未读的应排在最前'); + assert.match(lines[0], /3 封未读/); +}); + +test('renderContacts 带出剩余预算', () => { + const got = renderContacts({ + contacts: [{ address: 'a@.x', unread_count: 0, max_rounds: 20, used_rounds: 17 }], + }); + assert.match(got, /剩 3\/20 个来回/); +}); + +test('renderContacts 未命名会话说明只能 reply_to', () => { + const got = renderContacts({ contacts: [{ address: '', unread_count: 1 }] }); + assert.match(got, /reply_to/); +}); + +test('renderContacts 空列表', () => { + assert.match(renderContacts({ contacts: [] }), /还没有任何往来会话/); +}); + +const threadData = { + anchor_mail_id: 'm-2', + total: 3, + hidden: 1, + nodes: [ + { mail_id: 'm-1', from_name: 'admin', to_name: 'dsh', subject: '抄收联调', depth: 0 }, + { mail_id: 'm-2', from_name: 'dsh', to_name: 'opencode', subject: '[联调] 请提供部署现状', depth: 1 }, + { mail_id: 'm-3', from_name: 'opencode', to_name: 'dsh', subject: 'Re: 联调', depth: 2, detached: true, parent_hidden: true }, + ], +}; + +test('renderThread 用缩进表示层级', () => { + const got = renderThread(threadData, 'dsh'); + const lines = got.split('\n'); + const l1 = lines.find(l => l.includes('m-1')); + const l2 = lines.find(l => l.includes('m-2')); + assert.ok(l2.indexOf('- ') > l1.indexOf('- '), '子节点应更深缩进'); +}); + +test('不变量:detached 必须标出来', () => { + // 不标的话模型会以为这是一条独立线索,而它其实挂在一封看不到的邮件下面。 + const got = renderThread(threadData, 'dsh'); + const line = got.split('\n').find(l => l.includes('m-3')); + assert.match(line, /父邮件无权查看/); +}); + +test('renderThread 标出自己发的与当前这封', () => { + const got = renderThread(threadData, 'dsh'); + assert.match(got.split('\n').find(l => l.includes('m-2')), /你发的/); + assert.match(got.split('\n').find(l => l.includes('m-2')), /当前这封/); +}); + +test('renderThread 报告不可见数量', () => { + // 「共 3 封」与实际列出 3 条一致,但另有 1 封无权查看 —— + // 不说的话模型会以为自己看到了全貌。 + assert.match(renderThread(threadData), /另有 1 封无权查看/); +}); + +test('renderThread 有更多时给出 offset', () => { + const got = renderThread({ ...threadData, has_more: true, next_offset: 60 }); + assert.match(got, /offset=60/); +}); + +test('renderThread 空线索不崩', () => { + assert.match(renderThread({ nodes: [] }), /没有可见的邮件/); + assert.match(renderThread({}), /没有可见的邮件/); +}); diff --git a/plugins/pi-mail-bridge/test/inbox-format.test.mjs b/plugins/pi-mail-bridge/test/inbox-format.test.mjs new file mode 100644 index 0000000..c496a68 --- /dev/null +++ b/plugins/pi-mail-bridge/test/inbox-format.test.mjs @@ -0,0 +1,266 @@ +/** + * 收件箱渲染与已读策略的测试。 + * + * 每条断言都对应一次真实的错误行为(见 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('抄送')); +}); + +// ─── 收件人与身份(只有知道自己是谁才能判定)─── + +test('不变量:收件人要显示出来', () => { + // 不显示的后果:被抄送方不知道主收件人是谁,无法向对方转达或汇报。 + // 线上那封联调邮件要求「由收件人汇报」,而抄送方看不到收件人叫什么。 + const got = renderMail(mail({ to_name: 'dsh', to_workspace: '/home/program/llmsproxy' })); + assert.match(got, /收件人: dsh@\/home\/program\/llmsproxy/); +}); + +test('收件人无工作目录时只显名字', () => { + const got = renderMail(mail({ to_name: 'admin', to_workspace: '' })); + assert.match(got, /收件人: admin$/m); +}); + +test('不传 selfName 时不出现身份行(兼容旧调用)', () => { + const got = renderMail(mail({ to_name: 'dsh' })); + assert.ok(!got.includes('你的身份')); +}); + +test('不变量:区分收件人与抄送方身份', () => { + // 两者职责不同。不区分的话两方都会以为自己是负责人, + // 或者都以为自己只是旁观者。 + const m = mail({ + to_name: 'dsh', + to_workspace: '/home/program/llmsproxy', + cc_list: [{ name: 'opencode', path: '/home', raw: 'opencode@/home.new' }], + }); + assert.match(renderMail(m, 200, 'dsh'), /你的身份: 收件人/); + assert.match(renderMail(m, 200, 'opencode'), /你的身份: 抄送方/); + // 不相关的名字不编造身份 + assert.ok(!renderMail(m, 200, 'someone').includes('你的身份')); +}); + +// ─── 可投递地址(「精准发信」的关键)─── + +const joint = () => mail({ + mail_id: 'm-7', + from_name: 'admin', + to_name: 'dsh', + to_workspace: '/home/program/llmsproxy', + cc_list: [{ name: 'opencode', path: '/home', session: 'new', raw: 'opencode@/home.new' }], + session_alias: 'silent-harbor', +}); + +test('不变量:给出每个参与方的可投递地址', () => { + // 之前模型只能从抄送行里拄一个 `opencode@/home.new`, + // 而那个地址回过去只会再建一条平行会话。 + const got = renderMail(joint(), 200, 'dsh'); + assert.match(got, /可投递地址/); + assert.match(got, /opencode@\/home\.silent-harbor(抄送方)/); + assert.match(got, /admin@\.silent-harbor(发件人)/); +}); + +test('不变量:可投递地址里绝不出现 .new', () => { + // 这是本轮修的根因的直接回归:`.new` 是一次性动作, + // 把它当回信地址会让双方各说各话。 + const got = renderMail(joint(), 200, 'dsh'); + const line = got.split('\n').find(l => l.startsWith('可投递地址')); + assert.ok(line, '应有可投递地址行'); + assert.ok(!line.includes('.new'), `地址行仍含 .new: ${line}`); +}); + +test('可投递地址不列自己', () => { + const got = renderMail(joint(), 200, 'dsh'); + const line = got.split('\n').find(l => l.startsWith('可投递地址')); + assert.ok(!line.includes('dsh@'), `不该把自己当成收件人选项: ${line}`); +}); + +test('同时给出 reply_to 这条更稳的路', () => { + // 地址可能拼错,reply_to 不会 —— 两条路都告诉模型。 + const got = renderMail(joint(), 200, 'dsh'); + assert.match(got, /reply_to=m-7/); +}); + +test('无会话别名时不给地址(宁可不给不可给错)', () => { + // 别名为空时拼不出「投回这条会话」的地址。给一个看着能用 + // 实际指向默认会话的地址,比不给危险。 + const got = renderMail(mail({ + to_name: 'dsh', session_alias: '', + cc_list: [{ name: 'opencode', path: '/home' }], + }), 200, 'dsh'); + assert.ok(!got.includes('可投递地址')); +}); + +test('renderInbox 透传 selfName', () => { + const got = renderInbox([joint()], 200, 'opencode'); + assert.match(got, /你的身份: 抄送方/); + assert.match(got, /dsh@\/home\/program\/llmsproxy\.silent-harbor(收件人)/); +}); + +// ─── 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/pi-mail-bridge/test/model-scope.test.mjs b/plugins/pi-mail-bridge/test/model-scope.test.mjs new file mode 100644 index 0000000..ae15c29 --- /dev/null +++ b/plugins/pi-mail-bridge/test/model-scope.test.mjs @@ -0,0 +1,248 @@ +/** + * 模型范围与降级尝试的测试。 + * + * 最要紧的一条:范围为空时必须返回 `[undefined]`(试一次平台默认)而不是 `[]`。 + * 返回空数组会让调用方一次都不试,等于「管理员没配」就把 Agent 彻底哑掉。 + * + * node --test 'test/*.test.mjs' + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + snapshotOpencodeModels, + snapshotDshModels, + snapshotPiModels, + modelAttemptOrder, + renderFailureReport, + MAX_CATALOG, +} from '../lib/model-scope.js'; + +// ─── opencode 目录 ─── + +const ocConfig = { + providers: [ + { + id: 'llmsproxy', + models: { + AUTO: { name: 'AUTO (smart routing)' }, + 'claude-sonnet-4-6': { name: 'claude-sonnet-4-6' }, + }, + }, + { id: 'huawei', models: { 'deepseek-v4-flash': { name: 'dpkv4' } } }, + ], +}; + +test('opencode 目录拍平 provider × model', () => { + const got = snapshotOpencodeModels(ocConfig); + assert.equal(got.length, 3); + assert.deepEqual(got[0], { + provider: 'llmsproxy', + model: 'AUTO', + display_name: 'AUTO (smart routing)', + }); +}); + +test('models 是对象而非数组(键是 model id)', () => { + // 实测 opencode 的 /config/providers 返回 { models: { "AUTO": {...} } }。 + // 当成数组处理会得到零条目而不是报错。 + const got = snapshotOpencodeModels(ocConfig); + assert.ok(got.some(m => m.model === 'claude-sonnet-4-6')); +}); + +test('无 id 的 provider 被跳过', () => { + const got = snapshotOpencodeModels({ + providers: [{ models: { a: {} } }, { id: 'ok', models: { b: {} } }], + }); + assert.equal(got.length, 1); + assert.equal(got[0].provider, 'ok'); +}); + +test('缺 name 时 display_name 为空串而不是 undefined', () => { + const got = snapshotOpencodeModels({ providers: [{ id: 'p', models: { m: {} } }] }); + assert.equal(got[0].display_name, ''); +}); + +test('opencode 目录容错:结构缺失不崩', () => { + assert.deepEqual(snapshotOpencodeModels(undefined), []); + assert.deepEqual(snapshotOpencodeModels({}), []); + assert.deepEqual(snapshotOpencodeModels({ providers: 'oops' }), []); + assert.deepEqual(snapshotOpencodeModels({ providers: [{ id: 'p', models: null }] }), []); +}); + +// ─── DSH 目录 ─── + +test('DSH 目录用 provider + id', () => { + const got = snapshotDshModels([ + { provider: 'llmsproxy', id: 'AUTO', name: 'AUTO' }, + { provider: 'deepseek', id: 'chat', name: 'DeepSeek Chat' }, + ]); + assert.equal(got.length, 2); + assert.deepEqual(got[1], { provider: 'deepseek', model: 'chat', display_name: 'DeepSeek Chat' }); +}); + +test('DSH 目录跳过缺 provider 或 id 的条目', () => { + const got = snapshotDshModels([ + { provider: '', id: 'x' }, + { provider: 'p', id: '' }, + { provider: 'p', id: 'ok' }, + ]); + assert.equal(got.length, 1); + assert.equal(got[0].model, 'ok'); +}); + +test('重复的 provider/model 组合去重', () => { + const got = snapshotDshModels([ + { provider: 'p', id: 'm', name: '第一次' }, + { provider: 'p', id: 'm', name: '第二次' }, + ]); + assert.equal(got.length, 1); + assert.equal(got[0].display_name, '第一次'); +}); + +test('目录截断到 MAX_CATALOG', () => { + const many = Array.from({ length: MAX_CATALOG + 20 }, (_, i) => ({ + provider: 'p', id: `m${i}`, name: `M${i}`, + })); + assert.equal(snapshotDshModels(many).length, MAX_CATALOG); +}); + +// ─── pi 目录 ─── + +test('pi 目录用 provider + id', () => { + const got = snapshotPiModels([ + { provider: 'llmsproxy', id: 'AUTO', name: 'AUTO' }, + { provider: 'anthropic', id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6' }, + ]); + assert.equal(got.length, 2); + assert.deepEqual(got[1], { + provider: 'anthropic', + model: 'claude-sonnet-4-6', + display_name: 'Claude Sonnet 4.6', + }); +}); + +test('pi 目录跳过缺 provider 或 id 的条目', () => { + const got = snapshotPiModels([ + { provider: '', id: 'x' }, + { provider: 'p' }, + { provider: 'p', id: 'ok' }, + ]); + assert.equal(got.length, 1); + assert.equal(got[0].model, 'ok'); +}); + +test('pi 目录容错:非数组不崩', () => { + assert.deepEqual(snapshotPiModels(undefined), []); + assert.deepEqual(snapshotPiModels(null), []); + assert.deepEqual(snapshotPiModels('oops'), []); +}); + +test('pi 目录同样受 MAX_CATALOG 截断', () => { + // 本机 pi 的完整目录有 1221 个模型(getModels),远超上限。 + // 桥实际上报的是 getAvailable() 的结果(只有带凭证的),但截断仍要生效。 + const many = Array.from({ length: MAX_CATALOG + 50 }, (_, i) => ({ + provider: 'p', id: `m${i}`, name: `M${i}`, + })); + assert.equal(snapshotPiModels(many).length, MAX_CATALOG); +}); + +// ─── modelAttemptOrder ─── + +test('管理员划定范围时按 rank 顺序尝试', () => { + const got = modelAttemptOrder( + [{ provider: 'a', model: '1' }, { provider: 'b', model: '2' }], + { provider: 'env', model: 'x' } + ); + assert.deepEqual(got, [ + { provider: 'a', model: '1' }, + { provider: 'b', model: '2' }, + ]); +}); + +test('不变量:范围为空时返回 [undefined] 而不是 []', () => { + // 返回空数组会让调用方一次都不试 —— 「管理员没配」的正确含义是不限定, + // 不是「一个都不许用」。后者等于让 Agent 彻底哑掉。 + const got = modelAttemptOrder([], undefined); + assert.equal(got.length, 1, `应有一次尝试,实际 ${got.length}`); + assert.equal(got[0], undefined, 'undefined 表示交给平台自己选'); +}); + +test('范围为空但有环境变量时用环境变量', () => { + const got = modelAttemptOrder([], { provider: 'llmsproxy', model: 'AUTO' }); + assert.deepEqual(got, [{ provider: 'llmsproxy', model: 'AUTO' }]); +}); + +test('不变量:范围优先于环境变量', () => { + // 范围是运行时可改的策略,环境变量是部署时的兜底。 + // 反过来的话管理员在配置页改了范围却不生效,得去改 service 文件重启。 + const got = modelAttemptOrder( + [{ provider: 'chosen', model: 'm' }], + { provider: 'env', model: 'x' } + ); + assert.equal(got.length, 1); + assert.equal(got[0].provider, 'chosen'); +}); + +test('过滤掉范围里字段不全的项', () => { + const got = modelAttemptOrder( + [{ provider: 'a', model: '' }, { provider: '', model: '1' }, { provider: 'ok', model: 'm' }], + undefined + ); + assert.deepEqual(got, [{ provider: 'ok', model: 'm' }]); +}); + +test('环境变量只给一半时不采用', () => { + assert.deepEqual(modelAttemptOrder([], { provider: 'p' }), [undefined]); + assert.deepEqual(modelAttemptOrder([], { model: 'm' }), [undefined]); +}); + +test('modelAttemptOrder 容错非数组', () => { + assert.deepEqual(modelAttemptOrder(undefined, undefined), [undefined]); + assert.deepEqual(modelAttemptOrder('oops', undefined), [undefined]); +}); + +// ─── renderFailureReport ─── + +test('失败报告列出每次尝试的路由与原因', () => { + const got = renderFailureReport( + [ + { provider: 'llmsproxy', model: 'AUTO', error: '429 Too Many Requests' }, + { provider: 'huawei', model: 'dpk', error: 'connect ECONNREFUSED' }, + ], + '缓存选型' + ); + assert.match(got, /缓存选型/); + assert.match(got, /已尝试 2 个/); + assert.match(got, /llmsproxy\/AUTO/); + assert.match(got, /429 Too Many Requests/); + assert.match(got, /huawei\/dpk/); + assert.match(got, /ECONNREFUSED/); +}); + +test('没有路由信息时标为平台默认模型', () => { + const got = renderFailureReport([{ error: 'boom' }], '主题'); + assert.match(got, /平台默认模型/); +}); + +test('失败报告给出可操作的下一步', () => { + // 只报错误不说怎么办,收信的人只能来问。 + const got = renderFailureReport([{ error: 'x' }], '主题'); + assert.match(got, /配置页/); +}); + +test('多行报错缩进后不破坏 Markdown 排版', () => { + const got = renderFailureReport([{ error: 'line1\nline2' }], '主题'); + // 第二行也要带缩进,否则会脱离代码块、其中的字符被当作 Markdown 解析 + assert.match(got, / line1\n line2/); +}); + +test('空主题有兜底', () => { + assert.match(renderFailureReport([{ error: 'x' }], ''), /\(无主题\)/); + assert.match(renderFailureReport([{ error: 'x' }], undefined), /\(无主题\)/); +}); + +test('renderFailureReport 容错非数组', () => { + const got = renderFailureReport(undefined, '主题'); + assert.match(got, /已尝试 0 个/); +}); diff --git a/plugins/pi-mail-bridge/test/naming.test.mjs b/plugins/pi-mail-bridge/test/naming.test.mjs new file mode 100644 index 0000000..67a2407 --- /dev/null +++ b/plugins/pi-mail-bridge/test/naming.test.mjs @@ -0,0 +1,185 @@ +/** + * 会话命名一致性的测试。 + * + * 这是 pi 接入里最容易做错的一块,因为「两边各自命名然后指望撞上」看起来能用: + * 单条会话、不撞名、没人手工改过名时,两边确实一致。上面任何一条不成立就分叉。 + * + * 因此这里的每个 test 都对应一条**分叉场景**。 + * + * node --test 'test/*.test.mjs' + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { planNamingSync, planWriteBack } from '../src/naming.mjs'; + +// ─── 向 Gateway 提交(W-7)─── + +test('平台名字正常时派生别名并原样带标题', () => { + const got = planNamingSync({ platformName: '排查连接泄漏', mailSubject: '别用我', lastSynced: '' }); + assert.equal(got.skip, false); + assert.equal(got.alias, '排查连接泄漏'); + assert.equal(got.title, '排查连接泄漏'); + assert.equal(got.source, 'platform'); +}); + +test('不变量:标题原样提交,不清洗不派生', () => { + // I-4:插件只搬运。标题那一列不负责寻址,没有字符限制, + // 改写它等于让邮箱里显示的和 pi 里显示的是两个东西。 + const name = 'Fix: cache TTL (v2) — 缓存/过期'; + const got = planNamingSync({ platformName: name, mailSubject: '', lastSynced: '' }); + assert.equal(got.title, name); + // 别名要按寻址规则剥掉分隔符 + assert.doesNotMatch(got.alias, /[.@/]/); +}); + +test('不变量:内容没变就不重复提交', () => { + // setSessionName 会触发 session_info_changed,钩子又去 sync, + // 不判「与上次相同」就是自激循环 —— 每 30 秒刷一次 Gateway。 + const first = planNamingSync({ platformName: '同一个名字', mailSubject: '', lastSynced: '' }); + assert.equal(first.skip, false); + const again = planNamingSync({ platformName: '同一个名字', mailSubject: '', lastSynced: first.signature }); + assert.equal(again.skip, true); + assert.equal(again.reason, 'unchanged'); +}); + +test('不变量:pi 没有名字时退到邮件主题(SDK 路径的常态)', () => { + // 桥用 SDK 起的会话不经过 pi-web 的标题生成器,sessionName 一直是 undefined。 + // 只等平台命名的话别名永远是空的,`name@path.<别名>` 续谈无从下手 —— + // 第一次端到端跑通时就是这个结果(sessions.session_alias 是空串)。 + const got = planNamingSync({ platformName: undefined, mailSubject: '主链路验证', lastSynced: '' }); + assert.equal(got.skip, false); + assert.equal(got.alias, '主链路验证'); + assert.equal(got.source, 'mail-subject'); +}); + +test('不变量:无名字 + 主题未变时也要判 unchanged', () => { + // 用平台名字本身充当「上次提交了什么」的记录时,名字为空就无法区分 + // 「还没提交过」与「提交过、内容没变」,于是每轮心跳都白打一次 sync。 + const first = planNamingSync({ platformName: '', mailSubject: '固定主题', lastSynced: '' }); + const again = planNamingSync({ platformName: '', mailSubject: '固定主题', lastSynced: first.signature }); + assert.equal(again.skip, true, '空名字场景同样要能判出「没变化」'); +}); + +test('思维链泄漏的名字退到邮件主题派生别名', () => { + // pi-web 的标题生成器不防这个(cleanSessionName 只取首行 + 截 60 字符)。 + // 本机 81 条会话里实测捞到过这条。 + const got = planNamingSync({ + platformName: 'The user is asking me to generate a title for a coding-agent', + mailSubject: '排查连接泄漏', + lastSynced: '', + }); + assert.equal(got.skip, false); + assert.equal(got.alias, '排查连接泄漏'); + assert.equal(got.source, 'mail-subject'); +}); + +test('不变量:退到邮件主题时不写标题', () => { + // 标题那一列的语义是「平台生成的会话标题」。把邮件主题填进去会让 + // 邮箱里看起来像是 pi 生成了这个标题,而 pi 侧其实是另一个名字(或没有)。 + const got = planNamingSync({ + platformName: '我们只需要生成标题,不包含其他内容。标题应反映请求内容:测试。简短:测试。或者更简', + mailSubject: '压测报告', + lastSynced: '', + }); + assert.equal(got.title, ''); + assert.equal(got.alias, '压测报告'); +}); + +test('不变量:无可用名字时什么都不写(W-7.2)', () => { + // 宁可让会话保持无别名(session_alias 允许 NULL),也不要写 "session-123" + // 这种占位值 —— 它对人毫无指代作用,而且一旦落库就把 alias 位占住了, + // 真正的名字来了只能追 -2 后缀。 + const got = planNamingSync({ platformName: '', mailSubject: '', lastSynced: '' }); + assert.equal(got.skip, true); + assert.equal(got.reason, 'no-usable-name'); +}); + +test('纯符号名字:写标题但不写别名', () => { + // slugFromTitle('...') 是空串,作为别名非法(Gateway 会 400), + // 但这个名字本身是平台产出,标题列该照实反映。 + const got = planNamingSync({ platformName: '...', mailSubject: '', lastSynced: '' }); + assert.equal(got.skip, false); + assert.equal(got.alias, ''); + assert.equal(got.title, '...'); + assert.equal(got.source, 'platform-title-only'); +}); + +test('主题也派生不出别名时不写', () => { + const got = planNamingSync({ platformName: 'The user is asking me to', mailSubject: '@@@', lastSynced: '' }); + assert.equal(got.skip, true); +}); + +// ─── 回写进 pi(D-5 / 一致性的关键)─── + +test('不变量:撞名后缀必须回写进 pi', () => { + // Gateway 侧别名负有寻址唯一性义务(partial unique index),撞名自动追 -2。 + // pi 侧没有这个约束。不回写的话:邮箱里是 fix-leak-2、pi-web 里是 fix-leak, + // 用户按界面上看到的名字发信会 404。 + const got = planWriteBack({ finalAlias: 'fix-leak-2', currentPiName: 'fix-leak' }); + assert.equal(got.write, true); + assert.equal(got.name, 'fix-leak-2'); + assert.equal(got.reason, 'diverged'); +}); + +test('不变量:manual 别名(人手工改过)优先,回写进 pi', () => { + // SyncSessionAlias 遇到 alias_source='manual' 时不覆盖,**原样返回当前别名**。 + // 于是「人在 AgentMail 界面上定的名字」赢,pi 侧要跟着改 —— 这是有意的: + // 人的意图优先于模型生成的标题。 + const got = planWriteBack({ finalAlias: '紧急排查', currentPiName: 'connection-leak' }); + assert.equal(got.write, true); + assert.equal(got.name, '紧急排查'); +}); + +test('规范化改写过的别名也要回写', () => { + // normalizeAlias 把 . / @ 空白换成 -。提议 "a.b c" 会变成 "a-b-c"。 + const got = planWriteBack({ finalAlias: 'a-b-c', currentPiName: 'a.b c' }); + assert.equal(got.write, true); + assert.equal(got.name, 'a-b-c'); +}); + +test('两边已经一致就不回写', () => { + // 回写会 append 一条 session_info 并触发 session_info_changed。 + // 无条件回写 = 每轮多一条无意义的历史条目 + 一次多余的 sync。 + const got = planWriteBack({ finalAlias: 'fix-leak', currentPiName: 'fix-leak' }); + assert.equal(got.write, false); + assert.equal(got.reason, 'already-equal'); +}); + +test('pi 侧还没有名字时也要回写', () => { + // 桥用 SDK 起的会话没有名字(pi-web 的生成器不在这条链路上), + // 此时 Gateway 定稿的别名就是这条会话的第一个名字。 + const got = planWriteBack({ finalAlias: 'fix-leak', currentPiName: undefined }); + assert.equal(got.write, true); + assert.equal(got.reason, 'pi-unnamed'); +}); + +test('不变量:响应没带别名时不回写', () => { + // 本次只同步了标题(planNamingSync 的 platform-title-only 分支)→ 没有定稿值。 + // 拿空串去 setSessionName 是**清除**语义(实测 appendSessionInfo(" ") + // 之后 getSessionName() 变 undefined),会把 pi 侧原有的名字抹掉。 + assert.equal(planWriteBack({ finalAlias: '', currentPiName: 'keep-me' }).write, false); + assert.equal(planWriteBack({ finalAlias: undefined, currentPiName: 'keep-me' }).write, false); + assert.equal(planWriteBack({ finalAlias: ' ', currentPiName: 'keep-me' }).write, false); +}); + +// ─── 端到端的一致性推理 ─── + +test('完整链路:提议 → 撞名定稿 → 回写 → 再观测不再动', () => { + // 这个 test 钉住「不会自激循环」这条性质,它是分四步的: + // 1. pi 有了名字 fix-leak,提交 + // 2. Gateway 撞名,定稿 fix-leak-2 + // 3. 回写进 pi,pi 的名字变成 fix-leak-2 + // 4. session_info_changed 再次触发 → 必须 skip,否则无限循环 + const step1 = planNamingSync({ platformName: 'fix-leak', mailSubject: '', lastSynced: '' }); + assert.equal(step1.alias, 'fix-leak'); + + const step3 = planWriteBack({ finalAlias: 'fix-leak-2', currentPiName: 'fix-leak' }); + assert.equal(step3.write, true); + + // 桥在回写后把指纹更新成「定稿别名当作平台名字」会算出的那个值, + // 因此第 4 步(回写触发的事件)看到的指纹与它相同。 + const afterWriteBack = `platform:${step3.name}|${step3.name}`; + const step4 = planNamingSync({ platformName: 'fix-leak-2', mailSubject: '', lastSynced: afterWriteBack }); + assert.equal(step4.skip, true, '回写触发的事件必须被指纹挡住,否则无限循环'); +}); diff --git a/plugins/pi-mail-bridge/test/session-snapshot.test.mjs b/plugins/pi-mail-bridge/test/session-snapshot.test.mjs new file mode 100644 index 0000000..18f2e09 --- /dev/null +++ b/plugins/pi-mail-bridge/test/session-snapshot.test.mjs @@ -0,0 +1,326 @@ +/** + * 平台会话快照的纯函数测试。 + * + * 这些函数的产出直接决定「写信时能不能选到某条会话」:slug 错了就填出一个 + * 送不到的 session 位(三态语义下会 404),workspace 错了就归到别的工作区去。 + * + * node --test test/ + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + snapshotOpencodeSessions, + snapshotDshSessions, + snapshotPiSessions, + isUnusableName, + slugFromTitle, + MAX_REPORTED, +} from '../lib/session-snapshot.js'; + +// ─── opencode ─── + +const ocSession = (over = {}) => ({ + id: 'ses_abc', + slug: 'witty-planet', + title: '重构导入路径', + directory: '/home/program/agentmail', + path: '', + time: { created: 1788300000000, updated: 1788344476744 }, + ...over, +}); + +test('opencode 快照取 directory 作为 workspace', () => { + const [got] = snapshotOpencodeSessions([ocSession()]); + assert.equal(got.workspace, '/home/program/agentmail'); +}); + +test('不变量:workspace 取 directory 而不是 path', () => { + // opencode 的 path 是项目内的子路径(通常是空串),cwd 在 directory 上。 + // 取错的后果是所有会话的 workspace 都变成空串,一条都匹配不上。 + const [got] = snapshotOpencodeSessions([ + ocSession({ directory: '/home/real/cwd', path: 'src/sub' }), + ]); + assert.equal(got.workspace, '/home/real/cwd'); +}); + +test('opencode 快照带出 slug 与标题', () => { + const [got] = snapshotOpencodeSessions([ocSession()]); + assert.equal(got.slug, 'witty-planet'); + assert.equal(got.title, '重构导入路径'); + assert.equal(got.platform_id, 'ses_abc'); +}); + +test('不变量:无 slug 的会话不上报', () => { + // slug 是填进 session 位的值。没有它,这一项在补全里点下去 + // 只能得到 `name@path.` —— 一个空的 session 段。 + const got = snapshotOpencodeSessions([ + ocSession({ id: 'a', slug: '' }), + ocSession({ id: 'b', slug: undefined }), + ocSession({ id: 'c', slug: 'good-name' }), + ]); + assert.equal(got.length, 1); + assert.equal(got[0].slug, 'good-name'); +}); + +test('无 id 的条目被跳过', () => { + const got = snapshotOpencodeSessions([ocSession({ id: '' }), ocSession({ id: undefined })]); + assert.equal(got.length, 0); +}); + +test('mail_driven 由回调判定', () => { + const got = snapshotOpencodeSessions( + [ocSession({ id: 'driven' }), ocSession({ id: 'manual' })], + id => id === 'driven' + ); + assert.equal(got.find(s => s.platform_id === 'driven').mail_driven, true); + assert.equal(got.find(s => s.platform_id === 'manual').mail_driven, false); +}); + +test('updated_at 由毫秒时间戳转 ISO', () => { + const [got] = snapshotOpencodeSessions([ocSession()]); + assert.equal(got.updated_at, new Date(1788344476744).toISOString()); +}); + +test('没有 updated 时退回 created', () => { + const [got] = snapshotOpencodeSessions([ + ocSession({ time: { created: 1788300000000 } }), + ]); + assert.equal(got.updated_at, new Date(1788300000000).toISOString()); +}); + +test('时间完全缺失时 updated_at 为 undefined 而不是崩', () => { + const [got] = snapshotOpencodeSessions([ocSession({ time: undefined })]); + assert.equal(got.updated_at, undefined); +}); + +test('按最近活跃降序排列', () => { + const got = snapshotOpencodeSessions([ + ocSession({ id: 'old', slug: 'old', time: { updated: 1000 } }), + ocSession({ id: 'new', slug: 'new', time: { updated: 9000 } }), + ocSession({ id: 'mid', slug: 'mid', time: { updated: 5000 } }), + ]); + assert.deepEqual(got.map(s => s.platform_id), ['new', 'mid', 'old']); +}); + +test('截断到 MAX_REPORTED', () => { + const many = Array.from({ length: MAX_REPORTED + 50 }, (_, i) => + ocSession({ id: `s${i}`, slug: `slug-${i}`, time: { updated: i } }) + ); + assert.equal(snapshotOpencodeSessions(many).length, MAX_REPORTED); +}); + +test('非数组输入不崩', () => { + assert.deepEqual(snapshotOpencodeSessions(undefined), []); + assert.deepEqual(snapshotOpencodeSessions(null), []); + assert.deepEqual(snapshotOpencodeSessions({}), []); +}); + +// ─── DSH ─── + +test('DSH 快照从标题派生 slug', () => { + const [got] = snapshotDshSessions([ + { id: 'mail-1', cwd: '/home/x', title: '缓存层选型评估', updatedAt: 1788344476744 }, + ]); + assert.equal(got.slug, '缓存层选型评估'); + assert.equal(got.title, '缓存层选型评估'); + assert.equal(got.workspace, '/home/x'); +}); + +test('DSH 无标题时不上报(派生不出别名)', () => { + const got = snapshotDshSessions([ + { id: 'a', cwd: '/home/x', title: '' }, + { id: 'b', cwd: '/home/x' }, + ]); + assert.equal(got.length, 0); +}); + +// ─── slugFromTitle ─── + +test('slugFromTitle 空白转连字符', () => { + assert.equal(slugFromTitle('处理新邮件 任务'), '处理新邮件-任务'); + assert.equal(slugFromTitle('a b c'), 'a-b-c'); +}); + +test('不变量:slug 不含寻址分隔符', () => { + // `.` 是 session 位的分隔符、`@` 是 path 位的分隔符。留在 slug 里 + // 会让别名自己被解析器切开 —— 填进去的地址会指向一个完全不同的目标。 + const slug = slugFromTitle('修 a.b@c/d 的问题'); + for (const ch of ['.', '@', '/', '\\', ':']) { + assert.ok(!slug.includes(ch), `slug 里不该有 ${ch}:${slug}`); + } +}); + +test('slugFromTitle 压缩连续连字符', () => { + assert.equal(slugFromTitle('a...b'), 'ab'); + assert.equal(slugFromTitle('a - b'), 'a-b'); +}); + +test('slugFromTitle 去掉首尾连字符', () => { + assert.equal(slugFromTitle(' 中间 '), '中间'); + assert.equal(slugFromTitle('--x--'), 'x'); +}); + +test('slugFromTitle 截断到 48 字符且不留尾部连字符', () => { + const long = 'a'.repeat(60); + assert.equal(slugFromTitle(long).length, 48); + // 第 48 个字符正好落在空格上时,截断后不该留下尾部 - + const tricky = `${'b'.repeat(47)} tail`; + const slug = slugFromTitle(tricky); + assert.ok(!slug.endsWith('-'), `尾部残留连字符:${slug}`); +}); + +test('slugFromTitle 保留中文', () => { + // 不转拼音:huancunceng-xuanxing 既不好读也不好打, + // 而三维地址按最后一个 . 切分,中文不影响解析。 + assert.equal(slugFromTitle('缓存选型'), '缓存选型'); +}); + +test('slugFromTitle 纯符号标题返回空串', () => { + assert.equal(slugFromTitle('...'), ''); + assert.equal(slugFromTitle('@@@'), ''); + assert.equal(slugFromTitle(' '), ''); +}); + +test('slugFromTitle 容错非字符串', () => { + assert.equal(slugFromTitle(undefined), ''); + assert.equal(slugFromTitle(null), ''); + assert.equal(slugFromTitle(42), '42'); +}); + +// ─── DSH:subagent 过滤与 slug 去重 ─── + +test('不变量:subagent 子会话不上报(origin 判据)', () => { + // 它们是父 agent 内部的工作单元,人往里发邮件毫无意义。 + const got = snapshotDshSessions([ + { id: 'child', cwd: '/w', title: 'You are auditing ONE file', origin: 'subagent' }, + { id: 'top', cwd: '/w', title: '正常会话' }, + ]); + assert.equal(got.length, 1); + assert.equal(got[0].platform_id, 'top'); +}); + +test('不变量:subagent 子会话不上报(delegationDepth 判据)', () => { + const got = snapshotDshSessions([ + { id: 'child', cwd: '/w', title: '子任务', delegationDepth: 1 }, + { id: 'top', cwd: '/w', title: '顶层', delegationDepth: 0 }, + ]); + assert.equal(got.length, 1); + assert.equal(got[0].platform_id, 'top'); +}); + +test('不变量:slug 撞名只留最近那条', () => { + // 别名是寻址用的:同一个 slug 对应多条会话时服务端只能取其中一条, + // 上报一堆同名项只会让补全列表里出现几个点哪个都不确定的候选。 + const got = snapshotDshSessions([ + { id: 'old', cwd: '/w', title: '同一个标题', updatedAt: 1000 }, + { id: 'new', cwd: '/w', title: '同一个标题', updatedAt: 9000 }, + { id: 'mid', cwd: '/w', title: '同一个标题', updatedAt: 5000 }, + ]); + assert.equal(got.length, 1, `应去重到 1 条,实际 ${got.length}`); + assert.equal(got[0].platform_id, 'new', '应保留最近活跃的那条'); +}); + +test('不同标题不受去重影响', () => { + const got = snapshotDshSessions([ + { id: 'a', cwd: '/w', title: '标题一', updatedAt: 2000 }, + { id: 'b', cwd: '/w', title: '标题二', updatedAt: 1000 }, + ]); + assert.equal(got.length, 2); +}); + +// ─── pi:名字来自会话文件的 session_info ─── + +const piSession = (over = {}) => ({ + id: '01a064cc-df57-7b2d-bebb-736776105485', + cwd: '/home/program/agentmail', + name: '重构导入路径', + messageCount: 6, + created: new Date(1788300000000), + modified: new Date(1788344476744), + ...over, +}); + +test('pi 快照取 cwd 与 session_info 名字', () => { + const [got] = snapshotPiSessions([piSession()]); + assert.equal(got.workspace, '/home/program/agentmail'); + assert.equal(got.title, '重构导入路径'); + assert.equal(got.slug, '重构导入路径'); + assert.equal(got.platform_id, '01a064cc-df57-7b2d-bebb-736776105485'); +}); + +test('不变量:pi 无名会话不上报', () => { + // pi 的列表在无名时显示首条消息,而邮件驱动会话的首条消息是桥自己拼的提示词 + // (「你收到一封新邮件(AgentMail)…」)—— 拿它当别名毫无区分度,且条条撞名。 + const got = snapshotPiSessions([ + piSession({ id: 'named', name: '有名字' }), + piSession({ id: 'anon', name: undefined }), + piSession({ id: 'blank', name: '' }), + ]); + assert.deepEqual(got.map(s => s.platform_id), ['named']); +}); + +test('不变量:pi 老会话的空 cwd 照实上报', () => { + // SessionInfo 的注释写明老会话 cwd 是空串。拿桥自己的 cwd 冒充会让 + // 那条会话在补全里挂到一个它其实不属于的工作区下。 + const [got] = snapshotPiSessions([piSession({ cwd: '' })]); + assert.equal(got.workspace, ''); +}); + +test('不变量:pi 的 updated_at 取 modified(文件 mtime)', () => { + const [got] = snapshotPiSessions([piSession()]); + assert.equal(got.updated_at, new Date(1788344476744).toISOString()); +}); + +test('pi 快照按最近活跃排序并对撞名 slug 去重', () => { + const got = snapshotPiSessions([ + piSession({ id: 'old', name: '同一个标题', modified: new Date(1000) }), + piSession({ id: 'new', name: '同一个标题', modified: new Date(9000) }), + ]); + assert.equal(got.length, 1); + assert.equal(got[0].platform_id, 'new'); +}); + +test('pi 快照标记邮件驱动的会话', () => { + const got = snapshotPiSessions( + [piSession({ id: 'mail-one' }), piSession({ id: 'human', name: '人开的' })], + (id) => id === 'mail-one' + ); + assert.equal(got.find(s => s.platform_id === 'mail-one').mail_driven, true); + assert.equal(got.find(s => s.platform_id === 'human').mail_driven, false); +}); + +// ─── isUnusableName:pi-web 标题生成器的思维链泄漏 ─── + +test('不变量:思维链泄漏的标题判废', () => { + // 都是本机 ~/.pi/agent/sessions 里实测捞到的真实 session_info 名字。 + // pi-web 的 cleanSessionName 只做「取首行 + 去引号 + 截 60 字符」,不防这个。 + assert.equal(isUnusableName('The user is asking me to generate a title for a coding-agent'), true); + assert.equal( + isUnusableName('我们只需要生成标题,不包含其他内容。标题应反映请求内容:测试opencode的源。简短:Opencode源测试。或者更简'), + true + ); +}); + +test('isUnusableName 放过正常标题', () => { + // 宁可漏判也不误判:错杀一个好名字会让那条会话失去可寻址的别名。 + assert.equal(isUnusableName('查看Agent接入群聊'), false); + assert.equal(isUnusableName('你应该知道内网拓扑结构吧'), false); + assert.equal(isUnusableName('homeagent-gateway'), false); + assert.equal(isUnusableName('重构导入路径'), false); + assert.equal(isUnusableName('Fix flaky auth test'), false); +}); + +test('isUnusableName 判废空名字', () => { + assert.equal(isUnusableName(''), true); + assert.equal(isUnusableName(' '), true); + assert.equal(isUnusableName(undefined), true); +}); + +test('判废的名字不进快照', () => { + const got = snapshotPiSessions([ + piSession({ id: 'leaked', name: 'The user is asking me to generate a title for a coding-agent' }), + piSession({ id: 'clean', name: '正常标题' }), + ]); + assert.deepEqual(got.map(s => s.platform_id), ['clean']); +}); diff --git a/plugins/pi-mail-bridge/test/turn.test.mjs b/plugins/pi-mail-bridge/test/turn.test.mjs new file mode 100644 index 0000000..ff2bae4 --- /dev/null +++ b/plugins/pi-mail-bridge/test/turn.test.mjs @@ -0,0 +1,254 @@ +/** + * pi 专属纯逻辑的测试:轮次结论判定、消息文本提取、提示词。 + * + * 这些不在 lib/ 下(那里的六个文件三平台逐字节相同),因为它们依赖 pi 的 + * 消息形状与 stopReason 语义。但同样是纯函数,因此可以不起模型就钉住。 + * + * node --test 'test/*.test.mjs' + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + stripRe, + replySubject, + lastAssistantText, + classifyTurnOutcome, + describeError, + buildMailPrompt, + relayKeyFor, +} from '../src/turn.mjs'; + +// ─── 主题 ─── + +test('stripRe 去掉叠加的 Re: 前缀', () => { + assert.equal(stripRe('Re: Re: Re: 缓存选型'), '缓存选型'); + assert.equal(stripRe('缓存选型'), '缓存选型'); + assert.equal(stripRe('RE: RE: x'), 'x'); +}); + +test('replySubject 只加一层 Re:', () => { + assert.equal(replySubject('Re: 缓存选型'), 'Re: 缓存选型'); + assert.equal(replySubject('缓存选型'), 'Re: 缓存选型'); +}); + +test('无主题时回信有兜底主题', () => { + // 空主题会被 Gateway 拒(400 Missing subject),不兜底就发不出去 + assert.equal(replySubject(''), '本轮工作总结'); + assert.equal(replySubject(undefined), '本轮工作总结'); +}); + +// ─── 消息文本提取 ─── + +const asst = (blocks, over = {}) => ({ role: 'assistant', content: blocks, stopReason: 'stop', ...over }); + +test('只取 text 块,丢掉 thinking', () => { + // 思考过程不该出现在邮件里(B-5.1 / N-6):它对收件人没有意义, + // 而且经常包含「我先假设…」这类会被误读为结论的话。 + const got = lastAssistantText([ + asst([ + { type: 'thinking', thinking: '先看看有没有缓存层' }, + { type: 'text', text: '已定位到问题:连接池没有复用。' }, + ]), + ]); + assert.equal(got, '已定位到问题:连接池没有复用。'); +}); + +test('不变量:跳过纯工具调用的收尾消息,往前找有文本的那条', () => { + // 一轮的最后一条 assistant 消息常常只有 toolCall。取到它会得到空串, + // 于是 B-5.4 判成「无话可说」而漏掉真正的结论 —— 发件人再无音讯。 + const got = lastAssistantText([ + asst([{ type: 'text', text: '结论在这里。' }]), + asst([{ type: 'toolCall', toolName: 'bash', input: {} }]), + ]); + assert.equal(got, '结论在这里。'); +}); + +test('多个 text 块按顺序拼接', () => { + const got = lastAssistantText([asst([ + { type: 'text', text: '第一段' }, + { type: 'text', text: '第二段' }, + ])]); + assert.equal(got, '第一段\n第二段'); +}); + +test('忽略 user 消息里的文本', () => { + const got = lastAssistantText([ + asst([{ type: 'text', text: 'assistant 说的' }]), + { role: 'user', content: [{ type: 'text', text: 'user 说的' }] }, + ]); + assert.equal(got, 'assistant 说的'); +}); + +test('没有 assistant 消息时返回空串', () => { + assert.equal(lastAssistantText([{ role: 'user', content: [{ type: 'text', text: 'x' }] }]), ''); + assert.equal(lastAssistantText([]), ''); + assert.equal(lastAssistantText(undefined), ''); +}); + +// ─── 轮次结论(D-3,两次适配都踩过)─── + +test('不变量:prompt 抛错判为失败', () => { + // 实测:无凭证的 provider 让 prompt() reject(No API key found for + // amazon-bedrock.),**一个事件都不发**。只看事件的话这轮会被当成没跑完。 + const got = classifyTurnOutcome({ error: new Error('No API key found for amazon-bedrock.') }); + assert.equal(got.ok, false); + assert.match(got.error, /No API key/); +}); + +test('不变量:一条 assistant 消息都没有判为失败', () => { + // 判成功会让 B-5 转发一个空字符串回去 —— 发件人收到一封空邮件, + // 而不是错误说明。这是契约里 C-4「必须能区分成功与出错」的核心。 + const got = classifyTurnOutcome({ messages: [{ role: 'user', content: [] }] }); + assert.equal(got.ok, false); + assert.match(got.error, /没有产出/); +}); + +test('不变量:stopReason=error 判为失败并带出 errorMessage', () => { + const got = classifyTurnOutcome({ + messages: [asst([{ type: 'text', text: '半句' }], { + stopReason: 'error', + errorMessage: 'upstream 503 rate limited', + })], + }); + assert.equal(got.ok, false); + assert.equal(got.error, 'upstream 503 rate limited'); +}); + +test('stopReason=error 但没给原因也要有话可说', () => { + const got = classifyTurnOutcome({ messages: [asst([], { stopReason: 'error' })] }); + assert.equal(got.ok, false); + assert.ok(got.error, '失败原因不能是空串:renderFailureReport 会把它填进邮件'); +}); + +test('正常收尾判为成功', () => { + const got = classifyTurnOutcome({ messages: [asst([{ type: 'text', text: '好了' }])] }); + assert.deepEqual(got, { ok: true, error: '', aborted: false }); +}); + +test('不变量:length(被 max tokens 截断)判为成功', () => { + // 内容不完整,但**是模型的产出**。判失败会让一封「说了一半」的回信 + // 变成「换个模型重试」,那更糟 —— 用户什么都收不到。 + const got = classifyTurnOutcome({ messages: [asst([{ type: 'text', text: '说了一半' }], { stopReason: 'length' })] }); + assert.equal(got.ok, true); +}); + +test('aborted 判为失败但标记 aborted', () => { + // 有人主动打断(Esc / dispose),不是模型故障 —— 不该触发换模型重试 + const got = classifyTurnOutcome({ messages: [asst([], { stopReason: 'aborted' })] }); + assert.equal(got.ok, false); + assert.equal(got.aborted, true); +}); + +test('取最后一条 assistant 消息判定,不是第一条', () => { + const got = classifyTurnOutcome({ + messages: [ + asst([{ type: 'text', text: '第一轮好的' }], { stopReason: 'stop' }), + asst([], { stopReason: 'error', errorMessage: '第二轮炸了' }), + ], + }); + assert.equal(got.ok, false); + assert.equal(got.error, '第二轮炸了'); +}); + +// ─── describeError ─── + +test('describeError 只取首行', () => { + // 报错原文会被填进故障邮件的正文,多行堆栈会把那封信淹掉 + assert.equal(describeError(new Error('炸了\n at foo (bar.js:1)')), '炸了'); + assert.equal(describeError('单行错误'), '单行错误'); +}); + +test('describeError 带上 code', () => { + const e = new Error('connect failed'); + e.code = 'ECONNREFUSED'; + assert.equal(describeError(e), 'ECONNREFUSED: connect failed'); +}); + +test('describeError 容错', () => { + assert.equal(describeError(null), ''); + assert.equal(describeError(undefined), ''); +}); + +// ─── 提示词(B-3.4 / B-3.5)─── + +const mailData = { + mail_id: 'm-1', + from_name: 'admin', + subject: '排查连接泄漏', + to_workspace: '/home/program/agentmail', +}; + +test('不变量:提示词写明回信由桥自动发', () => { + // 不说的话模型会自己调 send_mail,而桥在轮次结束时也会转发一次 —— + // 同一件事两封邮件(生产里真实发生过)。 + const p = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: false }); + assert.match(p, /回信不用你自己发/); +}); + +test('不变量:提示词带 mail_id 与 read_inbox 指引', () => { + // 事件里只有主题,正文和附件清单都在收件箱里;不给 mail_id 模型无法定位这一封 + const p = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: false }); + assert.match(p, /m-1/); + assert.match(p, /read_inbox/); +}); + +test('首封带身份,续谈不重复带', () => { + const first = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: false }); + const again = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: true }); + assert.match(first, /你是 pi/); + assert.doesNotMatch(again, /你是 pi/); + assert.match(again, /续谈/); +}); + +test('补投的邮件在提示词里说明来源', () => { + // 不说明的话模型会以为这是刚到的、按「立即响应」的语气回 + const p = buildMailPrompt({ + agentName: 'pi', + data: { ...mailData, catchup: true }, + kind: 'mail', + reused: false, + }); + assert.match(p, /积压/); +}); + +test('不变量:带上服务端算好的 reply_address', () => { + // 模型确实会自己发信(要抄送第三方、或分多封交代不同的事)。 + // 让它自己拼三维地址的话,`.new` 会被拼进去 —— 回信静默开出一条新会话, + // 原来的线索里再无下文。服务端在 new_mail 里已经算好了这个地址。 + const p = buildMailPrompt({ + agentName: 'pi', + data: { ...mailData, reply_address: 'admin@.排查连接泄漏' }, + kind: 'mail', + reused: false, + }); + assert.match(p, /admin@\.排查连接泄漏/); +}); + +test('没有 reply_address 时不留空行占位', () => { + const p = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: false }); + assert.doesNotMatch(p, /回信地址/); +}); + +test('权限决策的提示词带决策与决策人', () => { + const p = buildMailPrompt({ + agentName: 'pi', + data: { decision: '同意', decided_by: 'zhang' }, + kind: 'permission', + reused: true, + }); + assert.match(p, /同意/); + assert.match(p, /zhang/); +}); + +// ─── 幂等键 ─── + +test('relayKey 由会话 id 与叶子 id 组成', () => { + assert.equal(relayKeyFor('sess-1', 'leaf-9'), 'sess-1:leaf-9'); +}); + +test('不变量:叶子 id 缺失时仍产出稳定键', () => { + // 返回空串会让服务端把 relay_key 当作「没给」,于是幂等失效、同一轮转两次 + assert.equal(relayKeyFor('sess-1', null), 'sess-1:noleaf'); + assert.equal(relayKeyFor('sess-1', undefined), 'sess-1:noleaf'); +}); diff --git a/plugins/pi-mail-bridge/test/workspace.test.mjs b/plugins/pi-mail-bridge/test/workspace.test.mjs new file mode 100644 index 0000000..4913747 --- /dev/null +++ b/plugins/pi-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/); +}); diff --git a/web/package-lock.json b/web/package-lock.json index 35f8b3d..8d2327a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -15,16 +15,29 @@ "zustand": "^4.5.4" }, "devDependencies": { + "@testing-library/dom": "10.4.1", + "@testing-library/jest-dom": "6.9.1", + "@testing-library/react": "16.3.0", + "@testing-library/user-event": "14.6.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.19", + "jsdom": "26.1.0", "postcss": "^8.4.39", "tailwindcss": "^3.4.6", "typescript": "^5.5.3", - "vite": "^5.3.4" + "vite": "^5.3.4", + "vitest": "3.2.4" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.3.0", "resolved": "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.3.0.tgz", @@ -38,6 +51,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -272,6 +306,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.29.7.tgz", @@ -320,6 +364,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmmirror.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -1215,6 +1374,102 @@ "win32" ] }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmmirror.com/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.0", + "resolved": "https://registry.npmmirror.com/@testing-library/react/-/react-16.3.0.tgz", + "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmmirror.com/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1260,6 +1515,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmmirror.com/@types/debug/-/debug-4.1.13.tgz", @@ -1269,6 +1535,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", @@ -1367,6 +1640,180 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmmirror.com/any-promise/-/any-promise-1.3.0.tgz", @@ -1395,6 +1842,26 @@ "dev": true, "license": "MIT" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/autoprefixer": { "version": "10.5.4", "resolved": "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.5.4.tgz", @@ -1515,6 +1982,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -1556,6 +2033,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmmirror.com/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmmirror.com/character-entities/-/character-entities-2.0.2.tgz", @@ -1596,6 +2090,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz", @@ -1661,6 +2165,13 @@ "dev": true, "license": "MIT" }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz", @@ -1674,12 +2185,40 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmmirror.com/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", @@ -1697,6 +2236,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmmirror.com/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -1710,6 +2256,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz", @@ -1746,6 +2302,13 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.418", "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.418.tgz", @@ -1753,6 +2316,19 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", @@ -1763,6 +2339,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", @@ -1834,6 +2417,26 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmmirror.com/extend/-/extend-3.0.2.tgz", @@ -2008,6 +2611,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmmirror.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -2018,6 +2634,57 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmmirror.com/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -2142,6 +2809,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmmirror.com/jiti/-/jiti-1.21.7.tgz", @@ -2158,6 +2832,46 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmmirror.com/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", @@ -2226,6 +2940,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2236,6 +2957,26 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmmirror.com/markdown-table/-/markdown-table-3.0.4.tgz", @@ -3103,6 +3844,16 @@ "node": ">=8.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", @@ -3160,6 +3911,13 @@ "node": ">=0.10.0" } }, + "node_modules/nwsapi": { + "version": "2.2.27", + "resolved": "https://registry.npmmirror.com/nwsapi/-/nwsapi-2.2.27.tgz", + "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", @@ -3205,6 +3963,19 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", @@ -3212,6 +3983,23 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", @@ -3405,6 +4193,21 @@ "dev": true, "license": "MIT" }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/property-information": { "version": "7.2.0", "resolved": "https://registry.npmmirror.com/property-information/-/property-information-7.2.0.tgz", @@ -3415,6 +4218,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -3461,6 +4274,13 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/react-markdown": { "version": "9.1.0", "resolved": "https://registry.npmmirror.com/react-markdown/-/react-markdown-9.1.0.tgz", @@ -3518,6 +4338,20 @@ "node": ">=8.10.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmmirror.com/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -3663,6 +4497,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmmirror.com/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz", @@ -3687,6 +4528,26 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz", @@ -3706,6 +4567,13 @@ "semver": "bin/semver.js" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3726,6 +4594,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmmirror.com/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmmirror.com/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -3740,6 +4622,39 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmmirror.com/style-to-js/-/style-to-js-1.1.21.tgz", @@ -3794,6 +4709,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -3855,6 +4777,20 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -3903,6 +4839,56 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmmirror.com/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmmirror.com/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3916,6 +4902,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmmirror.com/trim-lines/-/trim-lines-3.0.1.tgz", @@ -4179,6 +5191,232 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", diff --git a/web/package.json b/web/package.json index f536f31..514973e 100644 --- a/web/package.json +++ b/web/package.json @@ -8,9 +8,11 @@ "build": "vite build", "preview": "vite preview", "typecheck": "tsc --noEmit", - "test": "node test/markdown-xss.test.mjs && node test/narrow-layout.test.mjs", + "test": "node test/markdown-xss.test.mjs && node test/narrow-layout.test.mjs && vitest run", "test:narrow": "node test/manual/narrow-verify.mjs", - "test:wide": "node test/manual/wide-regression.mjs" + "test:wide": "node test/manual/wide-regression.mjs", + "test:components": "vitest run", + "test:watch": "vitest" }, "dependencies": { "react": "^18.3.1", @@ -20,13 +22,19 @@ "zustand": "^4.5.4" }, "devDependencies": { + "@testing-library/dom": "10.4.1", + "@testing-library/jest-dom": "6.9.1", + "@testing-library/react": "16.3.0", + "@testing-library/user-event": "14.6.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.19", + "jsdom": "26.1.0", "postcss": "^8.4.39", "tailwindcss": "^3.4.6", "typescript": "^5.5.3", - "vite": "^5.3.4" + "vite": "^5.3.4", + "vitest": "3.2.4" } } diff --git a/web/src/components/MailView.tsx b/web/src/components/MailView.tsx index 5fea2d3..1d42f16 100644 --- a/web/src/components/MailView.tsx +++ b/web/src/components/MailView.tsx @@ -475,7 +475,14 @@ function ThreadCard({ mail, onForward }: { mail: Mail; onForward?: () => void }) ); } -function PermissionPanel({ mail }: { mail: Mail }) { +/** + * 权限请求的决策面板。 + * + * 导出供测试单独渲染:通过整个 MailView 渲染它需要先把 mailStore 与 + * sessionStore 摆到「当前正看着一封 permission_request 邮件」的状态, + * 那些铺垫与这个组件本身的行为无关。 + */ +export function PermissionPanel({ mail }: { mail: Mail }) { const [note, setNote] = useState(''); const [busy, setBusy] = useState(false); const [decided, setDecided] = useState(mail.permission_result || ''); diff --git a/web/src/components/WorkCard.tsx b/web/src/components/WorkCard.tsx index cee4819..7b6ed69 100644 --- a/web/src/components/WorkCard.tsx +++ b/web/src/components/WorkCard.tsx @@ -122,8 +122,10 @@ export function WorkCard({ * 0 = 不限,此时不显示 —— 一个「0/0」或「不限」的徽标对每张卡片都成立, * 等于纯噪声。只在真正设了上限时才占位置。 * 剩 1 个来回时转红:那是需要人介入的时刻(要么加预算,要么让它收尾)。 + * + * 导出供测试单独渲染 —— 它是纯展示件,而通过 WorkCard 渲染要先造一整个 Contact。 */ -function BudgetChip({ max, used }: { max: number; used: number }) { +export function BudgetChip({ max, used }: { max: number; used: number }) { if (!max || max <= 0) return null; const remaining = Math.max(max - used, 0); diff --git a/web/test/components/AddressInput.test.tsx b/web/test/components/AddressInput.test.tsx new file mode 100644 index 0000000..2c120a0 --- /dev/null +++ b/web/test/components/AddressInput.test.tsx @@ -0,0 +1,264 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import AddressInput from '../../src/components/AddressInput'; +import * as api from '../../src/api/client'; +import type { SessionCandidate } from '../../src/types'; + +/** + * 三段式地址补全。 + * + * 每条断言都对应一个真实存在过或很容易犯的错: + * - 问错层(写了 @ 还在问 name 的候选) + * - 过滤后 suggestions 与 candidates 错位,标题挂到别的别名上 + * - 选中 name 后没自动补 `@`,人得自己敲 + * - session 段选完还开着下拉,挡住下面的输入框 + */ + +/** 造一个 suggestAddress 的响应。candidates 与 suggestions 必须同序同长。 */ +function res( + kind: 'name' | 'path' | 'session', + suggestions: string[], + candidates?: Partial[] +) { + return { + kind, + suggestions, + candidates: candidates?.map((c, i) => ({ + alias: suggestions[i], + title: '', + source: 'mail' as const, + unread: 0, + ...c + })) + }; +} + +/** 渲染一个受控的 AddressInput,返回 input 与「当前值」读取器。 */ +function setup(initial = '') { + let current = initial; + const onChange = vi.fn((v: string) => { + current = v; + rerender(); + }); + const { rerender: rr } = render( + React.createElement(AddressInput, { value: current, onChange }) + ); + function rerender() { + rr(React.createElement(AddressInput, { value: current, onChange })); + } + return { + onChange, + value: () => current, + input: () => screen.getByRole('textbox') as HTMLInputElement + }; +} + +describe('AddressInput 三段式补全', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('还没写 @ 时问 name 层', async () => { + const spy = vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh', 'opencode'])); + const { input } = setup(); + + await userEvent.click(input()); + await userEvent.type(input(), 'ds'); + + await waitFor(() => expect(spy).toHaveBeenCalled()); + // 问 name 层时不带任何参数 + expect(spy.mock.calls[spy.mock.calls.length - 1]).toEqual([]); + }); + + it('写了 @ 没写 . 时带 name 去问 path 层', async () => { + const spy = vi + .spyOn(api, 'suggestAddress') + .mockResolvedValue(res('path', ['/home/program/agentmail'])); + const { input } = setup(); + + await userEvent.click(input()); + await userEvent.type(input(), 'dsh@/home'); + + // 问错层的后果:候选里全是 Agent 名,人以为这个工作区下没有会话 + await waitFor(() => expect(spy).toHaveBeenCalledWith('dsh')); + }); + + it('写了 . 时带 name 与 path 去问 session 层', async () => { + const spy = vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('session', ['refactor'])); + const { input } = setup(); + + await userEvent.click(input()); + await userEvent.type(input(), 'dsh@/home/x.ref'); + + await waitFor(() => expect(spy).toHaveBeenCalledWith('dsh', '/home/x')); + }); + + it('path 里的 . 不被当作 session 分隔符(按最后一个 . 切)', async () => { + const spy = vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('session', ['a'])); + const { input } = setup(); + + await userEvent.click(input()); + // path 自身含 .:a.b/proj + await userEvent.type(input(), 'dsh@/home/a.b/proj.ref'); + + await waitFor(() => expect(spy).toHaveBeenCalledWith('dsh', '/home/a.b/proj')); + }); + + it('选中 name 候选后自动补 @', async () => { + vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh', 'opencode'])); + const { input, value } = setup(); + + await userEvent.click(input()); + await waitFor(() => expect(screen.getByText('dsh')).toBeInTheDocument()); + await userEvent.click(screen.getByText('dsh')); + + // 不补 @ 的话人得自己敲,而这是三段里唯一没有歧义的分隔符 + expect(value()).toBe('dsh@'); + }); + + it('选中 path 候选后自动补 .', async () => { + vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('path', ['/home/program/agentmail'])); + const { input, value } = setup('dsh@'); + + await userEvent.click(input()); + await waitFor(() => expect(screen.getByText('/home/program/agentmail')).toBeInTheDocument()); + await userEvent.click(screen.getByText('/home/program/agentmail')); + + expect(value()).toBe('dsh@/home/program/agentmail.'); + }); + + it('选中 session 候选后拼出完整地址并关掉下拉', async () => { + vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('session', ['refactor', 'new'])); + const { input, value } = setup('dsh@/home/x.'); + + await userEvent.click(input()); + await waitFor(() => expect(screen.getByText('refactor')).toBeInTheDocument()); + await userEvent.click(screen.getByText('refactor')); + + expect(value()).toBe('dsh@/home/x.refactor'); + // session 是最后一段,选完还开着下拉会挡住下面的输入框 + await waitFor(() => expect(screen.queryByText('会话别名(new 为新建)')).toBeNull()); + }); + + it('标题参与过滤,且过滤后标题不错位到别的别名上', async () => { + // 三条候选,只有第二条的标题含「缓存」 + vi.spyOn(api, 'suggestAddress').mockResolvedValue( + res( + 'session', + ['witty-planet', 'brisk-harbor', 'calm-river'], + [{ title: '重构导入路径' }, { title: '缓存选型讨论' }, { title: '修 CI' }] + ) + ); + const { input } = setup('dsh@/home/x.'); + + await userEvent.click(input()); + await userEvent.type(input(), '缓存'); + + await waitFor(() => { + // 命中的是 brisk-harbor,标题必须还是它自己的 + expect(screen.getByText('brisk-harbor')).toBeInTheDocument(); + expect(screen.getByText('缓存选型讨论')).toBeInTheDocument(); + }); + // 分别过滤两个数组会让 witty-planet 的标题挂到 brisk-harbor 上 + expect(screen.queryByText('重构导入路径')).toBeNull(); + expect(screen.queryByText('witty-planet')).toBeNull(); + }); + + it('platform 来源的候选标出「平台」,new 标出「新建会话」', async () => { + vi.spyOn(api, 'suggestAddress').mockResolvedValue( + res( + 'session', + ['ses-mirror', 'new'], + [ + { title: '平台侧在跑的会话', source: 'platform' }, + { source: 'new' } + ] + ) + ); + const { input } = setup('dsh@/home/x.'); + + await userEvent.click(input()); + + await waitFor(() => { + // 不标的话人不知道这一封是「接入」一条已在跑的会话 + expect(screen.getByText('平台')).toBeInTheDocument(); + expect(screen.getByText('新建会话')).toBeInTheDocument(); + }); + }); + + it('未读数显示在候选上', async () => { + vi.spyOn(api, 'suggestAddress').mockResolvedValue( + res('session', ['busy-session'], [{ title: '有新消息', unread: 3 }]) + ); + const { input } = setup('dsh@/home/x.'); + + await userEvent.click(input()); + + await waitFor(() => expect(screen.getByText('3')).toBeInTheDocument()); + }); + + it('键盘上下键移动选中项,Enter 采用', async () => { + vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh', 'opencode'])); + const { input, value } = setup(); + + await userEvent.click(input()); + await waitFor(() => expect(screen.getByText('opencode')).toBeInTheDocument()); + + await userEvent.keyboard('{ArrowDown}{Enter}'); + + // 初始 active=0(dsh),下移一格到 opencode + expect(value()).toBe('opencode@'); + }); + + it('Escape 关掉下拉但不清空已输入的内容', async () => { + vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh'])); + const { input, value } = setup(); + + await userEvent.click(input()); + await userEvent.type(input(), 'ds'); + await waitFor(() => expect(screen.getByText('Agent 名')).toBeInTheDocument()); + + await userEvent.keyboard('{Escape}'); + + await waitFor(() => expect(screen.queryByText('Agent 名')).toBeNull()); + expect(value()).toBe('ds'); + }); + + it('接口失败时静默清空候选,不炸也不弹错', async () => { + vi.spyOn(api, 'suggestAddress').mockRejectedValue(new Error('network down')); + const { input, value } = setup(); + + await userEvent.click(input()); + await userEvent.type(input(), 'ds'); + + // 补全只是便利功能,失败不该阻止人手动输入完整地址 + await waitFor(() => expect(screen.queryByText('Agent 名')).toBeNull()); + expect(value()).toBe('ds'); + }); + + it('allowMultiple 时补全只作用于最后一段,前面的地址保留', async () => { + vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['opencode'])); + + let current = 'dsh@/home/x.a, '; + const onChange = vi.fn((v: string) => { + current = v; + }); + render( + React.createElement(AddressInput, { + value: current, + onChange, + allowMultiple: true + }) + ); + + await userEvent.click(screen.getByRole('textbox')); + await waitFor(() => expect(screen.getByText('opencode')).toBeInTheDocument()); + await userEvent.click(screen.getByText('opencode')); + + // 抄送场景:前面已填好的地址不能被补全覆盖 + expect(current).toBe('dsh@/home/x.a, opencode@'); + }); +}); diff --git a/web/test/components/BudgetChip.test.tsx b/web/test/components/BudgetChip.test.tsx new file mode 100644 index 0000000..0b15174 --- /dev/null +++ b/web/test/components/BudgetChip.test.tsx @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { BudgetChip } from '../../src/components/WorkCard'; + +/** + * 往返预算徽标。 + * + * 它是「这个任务还剩几个来回」的唯一视觉提示,两件事必须对: + * - 显示的是**剩余**而不是已用(人做决定看的是「还能问几次」) + * - 0 = 不限时**不显示**,而不是显示「0/0」 + */ +describe('BudgetChip 预算渲染', () => { + it('显示剩余数而不是已用数', () => { + render(React.createElement(BudgetChip, { max: 20, used: 3 })); + + // 20 个上限、用了 3 个 → 剩 17。显示 3/20 会让人以为快用完了 + expect(screen.getByText('17/20')).toBeInTheDocument(); + }); + + it('max 为 0(不限)时完全不渲染', () => { + const { container } = render(React.createElement(BudgetChip, { max: 0, used: 0 })); + + // 「0/0」或「不限」对每张卡片都成立,等于纯噪声 + expect(container.firstChild).toBeNull(); + }); + + it('max 为负数时也不渲染(脏数据兜底)', () => { + const { container } = render(React.createElement(BudgetChip, { max: -1, used: 0 })); + expect(container.firstChild).toBeNull(); + }); + + it('用超时剩余按 0 显示,不出现负数', () => { + render(React.createElement(BudgetChip, { max: 5, used: 8 })); + + // 「-3/5」看起来像 bug,而实际情形(免配额转发不计数、人手动下调过上限)是合法的 + expect(screen.getByText('0/5')).toBeInTheDocument(); + }); + + it('剩余为 0 时转红并标注「已用尽」', () => { + render(React.createElement(BudgetChip, { max: 5, used: 5 })); + + const chip = screen.getByText('0/5').closest('span')!; + expect(chip.className).toContain('bg-red-100'); + expect(chip.getAttribute('title')).toContain('已用尽'); + }); + + it('剩余 1 个时转橙 —— 那是需要人介入的时刻', () => { + render(React.createElement(BudgetChip, { max: 20, used: 19 })); + + const chip = screen.getByText('1/20').closest('span')!; + // 要么加预算,要么让它收尾;这一步不提示的话下一封信就被挡住了 + expect(chip.className).toContain('bg-orange-100'); + }); + + it('余量充足时用中性灰,不抢注意力', () => { + render(React.createElement(BudgetChip, { max: 20, used: 2 })); + + const chip = screen.getByText('18/20').closest('span')!; + expect(chip.className).toContain('bg-gray-100'); + expect(chip.className).not.toContain('red'); + expect(chip.className).not.toContain('orange'); + }); + + it('title 里带已用/上限,供悬停查看细节', () => { + render(React.createElement(BudgetChip, { max: 20, used: 7 })); + + const chip = screen.getByText('13/20').closest('span')!; + // 徽标上只有剩余,具体用了几个放在 title 里 —— 徽标要窄 + expect(chip.getAttribute('title')).toContain('已用 7/20'); + }); + + it('剩余 2 个时还不转橙(阈值是 <= 1)', () => { + render(React.createElement(BudgetChip, { max: 10, used: 8 })); + + const chip = screen.getByText('2/10').closest('span')!; + expect(chip.className).toContain('bg-gray-100'); + }); +}); diff --git a/web/test/components/PermissionPanel.test.tsx b/web/test/components/PermissionPanel.test.tsx new file mode 100644 index 0000000..a4a7267 --- /dev/null +++ b/web/test/components/PermissionPanel.test.tsx @@ -0,0 +1,203 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { PermissionPanel } from '../../src/components/MailView'; +import * as api from '../../src/api/client'; +import { useMailStore } from '../../src/stores/mailStore'; +import { useSessionStore } from '../../src/stores/sessionStore'; +import type { Mail } from '../../src/types'; + +/** + * 权限决策面板。 + * + * 这是全站唯一一处「人的一次点击直接放行 Agent 的危险操作」, + * 因此断言集中在两件事: + * - 点下去到底把什么发给了服务端(选项原文,不是归一化后的 allow/deny) + * - 已决策的请求不能再点第二次 + */ + +function permMail(over: Partial = {}): Mail { + return { + mail_id: 'm-1', + session_id: 's-1', + parent_mail_id: null, + from_name: 'dsh', + from_workspace: '/home/program/agentmail', + to_name: 'admin', + to_workspace: '', + cc_list: [], + subject: '请求批准:删除 build/', + body: '将执行 rm -rf build/', + mail_type: 'permission_request', + permission_options: undefined, + permission_result: '', + status: 'unread', + created_at: '2026-09-03T00:00:00Z', + hop_limit: 5, + ...over + } as Mail; +} + +describe('PermissionPanel 决策', () => { + beforeEach(() => { + vi.restoreAllMocks(); + // fetchInbox / selectSession 会打网络,替换成空实现 + useMailStore.setState({ fetchInbox: vi.fn(async () => {}) } as any); + useSessionStore.setState({ selectSession: vi.fn(async () => {}) } as any); + }); + + it('没有 permission_options 时给默认的同意/拒绝两个选项', () => { + render(React.createElement(PermissionPanel, { mail: permMail() })); + + expect(screen.getByRole('button', { name: /同意/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /拒绝/ })).toBeInTheDocument(); + }); + + it('有 permission_options 时用它,且顺序保持', () => { + render( + React.createElement(PermissionPanel, { + mail: permMail({ permission_options: ['只这一次', '总是允许', '拒绝'] }) + }) + ); + + const btns = screen.getAllByRole('button').map(b => b.textContent?.trim()); + // 顺序是 Agent 给的语义顺序,重排会让「拒绝」跑到人的手指默认位置上 + expect(btns).toEqual(['只这一次', '总是允许', '拒绝']); + }); + + it('点选项时把【选项原文】发给服务端', async () => { + const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any); + render( + React.createElement(PermissionPanel, { + mail: permMail({ permission_options: ['只这一次', '拒绝'] }) + }) + ); + + await userEvent.click(screen.getByRole('button', { name: /只这一次/ })); + + // 关键:不能归一化成 allow/deny —— 「只这一次」与「总是允许」的区别 + // 只有 Agent 侧的权限机制懂,服务端与前端都不该替它翻译 + await waitFor(() => + expect(spy).toHaveBeenCalledWith('m-1', '只这一次', undefined) + ); + }); + + it('填了备注时一起发出去', async () => { + const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any); + render(React.createElement(PermissionPanel, { mail: permMail() })); + + await userEvent.type(screen.getByPlaceholderText('备注(可选)'), '只删 build,别动 dist'); + await userEvent.click(screen.getByRole('button', { name: /同意/ })); + + await waitFor(() => + expect(spy).toHaveBeenCalledWith('m-1', '同意', '只删 build,别动 dist') + ); + }); + + it('备注为空时传 undefined 而不是空字符串', async () => { + const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any); + render(React.createElement(PermissionPanel, { mail: permMail() })); + + await userEvent.click(screen.getByRole('button', { name: /同意/ })); + + // 空串会在决策邮件里留一行空的「备注:」 + await waitFor(() => expect(spy).toHaveBeenCalledWith('m-1', '同意', undefined)); + }); + + it('决策后变成「已处理」,不再显示按钮', async () => { + vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any); + render(React.createElement(PermissionPanel, { mail: permMail() })); + + await userEvent.click(screen.getByRole('button', { name: /同意/ })); + + await waitFor(() => expect(screen.getByText('已处理:')).toBeInTheDocument()); + // 还能点第二次的话人会以为第一次没生效,而服务端那边早已决策 + expect(screen.queryByRole('button')).toBeNull(); + }); + + it('已经有 permission_result 的邮件直接显示结论', () => { + render( + React.createElement(PermissionPanel, { + mail: permMail({ permission_result: '拒绝' }) + }) + ); + + expect(screen.getByText('拒绝')).toBeInTheDocument(); + expect(screen.queryByRole('button')).toBeNull(); + }); + + it('提交中禁用所有按钮,避免重复决策', async () => { + let release: (v: any) => void = () => {}; + vi.spyOn(api, 'decidePermission').mockReturnValue( + new Promise(res => { + release = res; + }) as any + ); + render(React.createElement(PermissionPanel, { mail: permMail() })); + + await userEvent.click(screen.getByRole('button', { name: /同意/ })); + + // 一次危险操作被批准两次,Agent 那边可能真的执行两遍 + await waitFor(() => { + for (const b of screen.getAllByRole('button')) { + expect(b).toBeDisabled(); + } + }); + + // 收尾:让悬挂的 Promise 落定并等状态更新走完, + // 否则组件在测试结束后才 setState,React 会报 act 警告 + await act(async () => { + release({ status: 'decided' }); + }); + await waitFor(() => expect(screen.getByText('已处理:')).toBeInTheDocument()); + }); + + it('提交失败时恢复可点,不假装已决策', async () => { + vi.spyOn(api, 'decidePermission').mockRejectedValue(new Error('500')); + vi.spyOn(console, 'error').mockImplementation(() => {}); + render(React.createElement(PermissionPanel, { mail: permMail() })); + + await userEvent.click(screen.getByRole('button', { name: /同意/ })); + + // 失败后显示「已处理」是最糟的结果:人以为批过了,Agent 还在等 + await waitFor(() => expect(screen.getByRole('button', { name: /同意/ })).toBeEnabled()); + expect(screen.queryByText('已处理:')).toBeNull(); + }); + + it('决策成功后刷新收件箱并选中该会话', async () => { + vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any); + const fetchInbox = vi.fn(async () => {}); + const selectSession = vi.fn(async () => {}); + useMailStore.setState({ fetchInbox } as any); + useSessionStore.setState({ selectSession } as any); + + render(React.createElement(PermissionPanel, { mail: permMail() })); + await userEvent.click(screen.getByRole('button', { name: /同意/ })); + + // 不刷新的话列表里那封还是「未读的权限请求」,人会以为没生效 + await waitFor(() => { + expect(fetchInbox).toHaveBeenCalledWith('all'); + expect(selectSession).toHaveBeenCalledWith('s-1'); + }); + }); + + it('同意类选项用绿色,其余用红色', () => { + render( + React.createElement(PermissionPanel, { + mail: permMail({ permission_options: ['允许', 'approve', '拒绝', '算了'] }) + }) + ); + + const cls = (name: string) => + screen.getByRole('button', { name: new RegExp(name) }).className; + + // 颜色是唯一的视觉提示:点错一次就放行了一个危险操作 + expect(cls('允许')).toContain('bg-green-600'); + expect(cls('approve')).toContain('bg-green-600'); + expect(cls('拒绝')).toContain('text-red-700'); + // 不在同意词表里的一律按「否」处理 —— 宁可让人多看一眼 + expect(cls('算了')).toContain('text-red-700'); + }); +}); diff --git a/web/test/components/setup.ts b/web/test/components/setup.ts new file mode 100644 index 0000000..cb6448c --- /dev/null +++ b/web/test/components/setup.ts @@ -0,0 +1,45 @@ +import { afterEach, expect } from 'vitest'; +import { cleanup } from '@testing-library/react'; +import * as matchers from '@testing-library/jest-dom/matchers'; + +/** + * 组件测试的全局准备。 + * + * 三件事,每件都对应一类会串味的状态: + * 1. 每个用例后卸载组件树(不卸载的话下一个用例的 getByText 会命中上一个的 DOM) + * 2. 重置 zustand store(它是模块级单例,跨用例共享) + * 3. 提供 matchMedia —— jsdom 没有实现它,而 useIsNarrow 直接调 + */ +expect.extend(matchers); + +afterEach(() => { + cleanup(); +}); + +/** + * jsdom 不实现 matchMedia。useIsNarrow 会直接调它并挂 change 监听, + * 缺了就抛 `matchMedia is not a function`,整个测试文件挂掉。 + * + * 默认返回 false(宽屏)。要测窄屏的用例用 setViewport(true) 覆盖。 + */ +function installMatchMedia(narrow: boolean) { + const listeners = new Set<(e: MediaQueryListEvent) => void>(); + (window as any).matchMedia = (query: string) => ({ + matches: narrow, + media: query, + onchange: null, + addEventListener: (_: string, cb: (e: MediaQueryListEvent) => void) => listeners.add(cb), + removeEventListener: (_: string, cb: (e: MediaQueryListEvent) => void) => listeners.delete(cb), + // 老式 API,某些库仍在用 + addListener: (cb: (e: MediaQueryListEvent) => void) => listeners.add(cb), + removeListener: (cb: (e: MediaQueryListEvent) => void) => listeners.delete(cb), + dispatchEvent: () => false + }); +} + +installMatchMedia(false); + +/** 让当前测试文件里的组件按窄屏/宽屏渲染。在 render 之前调。 */ +export function setViewport(narrow: boolean) { + installMatchMedia(narrow); +} diff --git a/web/tsconfig.json b/web/tsconfig.json index a4c834a..1a37597 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -14,7 +14,11 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + // vitest 的 globals: true 让 describe/it/expect 无需 import; + // jest-dom 提供 toBeInTheDocument 等 matcher 的类型。 + // 不加这两个的话 tsc --noEmit 会把整套测试报成「找不到名称」。 + "types": ["vitest/globals", "@testing-library/jest-dom"] }, - "include": ["src"] + "include": ["src", "test/components", "vitest.config.ts"] } diff --git a/web/vitest.config.ts b/web/vitest.config.ts new file mode 100644 index 0000000..eb56e7a --- /dev/null +++ b/web/vitest.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; + +/** + * 组件测试配置。 + * + * 与 vite.config.ts 分开:那份是构建与开发服务器的配置,插件链和 test 段混在 + * 一起时 `vite build` 也会解析 jsdom 这些只有测试才需要的依赖。 + * + * 只跑 test/components/ 下的用例。test/ 顶层那几个(markdown-xss、 + * narrow-layout)是 node:test / 手写断言脚本,由 `npm test` 直接用 node 跑 —— + * 它们不需要 DOM,套一层 vitest 只是变慢。 + */ +export default defineConfig({ + plugins: [react()], + test: { + include: ['test/components/**/*.test.tsx'], + environment: 'jsdom', + globals: true, + setupFiles: ['test/components/setup.ts'], + // 每个文件跑完清掉 DOM 与 mock,避免上一个用例的残留影响下一个 + restoreMocks: true, + clearMocks: true, + unstubEnvs: true, + unstubGlobals: true + } +});