From ca64d12057965e8a22fc158ed6e4744db581314f Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 2 Sep 2026 20:05:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B7=A5=E4=BD=9C=E5=8C=BA=E5=BD=92?= =?UTF-8?q?=E5=B1=9E=E4=BF=AE=E5=A4=8D=20+=20=E5=B9=B3=E5=8F=B0=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=90=8C=E6=AD=A5=20+=20=E5=AF=B9=E8=AF=9D=E6=A0=91?= =?UTF-8?q?=E6=95=B4=E6=A0=91=E5=B1=95=E5=BC=80=20+=20DSH=20=E6=8F=92?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 四个各自独立的生产缺陷,共同的根源都是「本该属于会话的属性没有存在会话上」。 ## 1. dsh 指定工作目录完全失效(所有会话落进「未分组」) 插件建会话时用的 cwd 是自己拼的 `~/.dsh/mail-sessions/mail-` —— 每封邮件一个全新的空目录。DSH 与 opencode 都按 cwd 给会话分组,于是所有 邮件会话既不属于任何项目、彼此也不同组。 而 Gateway 从来没把地址里的 path 位发给插件:`notifyRecipients` 的 payload 只有 mail_id/session_id/from_name/subject,`to_workspace` 虽然入库了却不在 SSE 事件里,插件即使想用也拿不到。 - SSE `new_mail` 事件加 `to_workspace`。**每个收件方拿到自己那个地址的 path**, 不是主收件人的 —— 抄送给 opencode@/a 与主发给 dsh@/b 是两个工作区 - 两个插件的 cwd 都改为取寻址的 path 位;不存在的目录**不创建**而是回退到 兜底目录(一个笔误不该在磁盘上落下真目录,Agent 会在里面一无所获地干活) - 拒绝相对路径:cwd 的相对基准是 harness 进程的启动目录,systemd 下通常是 `/` ## 2. 会话别名列不出工作区下的历史会话(无法选择) workspace 只存在于 `mails.to_workspace` 上,「这个工作区下有哪些会话」必须 JOIN mails 再从收发双方的 workspace 里猜。而 Agent 回信时 from_workspace 填的是 **Agent 名**而不是路径,旧条件 `to_workspace = $p OR from_workspace = $p` 在只剩 Agent 回信可匹配时两边都对不上。 - `sessions.workspace` 新列,`CreateSession` 从地址的 path 位带入 - `SuggestSessionCandidates` 取代 `SuggestSessionsFor`:以会话自己的 workspace 为权威,历史会话(该列为空)回退到 mails 反推 —— 升级后老会话不该消失 - `FindOrCreateDefaultSession` 同步改用会话的 workspace ## 3. 平台侧会话在补全里根本不存在 人直接在 opencode/DSH 界面上开的会话,Gateway 一无所知。 新增 `agent_platform_sessions` 镜像表,插件在心跳里上报快照。 **上报而非 Gateway 反向拉取**:当前架构是单向的(Agent 持密钥主动连 Gateway, Gateway 从不外呼),反向拉取需要它保存各平台的地址与凭证,那是另一套信任模型。 - 与 sessions 表分开存:镜像里是别人家的会话,id 属于平台的 id 空间,没有 本侧的 owner/预算/邮件。混进 sessions 会让每一处「按会话鉴权」都要先判断 这条到底是不是真的本侧会话 - **整表替换而非增量合并**:平台侧删掉的会话必须从候选里消失 —— session 位是 三态语义,指向不存在的会话直接 404 - **nil 与空数组语义不同**:插件拉不到列表时省略该字段(保留镜像), 而不是传空数组把镜像抹掉 - **subagent 子会话不上报**:实测 DSH 的 list 里混着 49 条子会话,标题就是 派活的提示词前缀(九条都叫 "You are auditing ONE file"),slug 全撞名; 它们是父 agent 内部的工作单元,人往里发邮件毫无意义 - **slug 撞名只留最近那条**:服务端只能取其中一条,上报同名项只会让补全里 出现几个点哪个都不确定的候选 - DSH 插件此前**完全没有心跳** —— Gateway 靠 last_seen 判在线,一直靠注册撑着 补全候选带标题与来源:`suggestions` 保留纯字符串数组(不打破已部署的前端与 第三方客户端),新增同序的 `candidates`。过滤时标题也参与匹配 —— 人记得的是 「缓存选型」而不是 brisk-harbor 这种随机短名。 ## 4. 对话树看不见抄送与转发产生的分支 旧实现从锚点分「祖先链 + 子树」两路展开,而**兄弟节点既不是锚点的祖先也不是 它的子孙**:一封抄送给两个 Agent 的邮件收到两个回复,从其中一个看树永远看不到 另一个;挂在原件上的转发分支同理。 改为先 `ThreadRootOf` 上溯到线索根,再从根整树 BFS。只剩一个加载方向, 因此不再需要滚动位置补偿。前端补上抄送人列表与转发标记 —— 树上两个兄弟节点 为什么并列,唯一的解释就是父邮件抄送给了两个人。 ## 5. DSH 插件(Phase 7.7) 卡了一下午的 `Cannot read properties of undefined (reading 'kind')` 根因是 `followup()` 的参数形状:DSH 要完整的 UserMessage(content + source), 而我照抄了 opencode 的 parts 数组。错误抛在 agent-loop 内部,不指向调用点。 - `agent/status` → idle 时自动转发最后一条 assistant 消息(对应 opencode 的 session.idle),复用 relay-dedup 让位于模型的主动回信,走免配额通道 - `approval/request` 权限询问转邮件问人。与 opencode 的差异:那边的 permission.ask 是同步钩子只能立即返回 ask,DSH 这边是异步 waterfall, 可以真的等人 —— 拆插件时未决询问一律 fail closed,否则 await 永不返回 - 会话别名由模型标题派生(保留中文,去掉 `.` `@` `/` 等寻址分隔符 —— 留在别名里会让它自己被解析器切开) - 逻辑放 lib/ 下的纯函数并加测试:三类约定都是「错了不当场报错、只在深处 炸一个无关错误」 ## 其他 - `deploy/reset-demo.sh`:清空演示邮件数据,保留账号与密钥。备份用 `.backup` 而非 cp(WAL 下 cp 拿到的是缺尾巴的库);手工按依赖顺序删(SQLite 的 foreign_keys 默认关,声明了 REFERENCES 也不级联);只在目标是默认库时才碰 systemd(演练时误停过一次生产服务) - 插件 dist/ 不进版本库,install.sh 负责构建 - `permission_decision` 事件补 session_id:插件重启丢了待决映射时要靠它定位会话 --- .gitignore | 6 + deploy/install.sh | 14 + deploy/reset-demo.sh | 113 +++ gateway/internal/db/migrate.go | 4 + gateway/internal/db/migrations/init.sql | 38 + .../internal/db/migrations/init_sqlite.sql | 72 ++ gateway/internal/handler/agents.go | 46 +- gateway/internal/handler/contacts.go | 21 +- gateway/internal/handler/mail.go | 19 +- gateway/internal/handler/permission.go | 6 +- gateway/internal/handler/thread.go | 144 ++-- gateway/internal/repo/budget_test.go | 2 +- gateway/internal/repo/markread_test.go | 2 +- gateway/internal/repo/models_scope.go | 204 +++++ gateway/internal/repo/platform_sessions.go | 222 ++++++ .../internal/repo/platform_sessions_test.go | 354 +++++++++ gateway/internal/repo/repo.go | 66 +- gateway/internal/repo/thread.go | 93 ++- gateway/internal/repo/thread_test.go | 265 ++++++- plugins/dsh-mail-bridge/cordis.patch.yml | 14 + plugins/dsh-mail-bridge/lib/message.d.ts | 10 + plugins/dsh-mail-bridge/lib/message.js | 88 +++ plugins/dsh-mail-bridge/lib/relay-dedup.d.ts | 14 + plugins/dsh-mail-bridge/lib/relay-dedup.js | 58 ++ .../dsh-mail-bridge/lib/session-snapshot.d.ts | 22 + .../dsh-mail-bridge/lib/session-snapshot.js | 151 ++++ plugins/dsh-mail-bridge/lib/workspace.d.ts | 6 + plugins/dsh-mail-bridge/lib/workspace.js | 65 ++ plugins/dsh-mail-bridge/package-lock.json | 102 +++ plugins/dsh-mail-bridge/package.json | 26 + plugins/dsh-mail-bridge/src/index.ts | 696 ++++++++++++++++++ plugins/dsh-mail-bridge/test/message.test.mjs | 179 +++++ .../test/session-snapshot.test.mjs | 228 ++++++ .../dsh-mail-bridge/test/workspace.test.mjs | 112 +++ plugins/dsh-mail-bridge/tsconfig.json | 16 + plugins/opencode-mail-bridge/index.js | 45 +- .../lib/session-snapshot.js | 151 ++++ plugins/opencode-mail-bridge/package.json | 2 +- .../test/session-snapshot.test.mjs | 228 ++++++ web/src/api/client.ts | 3 +- web/src/components/AddressInput.tsx | 105 ++- web/src/components/ThreadView.tsx | 180 +++-- web/src/types/index.ts | 38 +- 43 files changed, 3940 insertions(+), 290 deletions(-) create mode 100755 deploy/reset-demo.sh create mode 100644 gateway/internal/repo/models_scope.go create mode 100644 gateway/internal/repo/platform_sessions.go create mode 100644 gateway/internal/repo/platform_sessions_test.go create mode 100644 plugins/dsh-mail-bridge/cordis.patch.yml create mode 100644 plugins/dsh-mail-bridge/lib/message.d.ts create mode 100644 plugins/dsh-mail-bridge/lib/message.js create mode 100644 plugins/dsh-mail-bridge/lib/relay-dedup.d.ts create mode 100644 plugins/dsh-mail-bridge/lib/relay-dedup.js create mode 100644 plugins/dsh-mail-bridge/lib/session-snapshot.d.ts create mode 100644 plugins/dsh-mail-bridge/lib/session-snapshot.js create mode 100644 plugins/dsh-mail-bridge/lib/workspace.d.ts create mode 100644 plugins/dsh-mail-bridge/lib/workspace.js create mode 100644 plugins/dsh-mail-bridge/package-lock.json create mode 100644 plugins/dsh-mail-bridge/package.json create mode 100644 plugins/dsh-mail-bridge/src/index.ts create mode 100644 plugins/dsh-mail-bridge/test/message.test.mjs create mode 100644 plugins/dsh-mail-bridge/test/session-snapshot.test.mjs create mode 100644 plugins/dsh-mail-bridge/test/workspace.test.mjs create mode 100644 plugins/dsh-mail-bridge/tsconfig.json create mode 100644 plugins/opencode-mail-bridge/lib/session-snapshot.js create mode 100644 plugins/opencode-mail-bridge/test/session-snapshot.test.mjs diff --git a/.gitignore b/.gitignore index 88ef16b..47b5b41 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,12 @@ web/node_modules/ web/dist/ plugins/*/node_modules/ +# dsh-mail-bridge 是 TypeScript 写的,dist/ 是 tsc 的产物。 +# 与 web/dist 同一个理由:装依赖与构建都能从 package-lock.json 复现, +# 产物不进版本库。注意它**必须在装插件前构建**(package.json 的 +# main 指向 dist/index.js),deploy/install.sh 负责这一步。 +plugins/*/dist/ + # go:embed 的输入目录 = web/dist 的副本,同属构建产物。 # # 但目录本身要留下:go:embed 要求它存在才能编译,否则新克隆连 diff --git a/deploy/install.sh b/deploy/install.sh index 82c0730..953c722 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -26,6 +26,20 @@ if [[ -d "$REPO/plugins/opencode-mail-bridge/node_modules/zod" ]]; then else ( cd "$REPO/plugins/opencode-mail-bridge" && npm install --no-audit --no-fund && npm test ) fi +# DSH 插件:先构建再跑测试。 +# +# 它是 TypeScript 写的,package.json 的 main 指向 dist/index.js,而 dist/ 不进版本库 +# (与 web/dist 同理)—— 新克隆里不先 tsc,DSH 加载插件时会直接找不到入口。 +# 测试本身只碰 lib/ 下的纯函数(不依赖 dist),但先构建能把类型错误也当成门禁。 +# +# 盖住的三类约定都是「错了不当场报错、只在深处炸一个无关错误」: +# followup 消息形状、工作目录解析、会话快照的 subagent 过滤。 +if [[ -d "$REPO/plugins/dsh-mail-bridge/node_modules/typescript" ]]; then + ( cd "$REPO/plugins/dsh-mail-bridge" && npx tsc && npm test ) +else + ( cd "$REPO/plugins/dsh-mail-bridge" \ + && npm install --no-audit --no-fund && npx tsc && npm test ) +fi echo "==> 前端产物嵌入 Gateway" # 只清构建产物,不能 rm -rf 整个目录: diff --git a/deploy/reset-demo.sh b/deploy/reset-demo.sh new file mode 100755 index 0000000..6eaeda9 --- /dev/null +++ b/deploy/reset-demo.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# +# 清空演示部署里的邮件数据,保留账号与接入配置。 +# +# sudo ./deploy/reset-demo.sh # 交互确认 +# sudo ./deploy/reset-demo.sh --yes # 跳过确认(脚本化调用) +# +# 会清掉:邮件、会话、附件(含磁盘文件)、权限请求、免配额转发记录、限速计数 +# 会保留:用户与密码、Agent 注册与默认预算、Agent 密钥、用户密钥、登录会话 +# +# 保留账号是刻意的:演示环境重置一次就要重新配一遍密钥与插件, +# 那比手工删邮件还费事。要连账号一起清就直接删掉 .db 文件重启。 +set -euo pipefail + +PREFIX=${AGENTMAIL_PREFIX:-/opt/agentmail} +DEFAULT_DB=$PREFIX/data/agentmail.db +DB=${AGENTMAIL_DB:-$DEFAULT_DB} +ATTACH=${AGENTMAIL_ATTACHMENTS:-$PREFIX/data/attachments} +BACKUPS=$PREFIX/backups +SERVICE=agentmail-gateway.service + +# 只有在清理服务真正在用的那个库时才碰 systemd。 +# 演练时把 AGENTMAIL_DB 指到副本,却把生产服务停了 —— 这个坏事已经发生过一次。 +MANAGE_SERVICE=no +[[ $DB == "$DEFAULT_DB" ]] && MANAGE_SERVICE=yes + +[[ $EUID -eq 0 ]] || { echo "需要 root:sudo $0" >&2; exit 1; } +[[ -f $DB ]] || { echo "找不到数据库:$DB" >&2; exit 1; } +command -v sqlite3 >/dev/null || { echo "需要 sqlite3" >&2; exit 1; } + +count() { sqlite3 "$DB" "SELECT COUNT(*) FROM $1;" 2>/dev/null || echo 0; } + +echo "数据库:$DB" +echo +echo "将清除:" +printf ' 邮件 %s\n' "$(count mails)" +printf ' 会话 %s\n' "$(count sessions)" +printf ' 附件记录 %s\n' "$(count attachments)" +printf ' 附件文件 %s\n' "$(find "$ATTACH" -type f 2>/dev/null | wc -l)" +printf ' 权限请求 %s\n' "$(count permission_requests)" +printf ' 转发幂等键 %s\n' "$(count relayed_mails)" +echo "将保留:" +printf ' 用户 %s\n' "$(count users)" +printf ' Agent %s\n' "$(count agents)" +printf ' Agent 密钥 %s\n' "$(count agent_keys)" +printf ' 用户密钥 %s\n' "$(count user_keys)" +echo + +if [[ ${1:-} != "--yes" ]]; then + read -rp "确认清除?输入 yes 继续:" ans + [[ $ans == "yes" ]] || { echo "已取消"; exit 0; } +fi + +STAMP=$(date +%Y%m%d-%H%M%S) +install -d "$BACKUPS" +echo "==> 备份到 $BACKUPS/agentmail-$STAMP.db" +# .backup 而不是 cp:cp 会漏掉还在 WAL 里没落盘的最新写入 +sqlite3 "$DB" ".backup '$BACKUPS/agentmail-$STAMP.db'" +if [[ -d $ATTACH ]] && [[ -n $(find "$ATTACH" -type f 2>/dev/null | head -1) ]]; then + tar czf "$BACKUPS/attachments-$STAMP.tar.gz" -C "$(dirname "$ATTACH")" "$(basename "$ATTACH")" +fi + +# 停服务再清:DELETE 本身在 WAL 下可以并发跑,但清理期间进来的一封邮件 +# 会插进一个正被删空的会话里,留下没有父会话的孤儿行。 +RESTART=no +if [[ $MANAGE_SERVICE == yes ]] && systemctl is-active --quiet "$SERVICE" 2>/dev/null; then + echo "==> 停止 $SERVICE" + systemctl stop "$SERVICE" + RESTART=yes +elif [[ $MANAGE_SERVICE == no ]]; then + echo "==> 非默认数据库,不动 systemd(目标:$DB)" +fi + +echo "==> 清空邮件数据" +# 手工按依赖顺序删:SQLite 的外键默认不开(PRAGMA foreign_keys=0), +# 声明了 REFERENCES 也不会级联,倒过来删就会留下引用已删行的孤儿。 +sqlite3 "$DB" <<'SQL' +PRAGMA foreign_keys = ON; +BEGIN; +DELETE FROM attachments; +DELETE FROM relayed_mails; +DELETE FROM permission_requests; +DELETE FROM mails; +DELETE FROM sessions; +-- 限速计数是运行态噪声,留着只会让演示时的第一次登录/建会话莫名被拦 +DELETE FROM rate_limits; +-- used_rounds 是累计发信统计,邮件都删了它就是一个对不上任何记录的数字 +UPDATE agents SET used_rounds = 0; +COMMIT; +SQL + +echo "==> 删除附件文件" +if [[ -d $ATTACH ]]; then + # 只删内容文件与空壳目录,不删 $ATTACH 本身: + # 它的所有者与权限是部署时设好的,重建会丢掉。 + find "$ATTACH" -mindepth 1 -delete +fi + +echo "==> 回收数据库空间" +sqlite3 "$DB" "VACUUM;" + +if [[ $RESTART == yes ]]; then + echo "==> 启动 $SERVICE" + systemctl start "$SERVICE" + sleep 2 + curl -sf -m 5 http://127.0.0.1:8180/health >/dev/null \ + && echo " 健康检查通过" \ + || echo " 健康检查失败,查看:journalctl -u $SERVICE -n 50" +fi + +echo +echo "完成。备份在 $BACKUPS/agentmail-$STAMP.db" +echo "账号与密钥未改动,插件无需重新配置。" diff --git a/gateway/internal/db/migrate.go b/gateway/internal/db/migrate.go index 865cd9f..2f1bede 100644 --- a/gateway/internal/db/migrate.go +++ b/gateway/internal/db/migrate.go @@ -76,6 +76,10 @@ var sqliteAddColumns = []struct{ table, column, ddl string }{ // 会话级往返预算(0 = 不限)。旧库默认 0:引入预算不应该把已在进行的会话卡死。 {"sessions", "max_rounds", "ALTER TABLE sessions ADD COLUMN max_rounds INTEGER NOT NULL DEFAULT 0"}, {"sessions", "used_rounds", "ALTER TABLE sessions ADD COLUMN used_rounds INTEGER NOT NULL DEFAULT 0"}, + // 会话所属的工作目录。旧库默认空串:历史会话的 workspace 无法可靠反推 + // (Agent 回信的 from_workspace 存的是 Agent 名而不是路径),强行回填只会 + // 造出一批看起来有值实际是错的数据。 + {"sessions", "workspace", "ALTER TABLE sessions ADD COLUMN workspace TEXT NOT NULL DEFAULT ''"}, // 派给该 Agent 的新任务默认多少个来回。 // 旧库也给 20:之前的 max_rounds 默认是 10 但那是终身额度,语义不同, // 不能直接搬过来当单任务预算。 diff --git a/gateway/internal/db/migrations/init.sql b/gateway/internal/db/migrations/init.sql index 58fe484..516b4e5 100644 --- a/gateway/internal/db/migrations/init.sql +++ b/gateway/internal/db/migrations/init.sql @@ -51,6 +51,8 @@ CREATE TABLE IF NOT EXISTS agents ( CREATE TABLE IF NOT EXISTS sessions ( session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), session_alias VARCHAR(128), + -- 这条会话所属的工作目录(见 init_sqlite.sql 里的设计说明) + workspace VARCHAR(512) NOT NULL DEFAULT '', from_agent VARCHAR(64) NOT NULL, subject VARCHAR(512) NOT NULL, status VARCHAR(32) NOT NULL DEFAULT 'active', @@ -307,3 +309,39 @@ CREATE TABLE IF NOT EXISTS rate_limits ( expired BOOLEAN NOT NULL DEFAULT FALSE ); CREATE INDEX IF NOT EXISTS idx_rate_limits_bucket ON rate_limits(bucket, ts); + +-- ---------- 邮件场景下的可用模型(见 init_sqlite.sql 里的设计说明) ---------- +CREATE TABLE IF NOT EXISTS agent_model_catalog ( + agent_name VARCHAR(128) NOT NULL, + provider VARCHAR(128) NOT NULL, + model VARCHAR(256) NOT NULL, + display_name VARCHAR(256) NOT NULL DEFAULT '', + reported_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (agent_name, provider, model) +); + +CREATE TABLE IF NOT EXISTS agent_allowed_models ( + agent_name VARCHAR(128) NOT NULL, + provider VARCHAR(128) NOT NULL, + model VARCHAR(256) NOT NULL, + rank INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (agent_name, provider, model) +); + +CREATE INDEX IF NOT EXISTS idx_agent_allowed_rank ON agent_allowed_models(agent_name, rank); + +-- ---------- 平台会话镜像(见 init_sqlite.sql 里的设计说明) ---------- +CREATE TABLE IF NOT EXISTS agent_platform_sessions ( + agent_name VARCHAR(128) NOT NULL, + platform_id VARCHAR(256) NOT NULL, + workspace VARCHAR(512) NOT NULL DEFAULT '', + slug VARCHAR(256) NOT NULL DEFAULT '', + title VARCHAR(512) NOT NULL DEFAULT '', + mail_driven BOOLEAN NOT NULL DEFAULT FALSE, + updated_at TIMESTAMPTZ, + reported_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (agent_name, platform_id) +); + +CREATE INDEX IF NOT EXISTS idx_platform_sessions_ws + ON agent_platform_sessions(agent_name, workspace); diff --git a/gateway/internal/db/migrations/init_sqlite.sql b/gateway/internal/db/migrations/init_sqlite.sql index a0e02ba..2290321 100644 --- a/gateway/internal/db/migrations/init_sqlite.sql +++ b/gateway/internal/db/migrations/init_sqlite.sql @@ -70,6 +70,14 @@ CREATE TABLE IF NOT EXISTS agents ( CREATE TABLE IF NOT EXISTS sessions ( session_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()), session_alias TEXT, + -- workspace 是这条会话所属的工作目录(三维地址 name@path.session 的 path 位)。 + -- + -- 之前它只存在于 mails.to_workspace 上,于是「这个工作区下有哪些会话」 + -- 必须 JOIN mails 再从收发双方的 workspace 里猜,而 Agent 回信时 + -- from_workspace 填的是 Agent 名而不是路径 —— 猜出来的结果是错的, + -- 别名候选列表因此列不出本工作区的历史会话。 + -- 会话归属哪个工作区是会话自己的属性,就该存在会话上。 + workspace TEXT NOT NULL DEFAULT '', from_agent TEXT NOT NULL, subject TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', @@ -264,3 +272,67 @@ CREATE TABLE IF NOT EXISTS rate_limits ( expired INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_rate_limits_bucket ON rate_limits(bucket, ts); + +-- ---------- 邮件场景下的可用模型 ---------- +-- +-- 拆成两张表,因为它们是两种不同的真相: +-- +-- agent_model_catalog —— 平台**上报**它当前看得见哪些模型。每次注册整表替换。 +-- agent_allowed_models —— 管理员**选定**其中哪些可以在邮件场景下用,rank 即优先级。 +-- +-- 不合成一张带 allowed 标记的表:那样一来模型从平台目录里消失(换了 provider 配置、 +-- 上游下线了某个模型)就会连带把管理员的选择删掉,等模型回来还得重新配一遍。 +-- 分开存之后,选择是持久的,目录只决定「这一项现在是否可用」。 +CREATE TABLE IF NOT EXISTS agent_model_catalog ( + agent_name TEXT NOT NULL, + provider TEXT NOT NULL, + model TEXT NOT NULL, + -- 人类可读名,平台给什么就存什么;为空时前端显示 model id + display_name TEXT NOT NULL DEFAULT '', + reported_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')), + PRIMARY KEY (agent_name, provider, model) +); + +CREATE TABLE IF NOT EXISTS agent_allowed_models ( + agent_name TEXT NOT NULL, + provider TEXT NOT NULL, + model TEXT NOT NULL, + -- rank 越小越先试。插件按它顺序降级,全部失败才回一封失败邮件。 + rank INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (agent_name, provider, model) +); + +CREATE INDEX IF NOT EXISTS idx_agent_allowed_rank ON agent_allowed_models(agent_name, rank); + +-- ---------- 平台会话镜像 ---------- +-- +-- Agent 平台(opencode / DSH)自己也在开会话:有些经由邮件驱动,有些是人直接 +-- 在平台界面上开的。写信时想续谈某条会话,就得先知道那个工作区下有哪些会话 +-- 可以续 —— 而 Gateway 只看得见邮件驱动的那部分。 +-- +-- **由插件在心跳里上报,而不是 Gateway 反向拉取**:当前架构是单向的 +-- (Agent 持密钥主动连 Gateway,Gateway 从不外呼)。让 Gateway 去调平台接口 +-- 需要它保存各平台的地址与凭证,那是另一套信任模型,暂不引入。 +-- +-- 与 sessions 表分开存:这里是**别人家的**会话,其 id 属于平台的 id 空间, +-- 没有本侧的 owner / 预算 / 邮件。混进 sessions 会让每一处 +-- 「按会话鉴权」都要先判断这条到底是不是真的本侧会话。 +CREATE TABLE IF NOT EXISTS agent_platform_sessions ( + agent_name TEXT NOT NULL, + -- 平台侧的会话 id(opencode 的 ses_xxx / DSH 的 session id) + platform_id TEXT NOT NULL, + -- 平台侧 cwd,即三维地址的 path 位 + workspace TEXT NOT NULL DEFAULT '', + -- 平台自己的可寻址短名(opencode 的 slug;DSH 由模型标题派生) + slug TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL DEFAULT '', + -- 该平台会话是否由 AgentMail 的邮件驱动。用来在候选列表里区分 + -- 「续谈已有邮件线索」与「接入一条平台侧已经在跑的会话」。 + mail_driven INTEGER NOT NULL DEFAULT 0, + updated_at DATETIME, + reported_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')), + PRIMARY KEY (agent_name, platform_id) +); + +CREATE INDEX IF NOT EXISTS idx_platform_sessions_ws + ON agent_platform_sessions(agent_name, workspace); diff --git a/gateway/internal/handler/agents.go b/gateway/internal/handler/agents.go index f97cf42..b3530dd 100644 --- a/gateway/internal/handler/agents.go +++ b/gateway/internal/handler/agents.go @@ -11,10 +11,26 @@ import ( // ---------- Agent ---------- type registerRequest struct { - Name string `json:"name"` - Secret string `json:"secret"` + Name string `json:"name"` + Secret string `json:"secret"` Workspaces []models.Workspace `json:"workspaces"` - Platform string `json:"platform"` + Platform string `json:"platform"` +} + +// heartbeatRequest 是心跳可选带的上报体。 +// +// 字段全可省:旧插件发空心跳,不能因为新增了上报就把它们报错。 +type heartbeatRequest struct { + // PlatformSessions 是平台侧当前的会话快照(按最近活跃排序)。 + // + // 为什么让插件上报而不是 Gateway 反向拉取:当前架构是单向的 + // (Agent 持密钥主动连 Gateway,Gateway 从不外呼)。反向拉取需要 Gateway + // 保存各平台的地址与凭证,那是另一套信任模型。 + // + // nil 与空数组语义不同:nil = 本次不上报(保留现有镜像), + // 空数组 = 平台侧确实一条会话都没有(清空镜像)。 + // 拿不到会话列表的插件应当省略该字段,而不是传空数组把镜像抹掉。 + PlatformSessions []repo.PlatformSession `json:"platform_sessions"` } // POST /api/v1/agent/register @@ -111,6 +127,22 @@ func HeartbeatAgent(w http.ResponseWriter, r *http.Request) { return } + // 可选的平台会话快照。解不开就当作没带:心跳的主职责是「我还活着」, + // 不该因为上报体格式不对就把 Agent 判成离线。 + var req heartbeatRequest + if r.ContentLength > 0 { + _ = Decode(r, &req) + } + syncedSessions := -1 // -1 = 本次未上报 + if req.PlatformSessions != nil { + if err := repo.ReplacePlatformSessions(r.Context(), agentName, req.PlatformSessions); err != nil { + // 镜像写失败只影响候选补全,不影响投递,因此不报错 + syncedSessions = -1 + } else { + syncedSessions = len(req.PlatformSessions) + } + } + // 心跳回传该 Agent 的累计统计与新任务默认预算。 // // 不再回传「剩余额度」:额度属于具体任务(会话)而不属于 Agent, @@ -121,11 +153,15 @@ func HeartbeatAgent(w http.ResponseWriter, r *http.Request) { stats = repo.AgentStats{AgentName: agentName} } - JSON(w, http.StatusOK, map[string]interface{}{ + resp := map[string]interface{}{ "status": "ok", "pending_mails": pending, "stats": stats, - }) + } + if syncedSessions >= 0 { + resp["platform_sessions_synced"] = syncedSessions + } + JSON(w, http.StatusOK, resp) } // GET /api/v1/agents diff --git a/gateway/internal/handler/contacts.go b/gateway/internal/handler/contacts.go index e4eac95..3e2fb26 100644 --- a/gateway/internal/handler/contacts.go +++ b/gateway/internal/handler/contacts.go @@ -188,11 +188,26 @@ func SuggestAddress(w http.ResponseWriter, r *http.Request) { return } - sessions, _ := repo.SuggestSessionsFor(r.Context(), user.Username, name, path) - sessions = append(sessions, "new") + sessions, err := repo.SuggestSessionCandidates(r.Context(), user.Username, name, path) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to suggest sessions") + return + } + // suggestions 保留纯字符串形式:已部署的前端与第三方客户端只认这一个字段。 + // 带标题与来源的完整形式另放 candidates,两个字段同序。 + aliases := make([]string, 0, len(sessions)+1) + for _, c := range sessions { + aliases = append(aliases, c.Alias) + } + // new 总是可选且永远在最后:它不是一条已存在的会话, + // 排在前面会让人在想续谈时随手回车开出一条新线索。 + aliases = append(aliases, "new") + sessions = append(sessions, repo.SessionCandidate{Alias: "new", Source: "new", Title: "新建会话"}) + JSON(w, http.StatusOK, map[string]interface{}{ "kind": "session", - "suggestions": sessions, + "suggestions": aliases, + "candidates": sessions, }) } diff --git a/gateway/internal/handler/mail.go b/gateway/internal/handler/mail.go index bbeeff3..fcc0847 100644 --- a/gateway/internal/handler/mail.go +++ b/gateway/internal/handler/mail.go @@ -86,7 +86,9 @@ func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, sub "新建会话过于频繁(1 小时内已开 %d 条)。请在已有会话里继续,或 %d 秒后再试。", repo.SessionRateLimit(), retry)) } - id, err := repo.CreateSession(r.Context(), aliasPtr, fromAgent, subject) + // 带上 addr.Path:会话属于哪个工作区是会话自己的属性, + // 不存下来的话「这个工作区下有哪些会话」就只能从 mails 反推。 + id, err := repo.CreateSession(r.Context(), aliasPtr, fromAgent, subject, addr.Path) if err != nil { // 建失败要把名额还回去:那次新建实际上没有发生 repo.ReleaseNewSession(r.Context(), byAgent) @@ -267,8 +269,13 @@ func SendMail(w http.ResponseWriter, r *http.Request) { // notifyRecipients 向主收件人与抄送方推送 new_mail,并刷新相关方的会话列表。 // 收件人可能是 Agent 也可能是人类用户(三维地址 name 位共享命名空间), // 因此统一用 SendToRecipient 同时试 Agent 通道与用户通道。 +// +// **每个收件方拿到的 workspace 是自己那个地址的 path 位**,不是主收件人的: +// 三维地址 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 string) map[string]interface{} { + payload := func(role, workspace string) map[string]interface{} { return map[string]interface{}{ "mail_id": mailID.String(), "session_id": sessionID.String(), @@ -276,6 +283,10 @@ func notifyRecipients(to models.Address, cc []models.Address, sessionID, mailID "subject": subject, "mail_type": "normal", "role": role, // to / cc + // to_workspace 是收件方地址的 path 位,即希望它在哪个工作目录干活。 + // 不带这一项的后果:插件只能自己拼一个临时目录,于是每封邮件都落在 + // 不同的空目录里,DSH / opencode 按 cwd 分组时全进「未分组」。 + "to_workspace": workspace, } } @@ -287,7 +298,7 @@ func notifyRecipients(to models.Address, cc []models.Address, sessionID, mailID // 参与方去重:收件人 + 所有抄送 + 发件人自己(刷新他的发件箱) seen := map[string]bool{} - sse.Default.SendToRecipient(to.Name, "new_mail", payload("to")) + sse.Default.SendToRecipient(to.Name, "new_mail", payload("to", to.Path)) sse.Default.SendToRecipient(to.Name, "session_update", update) seen[to.Name] = true @@ -296,7 +307,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")) + sse.Default.SendToRecipient(c.Name, "new_mail", payload("cc", c.Path)) sse.Default.SendToRecipient(c.Name, "session_update", update) } diff --git a/gateway/internal/handler/permission.go b/gateway/internal/handler/permission.go index 01aceec..199589f 100644 --- a/gateway/internal/handler/permission.go +++ b/gateway/internal/handler/permission.go @@ -90,7 +90,8 @@ func RequestPermission(w http.ResponseWriter, r *http.Request) { sessionID = id repo.TouchSession(r.Context(), sessionID) } else { - id, err := repo.CreateSession(r.Context(), nil, agentName, "权限请求: "+req.Question) + // workspace 空串:权限询问不经三维寻址,没有 path 位可归属。 + id, err := repo.CreateSession(r.Context(), nil, agentName, "权限请求: "+req.Question, "") if err != nil { Error(w, http.StatusInternalServerError, "Failed to create session") return @@ -228,6 +229,9 @@ func DecidePermission(w http.ResponseWriter, r *http.Request) { "decision": req.Decision, "note": req.Note, "decided_by": user.Username, + // 会话 id:插件重启丢了待决映射时,会退化成「把决策当一封通知投进会话」, + // 那条路径要靠这个字段找到原会话,否则会凭空另开一个。 + "session_id": perm.SessionID.String(), } if key, kind := repo.RelayKeyForMail(r.Context(), mailID); key != "" { payload["relay_key"] = key diff --git a/gateway/internal/handler/thread.go b/gateway/internal/handler/thread.go index ac4723c..aabc543 100644 --- a/gateway/internal/handler/thread.go +++ b/gateway/internal/handler/thread.go @@ -9,20 +9,23 @@ import ( "github.com/google/uuid" ) -// ---------- 对话树(按方向分块加载) ---------- +// ---------- 对话树(从线索根整树展开,分块加载) ---------- // 分页参数。上限存在的意义是防止 ?limit=100000 一次把整条线索拉走 —— // 那就等于绕过了分块加载。 const ( - threadDefaultLimit = 40 + threadDefaultLimit = 60 threadMaxLimit = 200 + // anchorPathBudget 是「补齐根到锚点这条路径」时最多回填的层数。 + // 只在锚点没落进 BFS 首页时才用得上(几百封的巨型线索)。 + anchorPathBudget = 60 ) // threadNode 是返回给前端的树节点。 // // Detached 表示「这封的父邮件当前不在返回集里」,两种原因: // - 父邮件不可见(转发把线索引到别处,下游往来不回流给上游参与者) -// - 父邮件还没加载(分块加载的边界,往上滑会补上) +// - 父邮件还没加载(分块加载的边界,往下翻会补上) // // 前端据此画出断点,而不是因为找不到父节点就把它悄悄丢掉。 // 两种原因用 ParentHidden 区分:不可见是永久的,未加载是暂时的。 @@ -35,14 +38,16 @@ type threadNode struct { // GET /api/v1/mail/{id}/thread // -// 以给定邮件为锚点,按方向分块返回线索: +// 以给定邮件所在**线索的根**为起点,BFS 展开整棵树: // -// ?dir=around(默认) 锚点 + 一批祖先 + 一批子孙,首屏用 -// ?dir=up&offset=N 继续往上取祖先(上滑加载) -// ?dir=down&offset=N 继续往下取子孙 +// ?offset=0(默认) 从根开始的第一块 +// ?offset=N 继续往后取(下滑加载) // -// offset 是**相对锚点**的偏移:up 方向按层数(已取到的祖先数), -// down 方向按节点数(已取到的子孙数)。锚点本身只在 around/down&offset=0 时返回。 +// 曾经的实现是「锚点的祖先链 + 锚点的子树」两个方向各自分页,问题是 +// **兄弟节点整条分支都在盲区里**:一封抄送给两个 Agent 的邮件会收到两个回复, +// 它们互为兄弟;从其中一个回复看树,另一个回复既不是它的祖先也不是它的子孙, +// 于是永远不显示。挂在原件上的转发分支同理。改成从根整树 BFS 后, +// 兄弟、抄送产生的平行回复、转发分支都是根的子孙,一次覆盖。 // // 树可跨会话(转发是新线索但仍指向原件),因此**逐个会话鉴权**, // 只返回当前用户有权访问的节点。被过滤掉的计入 hidden。 @@ -74,17 +79,39 @@ func GetMailThread(w http.ResponseWriter, r *http.Request) { return } - dir := r.URL.Query().Get("dir") - if dir == "" { - dir = "around" - } - if dir != "around" && dir != "up" && dir != "down" { - Error(w, http.StatusBadRequest, "dir 只能是 around、up 或 down") - return - } limit := intQuery(r, "limit", threadDefaultLimit, 1, threadMaxLimit) offset := intQuery(r, "offset", 0, 0, 1<<20) + // 上溯到线索根:整棵树都是它的子孙。 + rootID, anchorDepth, err := repo.ThreadRootOf(r.Context(), mailID) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to locate thread root") + return + } + + raw, hasMore, err := repo.DescendantsRaw(r.Context(), rootID, offset, limit) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to load thread") + return + } + + // 锚点必须可见 —— 用户点开的就是它。巨型线索里 BFS 首页可能还没到锚点那一层, + // 此时单独把「根 → 锚点」这条路径补进来,否则用户点开一封邮件却在树里找不到它。 + if offset == 0 && anchorDepth > 0 && !containsMail(raw, mailID) { + path, _, pErr := repo.AncestorsRaw(r.Context(), mailID, 0, anchorPathBudget) + if pErr == nil { + // AncestorsRaw 给的是相对锚点的负 depth,换算成距根的绝对深度 + for i := range path { + path[i].Depth += anchorDepth + } + raw = append(raw, path...) + } + // 锚点自己(AncestorsRaw 从父开始,不含锚点) + if anchor, aErr := repo.TreeMailByID(r.Context(), mailID, anchorDepth); aErr == nil { + raw = append(raw, *anchor) + } + } + // 会话鉴权结果按会话缓存:一条线索里同一会话通常有多封,逐封查是浪费 seen := map[uuid.UUID]bool{} canSee := func(sid uuid.UUID) bool { @@ -99,95 +126,58 @@ func GetMailThread(w http.ResponseWriter, r *http.Request) { return v } - var ( - raw []repo.TreeMail - hasMoreUp bool - hasMoreDn bool - wantUp = dir == "around" || dir == "up" - wantDown = dir == "around" || dir == "down" - upOffset = offset - downOffset = offset - ) - - // around 时两个方向各取一半,避免首屏一次要求 2×limit。 - // 两边至少各给 1:否则 limit=1 时会算出 downLimit=0,连锚点自己都不返回。 - upLimit, downLimit := limit, limit - if dir == "around" { - upLimit = limit / 2 - if upLimit < 1 { - upLimit = 1 - } - downLimit = limit - upLimit - if downLimit < 1 { - downLimit = 1 - } - upOffset, downOffset = 0, 0 - } - - if wantUp { - anc, more, err := repo.AncestorsRaw(r.Context(), mailID, upOffset, upLimit) - if err != nil { - Error(w, http.StatusInternalServerError, "Failed to load ancestors") - return - } - raw = append(raw, anc...) - hasMoreUp = more - } - if wantDown { - // around 与 down&offset=0 会带上锚点自己(Depth 0); - // up 方向单独请求时不带,前端已经有它了 - desc, more, err := repo.DescendantsRaw(r.Context(), mailID, downOffset, downLimit) - if err != nil { - Error(w, http.StatusInternalServerError, "Failed to load descendants") - return - } - raw = append(raw, desc...) - hasMoreDn = more - } - // 可见性过滤。父节点是否在**本次返回集**里决定 detached; - // 父存在却不在集里,再判断是"无权看"还是"没加载"。 + // 父存在却不在集里,再判断是「无权看」还是「没加载」。 visible := map[uuid.UUID]bool{} + present := map[uuid.UUID]bool{} for _, m := range raw { + present[m.ID] = true if canSee(m.SessionID) { visible[m.ID] = true } } nodes := []threadNode{} + emitted := map[uuid.UUID]bool{} for _, m := range raw { - if !visible[m.ID] { + if !visible[m.ID] || emitted[m.ID] { + // 补齐锚点路径时可能与 BFS 结果重叠,去重 continue } + emitted[m.ID] = true n := threadNode{TreeMail: m} if m.ParentMailID != nil && !visible[*m.ParentMailID] { n.Detached = true // 父邮件在本次结果里出现过但被过滤掉 = 确实无权查看; - // 完全没出现过 = 只是还没加载到,往上滑会补上 - for _, other := range raw { - if other.ID == *m.ParentMailID { - n.ParentHidden = true - break - } - } + // 完全没出现过 = 只是还没加载到,往下翻会补上 + n.ParentHidden = present[*m.ParentMailID] } nodes = append(nodes, n) } JSON(w, http.StatusOK, map[string]interface{}{ "anchor_mail_id": mailID, - "dir": dir, + "root_mail_id": rootID, + "anchor_depth": anchorDepth, "nodes": nodes, "total": len(nodes), "hidden": len(raw) - len(nodes), + "has_more": hasMore, // 下一页的 offset。前端把它原样回传即可,不必自己算已加载数量。 - "has_more_up": hasMoreUp, - "has_more_down": hasMoreDn, - "next_up": upOffset + upLimit, - "next_down": downOffset + downLimit, + "next_offset": offset + limit, }) } +// containsMail 判断某封邮件是否已在结果集里。 +func containsMail(list []repo.TreeMail, id uuid.UUID) bool { + for i := range list { + if list[i].ID == id { + return true + } + } + return false +} + // intQuery 读取整数 query 参数并夹到 [min, max]。 // 非法值一律回落到默认值 —— 分页参数不该因为一个笔误就让整个请求失败。 func intQuery(r *http.Request, key string, def, min, max int) int { diff --git a/gateway/internal/repo/budget_test.go b/gateway/internal/repo/budget_test.go index 532a585..b7d43ad 100644 --- a/gateway/internal/repo/budget_test.go +++ b/gateway/internal/repo/budget_test.go @@ -16,7 +16,7 @@ import ( func setupBudgetDB(t *testing.T) uuid.UUID { t.Helper() setupTestDB(t) - id, err := CreateSession(context.Background(), nil, "bot", "预算测试") + id, err := CreateSession(context.Background(), nil, "bot", "预算测试", "") if err != nil { t.Fatalf("create session: %v", err) } diff --git a/gateway/internal/repo/markread_test.go b/gateway/internal/repo/markread_test.go index dfd4d0a..7a287bc 100644 --- a/gateway/internal/repo/markread_test.go +++ b/gateway/internal/repo/markread_test.go @@ -12,7 +12,7 @@ import ( func seedMailTo(t *testing.T, recipient string, cc string) uuid.UUID { t.Helper() ctx := context.Background() - sid, err := CreateSession(ctx, nil, "sender", "t") + sid, err := CreateSession(ctx, nil, "sender", "t", "") if err != nil { t.Fatal(err) } diff --git a/gateway/internal/repo/models_scope.go b/gateway/internal/repo/models_scope.go new file mode 100644 index 0000000..36e3d44 --- /dev/null +++ b/gateway/internal/repo/models_scope.go @@ -0,0 +1,204 @@ +package repo + +import ( + "context" + "strings" + + "github.com/agentmail/gateway/internal/db" +) + +// ---------- 邮件场景下的可用模型 ---------- +// +// 两张表,两种真相: +// +// agent_model_catalog —— 平台**上报**它当前看得见哪些模型(注册时整表替换) +// agent_allowed_models —— 管理员**选定**其中哪些能在邮件场景下用,rank 即优先级 +// +// 为什么不合成一张带 allowed 标记的表:模型会从平台目录里消失(换了 provider 配置、 +// 上游临时下线),那时整行被删掉就连带把管理员的选择也删了,模型回来还得重配一遍。 +// 分开存之后,「选了什么」是持久的,目录只决定「这一项现在是否可用」。 +// +// 为什么让平台上报而不是在 Gateway 里配一张静态表:模型清单是平台侧的事实 —— +// opencode 的 provider 配置、DSH 的 llm 适配器注册,都可能随时变。 +// Gateway 猜不出来,猜错的后果是管理员在配置页选了一个平台其实调不到的模型。 + +// ModelRef 是一次「provider + model」路由。 +type ModelRef struct { + Provider string `json:"provider"` + Model string `json:"model"` +} + +// CatalogModel 是平台上报的一个可选模型。 +type CatalogModel struct { + Provider string `json:"provider"` + Model string `json:"model"` + DisplayName string `json:"display_name,omitempty"` + // Allowed 表示它已被管理员选入邮件场景。 + // 与目录合并后一起返回,前端才能画出「已勾选」的复选框。 + Allowed bool `json:"allowed"` + // Rank 仅在 Allowed 为真时有意义,越小越先试。 + Rank int `json:"rank,omitempty"` +} + +// maxCatalogModels 限制单个 Agent 上报的模型数。 +// +// 有平台会把上游的全部模型都列出来(实测 opencode 的一个 provider 就有几十个), +// 无上限的话一次注册能写进几千行,而配置页面上几千个复选框对人毫无用处。 +const maxCatalogModels = 300 + +// ReplaceModelCatalog 整表替换某 Agent 上报的模型目录。 +// +// 整表替换而非增量合并:目录是平台当前状态的快照, +// 增量合并会让已经下线的模型永远留在列表里,而那正是「选了却调不到」的来源。 +// +// 事务包住删+插:中途失败留下一个空目录,会让配置页显示「该平台没有可用模型」 +// 而管理员根本没做任何操作。 +func ReplaceModelCatalog(ctx context.Context, agentName string, models []CatalogModel) error { + agentName = strings.TrimSpace(agentName) + if agentName == "" { + return nil + } + if len(models) > maxCatalogModels { + models = models[:maxCatalogModels] + } + + tx, err := db.DB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, + `DELETE FROM agent_model_catalog WHERE agent_name = $1`, agentName); err != nil { + return err + } + + seen := map[string]bool{} + for _, m := range models { + p := strings.TrimSpace(m.Provider) + id := strings.TrimSpace(m.Model) + if p == "" || id == "" { + continue // 半条记录不如不要:它在配置页上是一个点不动的空复选框 + } + key := p + "/" + id + if seen[key] { + continue + } + seen[key] = true + if _, err := tx.ExecContext(ctx, + `INSERT INTO agent_model_catalog (agent_name, provider, model, display_name, reported_at) + VALUES ($1, $2, $3, $4, NOW())`, + agentName, p, id, strings.TrimSpace(m.DisplayName)); err != nil { + return err + } + } + return tx.Commit() +} + +// ListModelCatalog 返回某 Agent 的模型目录,并标出哪些已被选入邮件场景。 +// +// LEFT JOIN 而不是两次查询:前端要的是一份「带勾选状态的清单」, +// 在 SQL 里合完比让前端自己对齐两个数组更难出错。 +func ListModelCatalog(ctx context.Context, agentName string) ([]CatalogModel, error) { + rows, err := db.DB.QueryContext(ctx, ` + SELECT c.provider, c.model, c.display_name, + CASE WHEN a.model IS NULL THEN 0 ELSE 1 END AS allowed, + COALESCE(a.rank, 0) + FROM agent_model_catalog c + LEFT JOIN agent_allowed_models a + ON a.agent_name = c.agent_name + AND a.provider = c.provider + AND a.model = c.model + WHERE c.agent_name = $1 + ORDER BY c.provider, c.model + `, agentName) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []CatalogModel{} + for rows.Next() { + var m CatalogModel + var allowed int + if err := rows.Scan(&m.Provider, &m.Model, &m.DisplayName, &allowed, &m.Rank); err != nil { + return nil, err + } + m.Allowed = allowed == 1 + out = append(out, m) + } + return out, rows.Err() +} + +// ListAllowedModels 按 rank 返回该 Agent 在邮件场景下可用的模型。 +// +// **不与目录做 JOIN**:目录是平台上次注册时的快照,插件重启前可能已经过期。 +// 真正能不能调通只有插件试过才知道 —— 这也正是插件要按顺序降级的原因。 +// 在这里用目录过滤,只会把「目录暂时没上报但其实可用」的模型挡掉。 +func ListAllowedModels(ctx context.Context, agentName string) ([]ModelRef, error) { + rows, err := db.DB.QueryContext(ctx, ` + SELECT provider, model FROM agent_allowed_models + WHERE agent_name = $1 + ORDER BY rank ASC, provider ASC, model ASC + `, agentName) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []ModelRef{} + for rows.Next() { + var m ModelRef + if err := rows.Scan(&m.Provider, &m.Model); err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} + +// SetAllowedModels 整表替换某 Agent 的邮件场景可用模型,入参顺序即优先级。 +// +// 允许传空列表:那表示「不限定」——插件回退到平台自己的默认模型。 +// 这与「一个都不许用」不同,后者等于让 Agent 彻底哑掉,不该是一次误删的后果。 +func SetAllowedModels(ctx context.Context, agentName string, picks []ModelRef) error { + agentName = strings.TrimSpace(agentName) + if agentName == "" { + return nil + } + + tx, err := db.DB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, + `DELETE FROM agent_allowed_models WHERE agent_name = $1`, agentName); err != nil { + return err + } + + rank := 0 + seen := map[string]bool{} + for _, m := range picks { + p := strings.TrimSpace(m.Provider) + id := strings.TrimSpace(m.Model) + if p == "" || id == "" { + continue + } + key := p + "/" + id + if seen[key] { + // 重复项直接跳过而不是报错:它对最终顺序没有影响, + // 为一次无害的重复让整次保存失败只会让人以为配置没生效。 + continue + } + seen[key] = true + if _, err := tx.ExecContext(ctx, + `INSERT INTO agent_allowed_models (agent_name, provider, model, rank) + VALUES ($1, $2, $3, $4)`, agentName, p, id, rank); err != nil { + return err + } + rank++ + } + return tx.Commit() +} diff --git a/gateway/internal/repo/platform_sessions.go b/gateway/internal/repo/platform_sessions.go new file mode 100644 index 0000000..e2834c2 --- /dev/null +++ b/gateway/internal/repo/platform_sessions.go @@ -0,0 +1,222 @@ +package repo + +import ( + "context" + "strings" + "time" + + "github.com/agentmail/gateway/internal/db" +) + +// ---------- 平台会话镜像 ---------- +// +// Agent 平台自己也在开会话:有些经由邮件驱动,有些是人直接在平台界面上开的。 +// 写信时想续谈某条会话,得先知道那个工作区下有哪些会话可续 —— 而 Gateway +// 只看得见邮件驱动的那部分。 +// +// **由插件在心跳里上报,Gateway 不反向拉取。** +// 当前架构是单向的(Agent 持密钥主动连 Gateway,Gateway 从不外呼); +// 让 Gateway 去调平台接口需要它保存各平台的地址与凭证,那是另一套信任模型。 +// 代价是插件没运行时同步不了 —— 但插件没运行时邮件本来也投不进去。 + +// PlatformSession 是插件上报的一条平台侧会话。 +type PlatformSession struct { + PlatformID string `json:"platform_id"` + Workspace string `json:"workspace"` + Slug string `json:"slug,omitempty"` + Title string `json:"title,omitempty"` + MailDriven bool `json:"mail_driven"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +// maxPlatformSessions 限制单次上报的会话数。 +// +// 一个长期运行的平台可以累积上千条会话,而候选列表上千项对人没有意义。 +// 插件按最近活跃排序后上报前 N 条即可。 +const maxPlatformSessions = 200 + +// ReplacePlatformSessions 整表替换某 Agent 的平台会话镜像。 +// +// 整表替换而非增量合并:镜像是平台当前状态的快照。增量合并会让已经删掉的 +// 平台会话永远留在候选列表里,而那正是「选了却送不到」的来源 +// —— session 位是三态语义,指向一条不存在的会话会直接 404。 +func ReplacePlatformSessions(ctx context.Context, agentName string, list []PlatformSession) error { + agentName = strings.TrimSpace(agentName) + if agentName == "" { + return nil + } + if len(list) > maxPlatformSessions { + list = list[:maxPlatformSessions] + } + + tx, err := db.DB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, + `DELETE FROM agent_platform_sessions WHERE agent_name = $1`, agentName); err != nil { + return err + } + + seen := map[string]bool{} + for _, ps := range list { + id := strings.TrimSpace(ps.PlatformID) + if id == "" || seen[id] { + continue + } + seen[id] = true + driven := 0 + if ps.MailDriven { + driven = 1 + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO agent_platform_sessions + (agent_name, platform_id, workspace, slug, title, mail_driven, updated_at, reported_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) + `, agentName, id, strings.TrimSpace(ps.Workspace), strings.TrimSpace(ps.Slug), + strings.TrimSpace(ps.Title), driven, ps.UpdatedAt); err != nil { + return err + } + } + return tx.Commit() +} + +// SessionCandidate 是「续谈某条会话」的一个候选项。 +type SessionCandidate struct { + // Alias 是填进 session 位的值 —— 候选项的实际用途就是它 + Alias string `json:"alias"` + // Title 给人看,用来分辨两条别名相似的会话在谈什么 + Title string `json:"title,omitempty"` + // Source 说明这条候选从哪来: + // mail 本侧邮件线索(可直接送达) + // platform 平台侧会话镜像(本侧还没有对应线索) + Source string `json:"source"` + // Unread 仅 mail 来源有意义 + Unread int `json:"unread,omitempty"` +} + +// SuggestSessionCandidates 汇总某 name@path 下可续谈的会话。 +// +// 两个来源合并: +// 1. 本侧邮件线索(sessions.workspace 匹配,或历史数据里靠 mails 反推) +// 2. 平台会话镜像里带 slug 的那些 +// +// 本侧优先:邮件线索是「这个别名一定送得到」的保证,而镜像只是平台的说法。 +// 同名时保留本侧那条,并把镜像的标题补上去(镜像通常有更新的标题)。 +func SuggestSessionCandidates(ctx context.Context, forUser, peerName, path string) ([]SessionCandidate, error) { + out := []SessionCandidate{} + seen := map[string]int{} // alias -> out 下标 + + // ---- 来源 1:本侧邮件线索 ---- + // + // sessions.workspace 是权威来源。它是新加的列,历史会话为空串, + // 因此保留 mails 反推作为兜底:`s.workspace = $2 OR (s.workspace = '' AND )`。 + // 反推只看 to_workspace —— Agent 回信时 from_workspace 存的是 Agent 名而非路径, + // 拿它比路径永远匹配不上。 + rows, err := db.DB.QueryContext(ctx, ` + SELECT s.session_alias, + COALESCE(s.subject, ''), + (SELECT COUNT(*) FROM mails u + WHERE u.session_id = s.session_id AND u.status = 'unread') + FROM sessions s + WHERE s.session_alias IS NOT NULL AND s.session_alias <> '' + AND s.status <> 'archived' + AND EXISTS ( + SELECT 1 FROM mails m + WHERE m.session_id = s.session_id + AND (m.to_name = $1 OR m.from_name = $1 OR `+db.CCHas("m.cc_list", 1)+`) + ) + AND ($2 = '' + OR s.workspace = $2 + OR (s.workspace = '' AND EXISTS ( + SELECT 1 FROM mails w + WHERE w.session_id = s.session_id + AND COALESCE(w.to_workspace,'') = $2 + ))) + AND ($3 = '' OR s.owner_user_id = (SELECT user_id FROM users WHERE username = $3) + OR EXISTS ( + SELECT 1 FROM mails mm + WHERE mm.session_id = s.session_id + AND (mm.from_name = $3 OR mm.to_name = $3 + OR `+db.CCHas("mm.cc_list", 3)+`) + )) + ORDER BY s.updated_at DESC + `, peerName, path, forUser) + if err != nil { + return out, err + } + defer rows.Close() + + for rows.Next() { + var alias, title string + var unread int + if err := rows.Scan(&alias, &title, &unread); err != nil { + return out, err + } + if alias == "" { + continue + } + seen[alias] = len(out) + out = append(out, SessionCandidate{ + Alias: alias, Title: title, Source: "mail", Unread: unread, + }) + } + if err := rows.Err(); err != nil { + return out, err + } + + // ---- 来源 2:平台会话镜像 ---- + prows, err := db.DB.QueryContext(ctx, ` + SELECT slug, title + FROM agent_platform_sessions + WHERE agent_name = $1 + AND slug <> '' + AND ($2 = '' OR workspace = $2) + -- 不用 NULLS LAST:它要 SQLite 3.30+,而驱动自带的版本不由我们控制。 + -- COALESCE 在两个方言里都成立,语义也更直接:没有 updated_at 就用上报时间。 + ORDER BY COALESCE(updated_at, reported_at) DESC + `, peerName, path) + if err != nil { + // 镜像查不到不该让整个补全失败:本侧线索已经够用了 + return out, nil + } + defer prows.Close() + + for prows.Next() { + var slug, title string + if err := prows.Scan(&slug, &title); err != nil { + break + } + if slug == "" { + continue + } + if i, ok := seen[slug]; ok { + // 本侧已有同名线索:保留 mail 来源(它保证送得到), + // 但补上镜像的标题 —— 平台侧标题通常比会话建立时的主题更贴切 + if out[i].Title == "" && title != "" { + out[i].Title = title + } + continue + } + seen[slug] = len(out) + out = append(out, SessionCandidate{Alias: slug, Title: title, Source: "platform"}) + } + return out, nil +} + +// SetSessionWorkspace 记下会话所属的工作目录。 +// +// 只在为空时写入:会话的工作区在建立时就定下了,之后不该被一封发往 +// 别处的邮件改掉 —— 那会让这条会话在候选列表里凭空换一个工作区。 +func SetSessionWorkspace(ctx context.Context, sessionID interface{ String() string }, workspace string) error { + ws := strings.TrimSpace(workspace) + if ws == "" { + return nil + } + _, err := db.DB.ExecContext(ctx, + `UPDATE sessions SET workspace = $1 WHERE session_id = $2 AND workspace = ''`, + ws, sessionID.String()) + return err +} diff --git a/gateway/internal/repo/platform_sessions_test.go b/gateway/internal/repo/platform_sessions_test.go new file mode 100644 index 0000000..f862599 --- /dev/null +++ b/gateway/internal/repo/platform_sessions_test.go @@ -0,0 +1,354 @@ +package repo + +import ( + "context" + "testing" + "time" + + "github.com/agentmail/gateway/internal/db" + "github.com/google/uuid" +) + +// seedPlatformAgent 注册一个 Agent,平台会话镜像与它绑定。 +// 与 quota_test.go 的 seedAgent 区分开:那个要指定 default_rounds,这里不关心。 +func seedPlatformAgent(t *testing.T, name string) { + t.Helper() + if _, err := db.DB.ExecContext(context.Background(), + `INSERT INTO agents (agent_name, secret, platform, status) VALUES ($1, 'x', $1, 'online')`, + name); err != nil { + t.Fatalf("seed agent %s: %v", name, err) + } +} + +// seedSessionWS 建一个带 workspace 与别名的会话。 +func seedSessionWS(t *testing.T, alias, workspace, subject string) uuid.UUID { + t.Helper() + var id uuid.UUID + err := db.DB.QueryRowContext(context.Background(), ` + INSERT INTO sessions (session_alias, workspace, from_agent, subject, alias_source) + VALUES ($1, $2, 'admin', $3, 'platform') + RETURNING session_id + `, alias, workspace, subject).Scan(&id) + if err != nil { + t.Fatalf("seed session %s: %v", alias, err) + } + return id +} + +// seedMailWS 插一封带明确 to_workspace 的邮件。 +func seedMailWS(t *testing.T, sessionID uuid.UUID, from, to, toWS, subject string) uuid.UUID { + t.Helper() + var id uuid.UUID + err := db.DB.QueryRowContext(context.Background(), ` + INSERT INTO mails (session_id, from_name, from_workspace, to_name, to_workspace, + subject, body, cc_list, created_at) + VALUES ($1, $2, '', $3, $4, $5, 'body', '[]', $6) + RETURNING mail_id + `, sessionID, from, to, toWS, subject, nextSeedTime()).Scan(&id) + if err != nil { + t.Fatalf("seed mail: %v", err) + } + return id +} + +// 这个测试是「会话别名没有正确显示曾经发生在工作区下的会话」那次故障的回归。 +// +// 旧实现按 mails 反推工作区,条件是 +// `to_workspace = $path OR from_workspace = $path`。 +// 而 Agent 回信时 from_workspace 存的是 **Agent 名**(如 "dsh")而不是路径, +// 于是一旦会话里只剩 Agent 的回信可匹配,反推就落空、别名列不出来。 +// 现在 workspace 存在会话自己身上,与邮件里那些脏数据无关。 +func TestSuggestSessionCandidatesUsesSessionWorkspace(t *testing.T) { + setupTestDB(t) + seedPlatformAgent(t, "dsh") + + sid := seedSessionWS(t, "brisk-harbor", "/home/program/agentmail", "缓存选型") + // 只有 Agent 的回信:from_workspace 是脏的(Agent 名),to_workspace 是人类(空) + if _, err := db.DB.ExecContext(context.Background(), ` + INSERT INTO mails (session_id, from_name, from_workspace, to_name, to_workspace, + subject, body, cc_list, created_at) + VALUES ($1, 'dsh', 'dsh', 'admin', '', 'Re: 缓存选型', 'body', '[]', $2) + `, sid, nextSeedTime()); err != nil { + t.Fatalf("seed agent reply: %v", err) + } + + got, err := SuggestSessionCandidates(context.Background(), "admin", "dsh", "/home/program/agentmail") + if err != nil { + t.Fatalf("SuggestSessionCandidates: %v", err) + } + if len(got) != 1 { + t.Fatalf("应有 1 个候选,实际 %d —— 会话的 workspace 列没被用上", len(got)) + } + if got[0].Alias != "brisk-harbor" { + t.Errorf("别名错误:%q", got[0].Alias) + } + if got[0].Source != "mail" { + t.Errorf("来源应为 mail,实际 %q", got[0].Source) + } + if got[0].Title != "缓存选型" { + t.Errorf("标题应带出来:%q", got[0].Title) + } +} + +// 历史会话的 workspace 列是空的(新加的列),必须回退到 mails.to_workspace 反推, +// 否则升级后所有老会话一夜之间从候选列表里消失。 +func TestSuggestSessionCandidatesFallsBackToMails(t *testing.T) { + setupTestDB(t) + seedPlatformAgent(t, "opencode") + + // workspace 留空,模拟升级前建立的会话 + sid := seedSessionWS(t, "legacy-thread", "", "老线索") + seedMailWS(t, sid, "admin", "opencode", "/home/legacy", "老线索") + + got, err := SuggestSessionCandidates(context.Background(), "admin", "opencode", "/home/legacy") + if err != nil { + t.Fatalf("SuggestSessionCandidates: %v", err) + } + if len(got) != 1 || got[0].Alias != "legacy-thread" { + t.Fatalf("老会话应能靠 mails 反推出来,实际 %+v", got) + } +} + +// 工作区不匹配的会话不能出现:候选项点下去就会被填进 session 位, +// 而 session 位是三态语义 —— 指向别处的会话会直接 404「无法送达」。 +func TestSuggestSessionCandidatesFiltersByWorkspace(t *testing.T) { + setupTestDB(t) + seedPlatformAgent(t, "dsh") + + mine := seedSessionWS(t, "here-thread", "/home/a", "本区") + seedMailWS(t, mine, "admin", "dsh", "/home/a", "本区") + other := seedSessionWS(t, "there-thread", "/home/b", "别区") + seedMailWS(t, other, "admin", "dsh", "/home/b", "别区") + + got, err := SuggestSessionCandidates(context.Background(), "admin", "dsh", "/home/a") + if err != nil { + t.Fatalf("SuggestSessionCandidates: %v", err) + } + if len(got) != 1 || got[0].Alias != "here-thread" { + t.Fatalf("只应给出本工作区的会话,实际 %+v", got) + } +} + +// path 为空(地址写成 `dsh` 而不带 @/path)时不按工作区过滤: +// 用户还没写到 path 段就该看到全部可续的会话。 +func TestSuggestSessionCandidatesEmptyPathReturnsAll(t *testing.T) { + setupTestDB(t) + seedPlatformAgent(t, "dsh") + + a := seedSessionWS(t, "ws-a", "/home/a", "A") + seedMailWS(t, a, "admin", "dsh", "/home/a", "A") + b := seedSessionWS(t, "ws-b", "/home/b", "B") + seedMailWS(t, b, "admin", "dsh", "/home/b", "B") + + got, err := SuggestSessionCandidates(context.Background(), "admin", "dsh", "") + if err != nil { + t.Fatalf("SuggestSessionCandidates: %v", err) + } + if len(got) != 2 { + t.Fatalf("path 为空应给出全部 2 条,实际 %d", len(got)) + } +} + +// 平台侧会话(人直接在 opencode/DSH 界面上开的)经心跳上报后也要能被选中 —— +// 这正是「定期从 agent 平台同步会话」要解决的问题。 +func TestSuggestSessionCandidatesIncludesPlatformMirror(t *testing.T) { + setupTestDB(t) + seedPlatformAgent(t, "opencode") + + now := time.Now() + err := ReplacePlatformSessions(context.Background(), "opencode", []PlatformSession{ + {PlatformID: "ses_1", Workspace: "/home/program/agentmail", Slug: "witty-planet", + Title: "重构导入路径", MailDriven: false, UpdatedAt: &now}, + {PlatformID: "ses_2", Workspace: "/home/other", Slug: "brave-comet", + Title: "别的工作区", MailDriven: false, UpdatedAt: &now}, + }) + if err != nil { + t.Fatalf("ReplacePlatformSessions: %v", err) + } + + got, err := SuggestSessionCandidates(context.Background(), "admin", "opencode", "/home/program/agentmail") + if err != nil { + t.Fatalf("SuggestSessionCandidates: %v", err) + } + if len(got) != 1 { + t.Fatalf("应有 1 个平台候选,实际 %d:%+v", len(got), got) + } + if got[0].Alias != "witty-planet" || got[0].Source != "platform" { + t.Errorf("平台候选错误:%+v", got[0]) + } + if got[0].Title != "重构导入路径" { + t.Errorf("标题应带出来:%q", got[0].Title) + } +} + +// 同一别名两边都有时保留 mail 来源:它是「一定送得到」的保证, +// 镜像只是平台的说法。但镜像的标题该补上去 —— 平台标题通常比会话主题更贴切。 +func TestSuggestSessionCandidatesMailWinsOverMirror(t *testing.T) { + setupTestDB(t) + seedPlatformAgent(t, "opencode") + + // 本侧线索:有别名但主题为空 + sid := seedSessionWS(t, "witty-planet", "/home/x", "") + seedMailWS(t, sid, "admin", "opencode", "/home/x", "某事") + + now := time.Now() + if err := ReplacePlatformSessions(context.Background(), "opencode", []PlatformSession{ + {PlatformID: "ses_1", Workspace: "/home/x", Slug: "witty-planet", + Title: "平台侧的标题", UpdatedAt: &now}, + }); err != nil { + t.Fatalf("ReplacePlatformSessions: %v", err) + } + + got, err := SuggestSessionCandidates(context.Background(), "admin", "opencode", "/home/x") + if err != nil { + t.Fatalf("SuggestSessionCandidates: %v", err) + } + if len(got) != 1 { + t.Fatalf("同名应合并成 1 条,实际 %d:%+v", len(got), got) + } + if got[0].Source != "mail" { + t.Errorf("应保留 mail 来源(它保证送得到),实际 %q", got[0].Source) + } + if got[0].Title != "平台侧的标题" { + t.Errorf("本侧标题为空时应补上镜像的:%q", got[0].Title) + } +} + +// 上报是整表替换:平台侧删掉的会话必须从候选列表里消失。 +// 增量合并会让它永远留着,而 session 位指向一条不存在的会话会直接 404。 +func TestReplacePlatformSessionsIsFullReplace(t *testing.T) { + setupTestDB(t) + seedPlatformAgent(t, "dsh") + ctx := context.Background() + + if err := ReplacePlatformSessions(ctx, "dsh", []PlatformSession{ + {PlatformID: "s1", Workspace: "/w", Slug: "one"}, + {PlatformID: "s2", Workspace: "/w", Slug: "two"}, + }); err != nil { + t.Fatalf("首次上报: %v", err) + } + if got, _ := SuggestSessionCandidates(ctx, "admin", "dsh", "/w"); len(got) != 2 { + t.Fatalf("首次上报应有 2 条,实际 %d", len(got)) + } + + // 第二次只报一条:另一条在平台侧已被删除 + if err := ReplacePlatformSessions(ctx, "dsh", []PlatformSession{ + {PlatformID: "s1", Workspace: "/w", Slug: "one"}, + }); err != nil { + t.Fatalf("二次上报: %v", err) + } + got, _ := SuggestSessionCandidates(ctx, "admin", "dsh", "/w") + if len(got) != 1 || got[0].Alias != "one" { + t.Fatalf("整表替换失效,实际 %+v", got) + } +} + +// 无 slug 的平台会话不进候选:slug 是填进 session 位的值, +// 没有它这一项点下去只能得到一个空的 session 段。 +func TestPlatformSessionsWithoutSlugAreSkipped(t *testing.T) { + setupTestDB(t) + seedPlatformAgent(t, "dsh") + ctx := context.Background() + + if err := ReplacePlatformSessions(ctx, "dsh", []PlatformSession{ + {PlatformID: "s1", Workspace: "/w", Slug: ""}, + {PlatformID: "s2", Workspace: "/w", Slug: "named"}, + }); err != nil { + t.Fatalf("上报: %v", err) + } + got, _ := SuggestSessionCandidates(ctx, "admin", "dsh", "/w") + if len(got) != 1 || got[0].Alias != "named" { + t.Fatalf("无 slug 的应被跳过,实际 %+v", got) + } +} + +// 上报里的重复 platform_id 不该让整次事务失败(主键冲突)。 +func TestReplacePlatformSessionsDedupes(t *testing.T) { + setupTestDB(t) + seedPlatformAgent(t, "dsh") + ctx := context.Background() + + if err := ReplacePlatformSessions(ctx, "dsh", []PlatformSession{ + {PlatformID: "dup", Workspace: "/w", Slug: "first"}, + {PlatformID: "dup", Workspace: "/w", Slug: "second"}, + }); err != nil { + t.Fatalf("重复 id 不该报错: %v", err) + } + got, _ := SuggestSessionCandidates(ctx, "admin", "dsh", "/w") + if len(got) != 1 || got[0].Alias != "first" { + t.Fatalf("应保留第一条,实际 %+v", got) + } +} + +// SetSessionWorkspace 只在为空时写入:会话的工作区在建立时就定下了, +// 之后不该被一封发往别处的邮件改掉 —— 那会让它在候选列表里凭空换工作区。 +func TestSetSessionWorkspaceDoesNotOverwrite(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + sid := seedSessionWS(t, "fixed-ws", "/home/original", "某事") + if err := SetSessionWorkspace(ctx, sid, "/home/hijacked"); err != nil { + t.Fatalf("SetSessionWorkspace: %v", err) + } + var ws string + if err := db.DB.QueryRowContext(ctx, + `SELECT workspace FROM sessions WHERE session_id = $1`, sid).Scan(&ws); err != nil { + t.Fatalf("read back: %v", err) + } + if ws != "/home/original" { + t.Errorf("已有 workspace 被覆盖成 %q", ws) + } + + // 空的那种要能补上(历史会话回填) + empty := seedSessionWS(t, "empty-ws", "", "某事") + if err := SetSessionWorkspace(ctx, empty, "/home/filled"); err != nil { + t.Fatalf("SetSessionWorkspace(empty): %v", err) + } + if err := db.DB.QueryRowContext(ctx, + `SELECT workspace FROM sessions WHERE session_id = $1`, empty).Scan(&ws); err != nil { + t.Fatalf("read back: %v", err) + } + if ws != "/home/filled" { + t.Errorf("空 workspace 未被补上,实际 %q", ws) + } +} + +// CreateSession 要把 workspace 存下来 —— 这是整条链的起点, +// 漏在这里的话后面所有查询都只能靠 mails 反推。 +func TestCreateSessionStoresWorkspace(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + id, err := CreateSession(ctx, nil, "admin", "带工作区", "/home/program/agentmail") + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + var ws string + if err := db.DB.QueryRowContext(ctx, + `SELECT workspace FROM sessions WHERE session_id = $1`, id).Scan(&ws); err != nil { + t.Fatalf("read back: %v", err) + } + if ws != "/home/program/agentmail" { + t.Errorf("workspace 未落库:%q", ws) + } +} + +// 归档的会话不进候选:归档就是「这条线索结束了」, +// 还出现在补全里等于邀请用户往一条已关闭的线索里发信。 +func TestSuggestSessionCandidatesExcludesArchived(t *testing.T) { + setupTestDB(t) + seedPlatformAgent(t, "dsh") + ctx := context.Background() + + sid := seedSessionWS(t, "done-thread", "/home/a", "已完成") + seedMailWS(t, sid, "admin", "dsh", "/home/a", "已完成") + if _, err := db.DB.ExecContext(ctx, + `UPDATE sessions SET status = 'archived' WHERE session_id = $1`, sid); err != nil { + t.Fatalf("archive: %v", err) + } + + got, _ := SuggestSessionCandidates(ctx, "admin", "dsh", "/home/a") + if len(got) != 0 { + t.Fatalf("归档会话不该出现,实际 %+v", got) + } +} diff --git a/gateway/internal/repo/repo.go b/gateway/internal/repo/repo.go index 33906a9..4839b45 100644 --- a/gateway/internal/repo/repo.go +++ b/gateway/internal/repo/repo.go @@ -100,16 +100,16 @@ func VerifyAgent(ctx context.Context, name, secret string) (*models.Agent, error // // 显式传了 alias(发信时的 session_alias 参数)= 调用方亲自命名,标为 manual, // 平台后续自动同步不得覆盖;未传则等待平台命名,标为 platform。 -func CreateSession(ctx context.Context, alias *string, fromAgent, subject string) (uuid.UUID, error) { +func CreateSession(ctx context.Context, alias *string, fromAgent, subject, workspace string) (uuid.UUID, error) { source := "platform" if alias != nil && *alias != "" { source = "manual" } var id uuid.UUID err := db.DB.QueryRowContext(ctx, - `INSERT INTO sessions (session_alias, from_agent, subject, alias_source) - VALUES ($1, $2, $3, $4) RETURNING session_id`, - alias, fromAgent, subject, source, + `INSERT INTO sessions (session_alias, from_agent, subject, alias_source, workspace) + VALUES ($1, $2, $3, $4, $5) RETURNING session_id`, + alias, fromAgent, subject, source, strings.TrimSpace(workspace), ).Scan(&id) return id, err } @@ -514,6 +514,10 @@ var ErrSessionNotFound = errors.New("session not found") // FindNamedSessionFor 查找收件人 name@path 名下别名为 alias 的会话。 // 严格匹配:会话必须存在、未归档,且该收件人确实参与过该会话,否则返回 ErrSessionNotFound。 +// FindNamedSessionFor 实现 session 位给具体别名时的语义:必须已存在。 +// +// 不限定 workspace:别名全局唯一且本身就承担寻址职责, +// 再叠一层工作区校验只会让「名字对上了却送不到」变成一种难查的失败。 func FindNamedSessionFor(ctx context.Context, name, path, alias string) (uuid.UUID, error) { var id uuid.UUID err := db.DB.QueryRowContext(ctx, ` @@ -537,15 +541,27 @@ func FindNamedSessionFor(ctx context.Context, name, path, alias string) (uuid.UU // FindOrCreateDefaultSession 实现 session 位省略时的「默认会话」语义: // 复用 name@path 最近一次活跃的非归档会话;从未通过信则建立一个新的作为默认会话。 +// +// 匹配工作区优先看 sessions.workspace(权威来源),旧会话那列为空时回退到 +// mails.to_workspace 反推。只看 to_workspace:Agent 回信时 from_workspace 存的是 +// Agent 名而不是路径,拿它比路径永远匹配不上(旧实现就挂在这里)。 func FindOrCreateDefaultSession(ctx context.Context, name, path, fromAgent, subject string) (uuid.UUID, error) { var id uuid.UUID err := db.DB.QueryRowContext(ctx, ` SELECT s.session_id FROM sessions s - JOIN mails m ON m.session_id = s.session_id WHERE s.status <> 'archived' - AND (m.to_name = $1 OR m.from_name = $1 OR `+db.CCHas("m.cc_list", 1)+`) - AND (COALESCE(m.to_workspace,'') = $2 OR COALESCE(m.from_workspace,'') = $2) + AND EXISTS ( + SELECT 1 FROM mails m + WHERE m.session_id = s.session_id + AND (m.to_name = $1 OR m.from_name = $1 OR `+db.CCHas("m.cc_list", 1)+`) + ) + AND (s.workspace = $2 + OR (s.workspace = '' AND EXISTS ( + SELECT 1 FROM mails w + WHERE w.session_id = s.session_id + AND COALESCE(w.to_workspace,'') = $2 + ))) ORDER BY s.updated_at DESC LIMIT 1 `, name, path).Scan(&id) @@ -556,7 +572,7 @@ func FindOrCreateDefaultSession(ctx context.Context, name, path, fromAgent, subj if !errors.Is(err, sql.ErrNoRows) { return uuid.Nil, err } - return CreateSession(ctx, nil, fromAgent, subject) + return CreateSession(ctx, nil, fromAgent, subject, path) } // SessionAliasOf 返回会话别名,未命名或查询失败时返回空串。 @@ -864,40 +880,6 @@ func SuggestPaths(ctx context.Context, agentName string) ([]string, error) { return out, nil } -// SuggestSessionsFor 返回某 (peer, path) 下当前用户可见的会话别名(用于发信补全) -func SuggestSessionsFor(ctx context.Context, forUser, peerName, path string) ([]string, error) { - rows, err := db.DB.QueryContext(ctx, ` - SELECT DISTINCT s.session_alias - FROM sessions s - JOIN mails m ON m.session_id = s.session_id - WHERE s.session_alias IS NOT NULL - AND s.status <> 'archived' - AND (m.to_name = $1 OR m.from_name = $1) - AND ($2 = '' OR COALESCE(m.to_workspace,'') = $2 OR COALESCE(m.from_workspace,'') = $2) - AND ($3 = '' OR s.owner_user_id = (SELECT user_id FROM users WHERE username = $3) - OR EXISTS ( - SELECT 1 FROM mails mm - WHERE mm.session_id = s.session_id - AND (mm.from_name = $3 OR mm.to_name = $3 - OR `+db.CCHas("mm.cc_list", 3)+`) - )) - ORDER BY s.session_alias - `, peerName, path, forUser) - if err != nil { - return []string{}, err - } - defer rows.Close() - - out := []string{} - for rows.Next() { - var alias string - if err := rows.Scan(&alias); err == nil && alias != "" { - out = append(out, alias) - } - } - return out, rows.Err() -} - // ListSentBy 列出某发件人发出的邮件(发件箱),排除已归档会话 func ListSentBy(ctx context.Context, fromName string, limit int) ([]models.Mail, error) { rows, err := db.DB.QueryContext(ctx, ` diff --git a/gateway/internal/repo/thread.go b/gateway/internal/repo/thread.go index fe14db3..2381c2a 100644 --- a/gateway/internal/repo/thread.go +++ b/gateway/internal/repo/thread.go @@ -2,6 +2,7 @@ package repo import ( "context" + "database/sql" "encoding/json" "github.com/agentmail/gateway/internal/db" @@ -20,22 +21,21 @@ import ( // 也正因如此,读取时必须按会话逐个鉴权(见 handler): // A 转发给 B 之后,B 与 C 在新会话里的往来不能回流给 A。 // -// **分块加载而非截断**:线索可以有几百封,一次全取要把几 MB 预览塞给前端。 -// 按方向分页 —— 祖先向上、子孙向下,各自带游标。 +// **从根展开,而不是从锚点展开**:曾经的实现是「锚点的祖先链 + 锚点的子树」, +// 于是兄弟节点整条分支都在盲区里 —— 一封抄送给两个 Agent 的邮件,两个回复 +// 互为兄弟,从其中一个看树看不到另一个;挂在原件上的转发同理。 +// 兄弟既不是锚点的祖先也不是它的子孙,只有先上溯到根、再整棵 BFS 才能覆盖。 // -// 游标用「相对锚点的原始层号偏移」而不是 mail_id: -// - 偏移量每次从锚点重走一遍,无状态、不可伪造,也不需要额外证明 -// 「这个 cursor 真的在这条线索上」 -// - 用 mail_id 做游标就必须允许传入**不可见**的邮件(不可见的中间段要穿过去), -// 那就得单独校验它确实是锚点的祖先,反而更绕 -// - 祖先方向的层号天然稳定:新邮件只会追加成叶子,不会插进已有链条中间 +// **分块加载而非截断**:线索可以有几百封,一次全取要把几 MB 预览塞给前端。 +// 从根 BFS 后只剩一个方向,游标就是「已取到的节点数」。 // TreeMail 是树里的一个节点。正文只带预览:整棵线索带全文可能几百 KB, // 前端点开某封时再单取全文与附件清单。 type TreeMail struct { models.Mail - // Depth 是**相对锚点**的层级:0 = 锚点,-1 = 父,1 = 子。 - // 不用「距根深度」—— 分块加载时根可能还没取到,绝对深度无从得知。 + // Depth 是**距线索根**的层级:0 = 根,1 = 它的直接回复。 + // 从根展开后根一定在结果里,绝对深度因此总是可知的(早先按相对锚点算, + // 是因为那时根可能还没取到)。 Depth int `json:"depth"` AttachmentCount int `json:"attachment_count"` } @@ -53,11 +53,40 @@ const threadCols = `m.mail_id, m.session_id, m.parent_mail_id, m.status, m.created_at, s.session_alias, (SELECT COUNT(*) FROM attachments a WHERE a.mail_id = m.mail_id) AS attach_count` -// AncestorsRaw 沿 parent_mail_id 上溯,取第 offset+1 .. offset+limit 层的祖先。 -// 层号 1 = 父,2 = 祖父;返回的 Depth 为负数。 +// ThreadRootOf 沿 parent_mail_id 上溯到线索的根,返回根的 mail_id 与锚点到根的层数。 // -// **不做可见性过滤** —— 不可见的中间段必须能穿过:转发把线索引进别人的会话, -// 再往上却可能仍是自己参与的往来。过滤放在 handler 层(那里知道调用者是谁)。 +// 「根」= 链条最上面那封:parent_mail_id 为 NULL,或者指向一封已被删掉的邮件 +// (JOIN 断掉,递归自然停在这一层)。锚点自己没有父时返回它自己、depth 0。 +// +// **不做可见性过滤**:不可见的中间段必须能穿过 —— 转发把线索引进别人的会话, +// 再往上却可能仍是自己参与的往来。只返回 id 与层数,不泄露任何内容。 +func ThreadRootOf(ctx context.Context, anchorID uuid.UUID) (uuid.UUID, int, error) { + var rootID uuid.UUID + var lvl int + err := db.DB.QueryRowContext(ctx, ` + WITH RECURSIVE up(mail_id, parent_mail_id, lvl) AS ( + SELECT mail_id, parent_mail_id, 0 FROM mails WHERE mail_id = $1 + UNION ALL + SELECT m.mail_id, m.parent_mail_id, up.lvl + 1 + FROM mails m JOIN up ON m.mail_id = up.parent_mail_id + WHERE up.lvl < $2 + ) + SELECT mail_id, lvl FROM up ORDER BY lvl DESC LIMIT 1 + `, anchorID, descendantDepthCap).Scan(&rootID, &lvl) + if err != nil { + return uuid.Nil, 0, err + } + return rootID, lvl, nil +} + +// AncestorsRaw 沿 parent_mail_id 上溯,取第 offset+1 .. offset+limit 层的祖先。 +// 层号 1 = 父,2 = 祖父;返回的 Depth 为负数(相对锚点)。 +// +// 从根 BFS 之后这个函数只在一处还有用:巨型线索里锚点没落在 BFS 首页时, +// 用它把「根到锚点」这条路径单独补齐,保证点开的那封一定看得见。 +// 调用方需要自己把负 depth 换算成绝对深度(锚点绝对深度由 ThreadRootOf 给出)。 +// +// **不做可见性过滤**,理由同 ThreadRootOf。过滤放在 handler 层(那里知道调用者是谁)。 // // 第二个返回值表示 offset+limit 层之上还有节点。 func AncestorsRaw(ctx context.Context, anchorID uuid.UUID, offset, limit int) ([]TreeMail, bool, error) { @@ -91,14 +120,16 @@ func AncestorsRaw(ctx context.Context, anchorID uuid.UUID, offset, limit int) ([ return out, hasMore, nil } -// DescendantsRaw 取锚点及其子孙,BFS 顺序(同层按时间),按节点数分页。 +// DescendantsRaw 取给定节点及其全部子孙,BFS 顺序(同层按时间),按节点数分页。 // -// offset = 0 时结果的第一个是锚点自己(Depth 0)。 -// 同样不做可见性过滤,理由同 AncestorsRaw:不可见的子节点下面可能挂着可见的孙节点 +// 传线索的根(见 ThreadRootOf)就能覆盖整棵树:兄弟、抄送产生的平行回复、 +// 挂在原件上的转发分支,全都是根的子孙。offset = 0 时结果第一个是起点自己(Depth 0)。 +// +// 同样不做可见性过滤:不可见的子节点下面可能挂着可见的孙节点 // (别人把线索转走又转回来给我)。 // -// 注意 CTE 每次都会走完整棵子树,LIMIT 只截断输出。一封邮件的子孙通常很少 -// (分支来自转发,不是回复),这个代价可以接受;真出现巨型子树时再加物化。 +// 注意 CTE 每次都会走完整棵子树,LIMIT 只截断输出。一条邮件线索通常几十封, +// 这个代价可以接受;真出现巨型线索时再加物化。 func DescendantsRaw(ctx context.Context, anchorID uuid.UUID, offset, limit int) ([]TreeMail, bool, error) { rows, err := db.DB.QueryContext(ctx, ` WITH RECURSIVE down(mail_id, lvl) AS ( @@ -129,6 +160,30 @@ func DescendantsRaw(ctx context.Context, anchorID uuid.UUID, offset, limit int) return out, hasMore, nil } +// TreeMailByID 取单封邮件的树节点形式,深度由调用方给定。 +// +// 补齐「根 → 锚点」路径时用得上:AncestorsRaw 从父开始,不含锚点自己。 +// 同样不做可见性过滤,由 handler 负责。 +func TreeMailByID(ctx context.Context, id uuid.UUID, depth int) (*TreeMail, error) { + rows, err := db.DB.QueryContext(ctx, ` + SELECT `+threadCols+`, $2 + FROM mails m + JOIN sessions s ON m.session_id = s.session_id + WHERE m.mail_id = $1 + `, id, depth) + if err != nil { + return nil, err + } + out, err := scanTreeRows(rows, false) + if err != nil { + return nil, err + } + if len(out) == 0 { + return nil, sql.ErrNoRows + } + return &out[0], nil +} + // scanTreeRows 读出节点。negate 为真时把层号取负(祖先方向)。 func scanTreeRows(rows interface { Next() bool diff --git a/gateway/internal/repo/thread_test.go b/gateway/internal/repo/thread_test.go index e19a81c..8022ef7 100644 --- a/gateway/internal/repo/thread_test.go +++ b/gateway/internal/repo/thread_test.go @@ -1,6 +1,12 @@ package repo -import "testing" +import ( + "context" + "testing" + + "github.com/agentmail/gateway/internal/db" + "github.com/google/uuid" +) func TestPreviewTruncatesOnUTF8Boundary(t *testing.T) { // 「巡」是 3 字节;在 max=4 处切会切进第 2 个字符中间 @@ -21,3 +27,260 @@ func TestPreviewKeepsShortBodyIntact(t *testing.T) { t.Fatalf("未超长却被改动:%q", got) } } + +// seedReply 插一封回复:parent 指向来信。 +func seedReply(t *testing.T, sessionID uuid.UUID, parent uuid.UUID, from, to, subject string) uuid.UUID { + t.Helper() + var id uuid.UUID + err := db.DB.QueryRowContext(context.Background(), ` + INSERT INTO mails (session_id, parent_mail_id, from_name, from_workspace, + to_name, to_workspace, subject, body, cc_list, created_at) + VALUES ($1, $2, $3, '', $4, '', $5, 'body', '[]', $6) + RETURNING mail_id + `, sessionID, parent, from, to, subject, nextSeedTime()).Scan(&id) + if err != nil { + t.Fatalf("seed reply: %v", err) + } + return id +} + +// 线索根定位:整棵树从根展开,所以这一步错了后面全错。 +func TestThreadRootOf(t *testing.T) { + setupTestDB(t) + sid := seedSessionRow(t, "root-of") + + root := seedMailIn(t, sid, "admin", "dsh", "原件") + mid := seedReply(t, sid, root, "dsh", "admin", "Re: 原件") + leaf := seedReply(t, sid, mid, "admin", "dsh", "Re: Re: 原件") + + cases := []struct { + name string + from uuid.UUID + depth int + }{ + {"从根本身出发", root, 0}, + {"从中间一封出发", mid, 1}, + {"从叶子出发", leaf, 2}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + gotRoot, gotDepth, err := ThreadRootOf(context.Background(), c.from) + if err != nil { + t.Fatalf("ThreadRootOf: %v", err) + } + if gotRoot != root { + t.Errorf("根定位错误:得到 %s,期望 %s", gotRoot, root) + } + if gotDepth != c.depth { + t.Errorf("层数错误:得到 %d,期望 %d", gotDepth, c.depth) + } + }) + } +} + +// 这个测试是对话树那次故障的回归: +// 一封抄送给两个 Agent 的邮件收到两个回复,它们互为**兄弟**。 +// 旧实现从锚点分「祖先方向 + 子孙方向」两路展开,兄弟既不是锚点的祖先 +// 也不是它的子孙,于是整条分支在树里根本不出现。 +// 从线索根 BFS 之后,兄弟都是根的子孙,必须一次全出来。 +func TestDescendantsFromRootIncludesSiblings(t *testing.T) { + setupTestDB(t) + sid := seedSessionRow(t, "siblings") + + root := seedMailIn(t, sid, "admin", "dsh", "测试抄送") + replyA := seedReply(t, sid, root, "dsh", "admin", "Re: 测试抄送") + replyB := seedReply(t, sid, root, "opencode", "admin", "Re: 测试抄送") + + // 从 replyA 出发定位根,再从根整树展开 + gotRoot, anchorDepth, err := ThreadRootOf(context.Background(), replyA) + if err != nil { + t.Fatalf("ThreadRootOf: %v", err) + } + if gotRoot != root || anchorDepth != 1 { + t.Fatalf("根定位错误:root=%s depth=%d", gotRoot, anchorDepth) + } + + nodes, hasMore, err := DescendantsRaw(context.Background(), gotRoot, 0, 50) + if err != nil { + t.Fatalf("DescendantsRaw: %v", err) + } + if hasMore { + t.Error("三封邮件不该报 hasMore") + } + + byID := map[uuid.UUID]TreeMail{} + for _, n := range nodes { + byID[n.ID] = n + } + for name, id := range map[string]uuid.UUID{"根": root, "回复A": replyA, "回复B": replyB} { + if _, ok := byID[id]; !ok { + t.Errorf("%s 不在树里 —— 兄弟分支又丢了", name) + } + } + if byID[root].Depth != 0 { + t.Errorf("根的深度应为 0,实际 %d", byID[root].Depth) + } + if byID[replyA].Depth != 1 || byID[replyB].Depth != 1 { + t.Errorf("两个回复都应在深度 1:A=%d B=%d", byID[replyA].Depth, byID[replyB].Depth) + } +} + +// 转发落在**另一个会话**里,但 parent 仍指向原件。 +// 树必须跨会话展开,否则「这条线索转发给谁了」就看不见了。 +func TestDescendantsCrossSession(t *testing.T) { + setupTestDB(t) + srcSession := seedSessionRow(t, "fwd-src") + dstSession := seedSessionRow(t, "fwd-dst") + + root := seedMailIn(t, srcSession, "admin", "dsh", "原件") + // 转发:新会话,parent 仍指原件 + fwd := seedReply(t, dstSession, root, "admin", "opencode", "Fwd: 原件") + // 转发的下游回复,还在新会话里 + fwdReply := seedReply(t, dstSession, fwd, "opencode", "admin", "Re: Fwd: 原件") + + nodes, _, err := DescendantsRaw(context.Background(), root, 0, 50) + if err != nil { + t.Fatalf("DescendantsRaw: %v", err) + } + found := map[uuid.UUID]int{} + for _, n := range nodes { + found[n.ID] = n.Depth + } + if _, ok := found[fwd]; !ok { + t.Error("转发不在树里 —— 跨会话展开失效") + } + if _, ok := found[fwdReply]; !ok { + t.Error("转发的下游回复不在树里") + } + if found[fwd] != 1 || found[fwdReply] != 2 { + t.Errorf("跨会话深度错误:fwd=%d fwdReply=%d(期望 1/2)", found[fwd], found[fwdReply]) + } + // 会话不同 → session_id 必须如实反映,否则前端无法标出「线索去了别的会话」 + for _, n := range nodes { + if n.ID == fwd && n.SessionID != dstSession { + t.Errorf("转发的 session_id 错误:%s,期望 %s", n.SessionID, dstSession) + } + } +} + +// 分页:BFS 顺序稳定,两页拼起来等于一次全取。 +func TestDescendantsPaginationStable(t *testing.T) { + setupTestDB(t) + sid := seedSessionRow(t, "paging") + + root := seedMailIn(t, sid, "admin", "dsh", "原件") + for i := 0; i < 5; i++ { + seedReply(t, sid, root, "dsh", "admin", "Re: 原件") + } + + full, hasMoreFull, err := DescendantsRaw(context.Background(), root, 0, 50) + if err != nil { + t.Fatalf("全取: %v", err) + } + if hasMoreFull { + t.Error("6 封邮件一次取完不该报 hasMore") + } + if len(full) != 6 { + t.Fatalf("应有 6 个节点,实际 %d", len(full)) + } + + page1, hasMore1, err := DescendantsRaw(context.Background(), root, 0, 4) + if err != nil { + t.Fatalf("第一页: %v", err) + } + if !hasMore1 { + t.Error("还有 2 封没取,hasMore 应为真") + } + page2, hasMore2, err := DescendantsRaw(context.Background(), root, 4, 4) + if err != nil { + t.Fatalf("第二页: %v", err) + } + if hasMore2 { + t.Error("第二页已取完,hasMore 应为假") + } + + joined := append(append([]TreeMail{}, page1...), page2...) + if len(joined) != len(full) { + t.Fatalf("两页拼接 %d 个,全取 %d 个", len(joined), len(full)) + } + for i := range full { + if joined[i].ID != full[i].ID { + t.Fatalf("第 %d 个节点顺序不一致:分页 %s,全取 %s —— BFS 顺序不稳定,"+ + "分页加载会重复或漏掉节点", i, joined[i].ID, full[i].ID) + } + } +} + +// TreeMailByID 是「锚点没落进 BFS 首页」时的回填手段,深度由调用方给。 +func TestTreeMailByIDUsesGivenDepth(t *testing.T) { + setupTestDB(t) + sid := seedSessionRow(t, "by-id") + id := seedMailIn(t, sid, "admin", "dsh", "某封") + + got, err := TreeMailByID(context.Background(), id, 7) + if err != nil { + t.Fatalf("TreeMailByID: %v", err) + } + if got.ID != id { + t.Errorf("取错了邮件:%s", got.ID) + } + if got.Depth != 7 { + t.Errorf("深度应取调用方给的 7,实际 %d", got.Depth) + } + // 树视图只要预览,全文必须被清空 —— 否则整条线索会把几百 KB 正文塞给前端 + if got.Body != "" { + t.Errorf("Body 应清空,实际 %q", got.Body) + } + if got.BodyPreview == "" { + t.Error("BodyPreview 应有内容") + } +} + +// 抄送列表必须原样带出来:树上两个兄弟节点为什么并列, +// 唯一的解释就是父邮件抄送给了两个人。丢了 cc_list 前端就没法说明。 +func TestTreeCarriesCCList(t *testing.T) { + setupTestDB(t) + sid := seedSessionRow(t, "cc-carry") + + var root uuid.UUID + err := db.DB.QueryRowContext(context.Background(), ` + INSERT INTO mails (session_id, from_name, from_workspace, to_name, to_workspace, + subject, body, cc_list, created_at) + VALUES ($1, 'admin', '', 'dsh', '', '抄送两人', 'body', + '[{"name":"opencode","path":"/home","session":"new","raw":"opencode@/home.new"}]', $2) + RETURNING mail_id + `, sid, nextSeedTime()).Scan(&root) + if err != nil { + t.Fatalf("seed: %v", err) + } + + nodes, _, err := DescendantsRaw(context.Background(), root, 0, 10) + if err != nil { + t.Fatalf("DescendantsRaw: %v", err) + } + if len(nodes) != 1 { + t.Fatalf("应有 1 个节点,实际 %d", len(nodes)) + } + if len(nodes[0].CCList) != 1 { + t.Fatalf("抄送应有 1 人,实际 %d —— cc_list 没带出来", len(nodes[0].CCList)) + } + if nodes[0].CCList[0].Raw != "opencode@/home.new" { + t.Errorf("抄送 raw 错误:%q", nodes[0].CCList[0].Raw) + } +} + +// 无抄送时 cc_list 必须是空数组而不是 null: +// Go 的 nil slice 会序列化成 null,前端 node.cc_list.length 直接抛异常。 +func TestTreeEmptyCCIsArrayNotNull(t *testing.T) { + setupTestDB(t) + sid := seedSessionRow(t, "cc-empty") + root := seedMailIn(t, sid, "admin", "dsh", "无抄送") + + nodes, _, err := DescendantsRaw(context.Background(), root, 0, 10) + if err != nil { + t.Fatalf("DescendantsRaw: %v", err) + } + if nodes[0].CCList == nil { + t.Error("cc_list 为 nil,会序列化成 null") + } +} diff --git a/plugins/dsh-mail-bridge/cordis.patch.yml b/plugins/dsh-mail-bridge/cordis.patch.yml new file mode 100644 index 0000000..688484e --- /dev/null +++ b/plugins/dsh-mail-bridge/cordis.patch.yml @@ -0,0 +1,14 @@ +# dsh-mail-bridge bundle patch. +# 安装方式: dsh plugin --profile web add link:/home/program/agentmail/plugins/dsh-mail-bridge +- insert: + - id: dsh-mail-bridge + name: dsh-mail-bridge + config: + gateway: + url: !!js process.env.AGENTMAIL_GATEWAY_URL ?? 'http://127.0.0.1:8180' + agentName: !!js process.env.AGENTMAIL_AGENT_NAME ?? 'dsh' + agentKey: !!js process.env.AGENTMAIL_AGENT_KEY + agentSecret: !!js process.env.AGENTMAIL_AGENT_SECRET + reply: + provider: !!js process.env.AGENTMAIL_REPLY_PROVIDER + model: !!js process.env.AGENTMAIL_REPLY_MODEL diff --git a/plugins/dsh-mail-bridge/lib/message.d.ts b/plugins/dsh-mail-bridge/lib/message.d.ts new file mode 100644 index 0000000..ba541f0 --- /dev/null +++ b/plugins/dsh-mail-bridge/lib/message.d.ts @@ -0,0 +1,10 @@ +export interface DshUserMessage { + content: { type: 'text'; text: string }[]; + source: { kind: 'user' }; +} + +export function userMessage(text: string): DshUserMessage; +export function stripRe(subject: string): string; +export function replySubject(subject: string, fallback?: string): string; +export function lastAssistantText(events: readonly any[]): string; +export function modelTitle(events: readonly any[]): string; diff --git a/plugins/dsh-mail-bridge/lib/message.js b/plugins/dsh-mail-bridge/lib/message.js new file mode 100644 index 0000000..3fe2ff9 --- /dev/null +++ b/plugins/dsh-mail-bridge/lib/message.js @@ -0,0 +1,88 @@ +/** + * DSH 消息与会话日志的纯函数工具。 + * + * 单独放一个模块是为了能被单测覆盖 —— 其中 userMessage() 的形状曾让整个插件 + * 卡了一下午(见该函数注释),这种「错了不报错、只在深处炸一个无关的类型错误」 + * 的约定必须被测试钉住。 + */ + +/** + * 构造 DSH 的 UserMessage。 + * + * **这是 followup()/steer() 唯一接受的形状。** DSH 的 `agent.followup(message)` + * 要的是完整的 UserMessage(`content` + `source`),不是 opencode 那种 parts 数组。 + * 传数组进去不会当场报错:agent-loop 会一路走到 preStep 里读 `message.source.kind`, + * 然后抛 `Cannot read properties of undefined (reading 'kind')` —— 错误信息落在 + * agent-loop 内部,完全不指向调用点。 + * + * @param {string} text 正文 + * @returns {{content: {type: 'text', text: string}[], source: {kind: 'user'}}} + */ +export function userMessage(text) { + return { + content: [{ type: 'text', text: String(text) }], + source: { kind: 'user' }, + }; +} + +/** 去掉已有的 Re: 前缀,避免 Re: Re: Re: 无限叠加。 */ +export function stripRe(subject) { + return String(subject ?? '').replace(/^(\s*Re:\s*)+/i, ''); +} + +/** + * 回信主题:原主题前加一个 Re:,空主题给一个兜底。 + * @param {string} subject 来信主题 + * @param {string} fallback 主题为空时用的标题 + */ +export function replySubject(subject, fallback = 'DSH 回复') { + const base = stripRe(subject).trim(); + return base ? `Re: ${base}` : fallback; +} + +/** + * 从会话事件日志里取最后一条 assistant 消息的可见文本。 + * + * 只取 `type === 'text'` 的块:reasoning 块是模型的思考过程,不该出现在邮件里。 + * + * @param {readonly any[]} events session.events + * @returns {string} 文本,找不到时为空串 + */ +export function lastAssistantText(events) { + const list = Array.isArray(events) ? events : []; + for (let i = list.length - 1; i >= 0; i--) { + const ev = list[i]; + if (ev?.type !== 'assistant/message') continue; + const blocks = ev.data?.message?.content; + if (!Array.isArray(blocks)) return ''; + return blocks + .filter((b) => b?.type === 'text' && typeof b.text === 'string') + .map((b) => b.text) + .join('\n') + .trim(); + } + return ''; +} + +/** + * 从会话事件日志里取最后一次 session/title 的标题。 + * + * DSH 首轮结束后由模型生成摘要标题,之前是 `source.kind === 'fallback'` 的占位 + * (内容就是用户第一句话的截断)。占位标题不值得回写给 AgentMail,会把 + * 「你收到一封新邮件(AgentMail)」这种插件自己的提示词当成会话标题。 + * + * @param {readonly any[]} events session.events + * @returns {string} 模型生成的标题,没有则空串 + */ +export function modelTitle(events) { + const list = Array.isArray(events) ? events : []; + for (let i = list.length - 1; i >= 0; i--) { + const ev = list[i]; + if (ev?.type !== 'session/title') continue; + const kind = ev.data?.source?.kind; + if (kind === 'fallback') return ''; + const title = ev.data?.title; + return typeof title === 'string' ? title.trim() : ''; + } + return ''; +} diff --git a/plugins/dsh-mail-bridge/lib/relay-dedup.d.ts b/plugins/dsh-mail-bridge/lib/relay-dedup.d.ts new file mode 100644 index 0000000..f103bca --- /dev/null +++ b/plugins/dsh-mail-bridge/lib/relay-dedup.d.ts @@ -0,0 +1,14 @@ +export interface RelayRecord { + names: Set; + replyTos: Set; +} + +export declare const explicitSends: Map; + +export function addrName(addr: string): string; +export function noteExplicitSend(sessionID: string | undefined, to: string, replyTo: string): void; +export function shouldSkipAutoRelay( + sent: RelayRecord | undefined, + replyTo: string, + mailID?: string +): boolean; diff --git a/plugins/dsh-mail-bridge/lib/relay-dedup.js b/plugins/dsh-mail-bridge/lib/relay-dedup.js new file mode 100644 index 0000000..bccb65a --- /dev/null +++ b/plugins/dsh-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/dsh-mail-bridge/lib/session-snapshot.d.ts b/plugins/dsh-mail-bridge/lib/session-snapshot.d.ts new file mode 100644 index 0000000..d478850 --- /dev/null +++ b/plugins/dsh-mail-bridge/lib/session-snapshot.d.ts @@ -0,0 +1,22 @@ +export interface PlatformSessionReport { + platform_id: string; + workspace: string; + slug: string; + title: string; + mail_driven: boolean; + updated_at?: string; +} + +export declare const MAX_REPORTED: number; + +export function snapshotOpencodeSessions( + sessions: readonly any[], + isMailDriven?: (id: string) => boolean +): PlatformSessionReport[]; + +export function snapshotDshSessions( + entries: readonly any[], + isMailDriven?: (id: string) => boolean +): PlatformSessionReport[]; + +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 new file mode 100644 index 0000000..e4cbfee --- /dev/null +++ b/plugins/dsh-mail-bridge/lib/session-snapshot.js @@ -0,0 +1,151 @@ +/** + * 平台会话快照:把 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)); +} + +/** 判断一条会话是否为 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 串 → ISO 串;无法解析时返回 undefined。 */ +function toISO(v) { + if (typeof v === 'number' && Number.isFinite(v)) { + return new Date(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/dsh-mail-bridge/lib/workspace.d.ts b/plugins/dsh-mail-bridge/lib/workspace.d.ts new file mode 100644 index 0000000..ba9ca04 --- /dev/null +++ b/plugins/dsh-mail-bridge/lib/workspace.d.ts @@ -0,0 +1,6 @@ +export function resolveWorkspaceCwd( + workspace: string | undefined, + fallbackKey: string +): { cwd: string; grouped: boolean }; + +export function ensureCwd(cwd: string, grouped: boolean): void; diff --git a/plugins/dsh-mail-bridge/lib/workspace.js b/plugins/dsh-mail-bridge/lib/workspace.js new file mode 100644 index 0000000..25455b1 --- /dev/null +++ b/plugins/dsh-mail-bridge/lib/workspace.js @@ -0,0 +1,65 @@ +/** + * 邮件寻址里的工作目录(三维地址 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'; + +/** + * 把 new_mail 事件里的 to_workspace 解析成一个可用的 cwd。 + * + * 决策顺序: + * 1. path 位是一个已存在的目录 → 直接用它(同 path 的多封邮件天然同组) + * 2. path 位非空但目录不存在 → **不创建**,回退到兜底目录 + * 3. path 位为空(地址写成 `dsh` 而不带 `@/path`)→ 兜底目录 + * + * 为什么不给不存在的 path 建目录:那等于让一个笔误(`/home/porgram/x`) + * 在磁盘上落下一个真目录,而 Agent 会在里面一无所获地干活 —— + * 用户看到会话建起来了却什么都做不了,比明确落到兜底目录更难排查。 + * + * 为什么拒绝相对路径:cwd 的相对基准是 harness 进程的启动目录, + * 那是个与邮件语义无关的量(systemd 下通常是 `/`)。 + * + * @param {string} workspace 事件里的 to_workspace + * @param {string} fallbackKey 兜底目录名(通常是会话 id) + * @returns {{cwd: string, grouped: boolean}} grouped 为真表示落在了寻址指定的目录里 + */ +export function resolveWorkspaceCwd(workspace, fallbackKey) { + const raw = typeof workspace === 'string' ? workspace.trim() : ''; + const fallback = join(homedir(), '.dsh', 'mail-sessions', String(fallbackKey || 'default')); + + if (!raw || !isAbsolute(raw)) return { cwd: fallback, grouped: false }; + + const abs = resolve(raw); + try { + if (existsSync(abs) && statSync(abs).isDirectory()) { + return { cwd: abs, grouped: true }; + } + } catch { + // 权限不足等:当作不可用 + } + return { cwd: fallback, grouped: false }; +} + +/** + * 确保兜底目录存在。寻址指定的目录本来就存在(否则不会被选中), + * 只有兜底目录需要现建。 + * @param {string} cwd resolveWorkspaceCwd 的结果 + * @param {boolean} grouped 是否落在寻址指定的目录里 + */ +export function ensureCwd(cwd, grouped) { + if (grouped) return; + try { + mkdirSync(cwd, { recursive: true }); + } catch { + // 建不出来就让 harness 自己报错,这里不该吞掉真实原因 + } +} diff --git a/plugins/dsh-mail-bridge/package-lock.json b/plugins/dsh-mail-bridge/package-lock.json new file mode 100644 index 0000000..94c6eea --- /dev/null +++ b/plugins/dsh-mail-bridge/package-lock.json @@ -0,0 +1,102 @@ +{ + "name": "dsh-mail-bridge", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dsh-mail-bridge", + "version": "0.1.0", + "dependencies": { + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^6.0.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1" + } + }, + "node_modules/@deepseek-ai/cordis": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/@deepseek-ai/cordis/-/cordis-4.0.2.tgz", + "integrity": "sha512-asOnXP1TzFSFQlHb1iegDZp0z/8WD1c7YNrwJR/Tx2bzNuMXfcekE/I67Iv6SQXeLB4csxqCngzQKANP7gdw0g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/cosmokit": "^1.8.3", + "@standard-schema/spec": "^1.1.0" + }, + "bin": { + "cordis": "bin.js" + }, + "peerDependencies": { + "@deepseek-ai/cordis-plugin-include": "^1.0.7", + "@deepseek-ai/cordis-plugin-loader": "^1.0.3" + }, + "peerDependenciesMeta": { + "@deepseek-ai/cordis-plugin-include": { + "optional": true + }, + "@deepseek-ai/cordis-plugin-loader": { + "optional": true + } + } + }, + "node_modules/@deepseek-ai/cosmokit": { + "version": "1.8.3", + "resolved": "https://registry.npmmirror.com/@deepseek-ai/cosmokit/-/cosmokit-1.8.3.tgz", + "integrity": "sha512-qBo+ronVM6Eu2WNVJXi8JcMiqZ19T9BRIpV+5qJUFPXjGH/Z0QKcQMC/IZJ7L394YTOtJgcovbk9qP0w2GsBXQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/plugins/dsh-mail-bridge/package.json b/plugins/dsh-mail-bridge/package.json new file mode 100644 index 0000000..2990cc9 --- /dev/null +++ b/plugins/dsh-mail-bridge/package.json @@ -0,0 +1,26 @@ +{ + "name": "dsh-mail-bridge", + "version": "0.1.0", + "description": "DeepSeek Harness plugin: AgentMail 邮件驱动多智能体协作平台桥接", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, + "scripts": { + "build": "tsc", + "verify": "tsc --noEmit", + "test": "node --test 'test/*.test.mjs'" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-tools": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^6.0.3" + } +} diff --git a/plugins/dsh-mail-bridge/src/index.ts b/plugins/dsh-mail-bridge/src/index.ts new file mode 100644 index 0000000..3c18b4c --- /dev/null +++ b/plugins/dsh-mail-bridge/src/index.ts @@ -0,0 +1,696 @@ +/** + * dsh-mail-bridge — DeepSeek Harness ↔ AgentMail 桥接插件 + * + * 与 opencode-mail-bridge 共享同一套 Gateway API。 + * DSH 用 Cordis 插件框架(@deepseek-ai/cordis),不是 opencode 的 @opencode-ai/plugin。 + * + * 关键差异: + * - opencode: client.session.create() + client.session.promptAsync() + * - DSH: ctx.agents.create() + agent.followup() + * - opencode: tool 用 zod schema + * - DSH: tool 用 defineTool() + 参数 spec 格式 + */ + +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; +import { randomBytes } from 'node:crypto'; +import { homedir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { + explicitSends, + noteExplicitSend, + shouldSkipAutoRelay, +} from '../lib/relay-dedup.js'; +import { + userMessage, + replySubject, + lastAssistantText, + modelTitle, +} from '../lib/message.js'; +import { snapshotDshSessions, slugFromTitle } from '../lib/session-snapshot.js'; +import { resolveWorkspaceCwd, ensureCwd } from '../lib/workspace.js'; + +// ─── 凭证管理 ─── + +const CONFIG_DIR = process.env.AGENTMAIL_CONFIG_DIR || join(homedir(), '.agentmail'); +const KEY_FILE = join(CONFIG_DIR, 'agent.key'); + +function readLocalKey(): string | null { + try { + if (!existsSync(KEY_FILE)) return null; + const raw = JSON.parse(readFileSync(KEY_FILE, 'utf8')); + return typeof raw.key === 'string' ? raw.key : null; + } catch { return null; } +} + +function saveLocalKey(key: string) { + try { + mkdirSync(dirname(KEY_FILE), { recursive: true }); + writeFileSync(KEY_FILE, JSON.stringify({ key }, null, 2), 'utf8'); + } catch { /* 忽略 */ } +} + +function generateLocalKey(): string { + const key = 'ak_' + randomBytes(24).toString('hex'); + saveLocalKey(key); + return key; +} + +// ─── Gateway HTTP 客户端 ─── + +class GatewayClient { + baseURL: string; + agentName: string; + agentKey: string; + agentSecret: string; + + constructor(gatewayURL: string, agentName: string, agentKey: string, agentSecret: string) { + this.baseURL = gatewayURL.replace(/\/+$/, ''); + this.agentName = agentName; + this.agentKey = agentKey; + this.agentSecret = agentSecret; + } + + /** 与 opencode-mail-bridge 的 authHeaders() 相同逻辑 */ + authHeaders(): Record { + 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 post(path: string, body: Record): Promise { + 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() as any; + if (!res.ok) throw new Error(data?.error || `POST ${path} failed: ${res.status}`); + return data; + } + + async get(path: string): Promise { + const res = await fetch(`${this.baseURL}/api/v1${path}`, { headers: this.authHeaders() }); + const data = await res.json() as any; + if (!res.ok) throw new Error(data?.error || `GET ${path} failed: ${res.status}`); + return data; + } + + async register(): Promise { + await this.post('/agent/register', { + name: this.agentName, + secret: this.agentSecret || '', + workspaces: [], + platform: 'dsh', + }); + } +} + +// ─── 会话映射(与 opencode-mail-bridge 相同结构)─── + +const sessionMap = new Map(); +const reverseMap = new Map(); +const mailDrivenSessions = new Set(); +const mailContexts = new Map(); +const relayedSummaries = new Map(); +const syncedTitles = new Map(); + +// 权限询问:DSH 的 approval/request 是 waterfall 钩子,插件把它转成邮件问人, +// 人类决策通过 SSE 回来后再 resolve 这个 promise,让 DSH 自己恢复执行。 +// relay_key 用 `${sessionId}:${toolName}:${callId}` —— DSH 不给询问发 id, +// 而同一个 callId 的同一个工具只会问一次。 +interface PendingApproval { + resolve: (outcome: string) => void; + sessionId: string; +} +const pendingApprovals = new Map(); + +// ─── 运行时导入 DSH 内部函数 ─── + +let _defineTool: any; +function defineTool(opts: any): any { + if (!_defineTool) { + try { + const dshToolsPath = require.resolve('@deepseek-ai/dsh-tools'); + _defineTool = require(dshToolsPath).defineTool; + } catch { return opts; } + } + return _defineTool(opts); +} + +// ─── Cordis 插件入口 ─── + +// Cordis 要求插件声明依赖的服务:没有 inject,ctx.tools / ctx.agents 根本不存在 +// (报 `cannot get property "tools" without inject`)。 +// +// sessionQuery 不列在这里而用 ctx.get('sessionQuery') 取:inject 是硬依赖, +// 列进去的服务没挂载时整个插件不会启动 —— 而会话上报只是补全体验, +// 不应该能把邮件投递整体拘死。 +export const inject = ['agents', 'tools']; +export const name = 'dsh-mail-bridge'; + +interface PluginConfig { + gateway: { url: string; agentName: string; agentKey: string; agentSecret: string }; + reply: { provider?: string; model?: string }; +} + +export function apply(ctx: any, config: PluginConfig): void { + const GW = config.gateway.url || 'http://127.0.0.1:8180'; + const AGENT_NAME = config.gateway.agentName || 'dsh'; + let AGENT_KEY = config.gateway.agentKey || ''; + const AGENT_SECRET = config.gateway.agentSecret || ''; + const REPLY_PROVIDER = config.reply.provider || ''; + const REPLY_MODEL = config.reply.model || 'AUTO'; + + if (!AGENT_KEY && !AGENT_SECRET) { + AGENT_KEY = readLocalKey() || generateLocalKey(); + } + + const client = new GatewayClient(GW, AGENT_NAME, AGENT_KEY, AGENT_SECRET); + + // 注册 Agent + (async () => { + try { + await client.register(); + ctx.logger.info(`[dsh-mail-bridge] 已接入 ${GW},身份 ${AGENT_NAME}`); + } catch (e: any) { + ctx.logger.error(`[dsh-mail-bridge] 注册失败: ${e?.message || e}`); + } + })(); + + // ─── 心跳 + 平台会话上报 ─── + // + // 心跳两个职责: + // 1. 保活 —— Gateway 靠 last_seen 判在线,不发心跳就会被当成离线(之前真的没发) + // 2. 上报平台侧会话快照 —— 写信时想续谈某条会话,得先知道那个工作区下 + // 有哪些会话可续;Gateway 只看得见邮件驱动的那部分。 + // + // 上报而不是让 Gateway 反向拉取:当前架构是单向的(Agent 持密钥主动连 + // Gateway,Gateway 从不外呼),反向拉取需要它保存各平台的地址与凭证。 + + /** + * 收集本机 DSH 的会话快照。 + * + * 优先用 ctx.sessionQuery(包含已落盘的历史会话),它不可用时退到 + * ctx.agents.list()(只有当前活着的)。base bundle 里 session-query-sqlite 的 + * openAt 是 'never',但 listSessions/readTitle 这些精确读不依赖 SQLite + * —— 只有全文搜索会报 SESSION_QUERY_SEARCH_DISABLED。 + * + * 返回 undefined 表示「本次拿不到列表」,调用方应当省略字段而不是传空数组: + * 空数组的语义是「平台侧确实一条会话都没有」,会把服务端的镜像抹掉。 + */ + async function collectSessions(): Promise { + const q = ctx.get('sessionQuery'); + if (q?.listSessions) { + try { + const records = await q.listSessions(); + const ids = records + .map((r: any) => r?.header?.id) + .filter((id: any): id is string => typeof id === 'string'); + // 标题在日志里,需要单独 fold。批量读而不是逐个读: + // readTitleSnapshots 共用一次 corpus 观测,而逐个 readTitle 会重复加载日志。 + const titles = new Map(); + if (q.readTitleSnapshots && ids.length > 0) { + const results = await q.readTitleSnapshots(ids); + results.forEach((res: any, i: number) => { + // 单个会话读失败不该拘到其他会话(接口本身就是逐条隔离的) + if (res?.status === 'rejected') return; + const title = res?.value?.title?.title ?? res?.title?.title; + if (typeof title === 'string' && title) titles.set(ids[i], title); + }); + } + return records.map((r: any) => ({ + id: r?.header?.id, + cwd: r?.header?.cwd ?? '', + title: titles.get(r?.header?.id) ?? '', + updatedAt: r?.header?.createdAt, + // subagent 子会话要被过滤掉,判据在 header 上 + origin: r?.header?.origin, + delegationDepth: r?.header?.delegationDepth, + })); + } catch (e: any) { + ctx.logger.warn(`[dsh-mail-bridge] sessionQuery 不可用,退到活会话列表: ${e?.message || e}`); + } + } + + // 退路:只报当前活着的 agent。比什么都不报强 —— + // 它们恰好是正在进行的任务,也是最可能被续谈的那批。 + try { + const live = ctx.agents?.list?.() ?? []; + return live.map((a: any) => ({ + id: String(a?.id ?? ''), + cwd: a?.session?.header?.cwd ?? '', + title: modelTitle(a?.session?.events ?? []), + updatedAt: a?.session?.header?.createdAt, + origin: a?.session?.header?.origin, + delegationDepth: a?.session?.header?.delegationDepth, + })); + } catch { + return undefined; + } + } + + async function beat(): Promise { + let body: Record = {}; + const entries = await collectSessions(); + if (entries) { + body = { + platform_sessions: snapshotDshSessions(entries, (id) => mailDrivenSessions.has(id)), + }; + } + try { + await client.post('/agent/heartbeat', body); + } catch { + // 心跳失败不报错:网络抖动很常见,下一轮会补上。 + // 真的持续连不上时 Gateway 会把它判成离线,那才是可见的信号。 + } + } + + ctx.effect(() => { + void beat(); + const timer = setInterval(() => { void beat(); }, 30_000); + return () => clearInterval(timer); + }, 'dsh-mail-bridge.heartbeat'); + + // ─── 获取默认模型 ─── + + function modelSelection(): { provider: string; model: string } | undefined { + if (REPLY_PROVIDER && REPLY_MODEL) { + return { provider: REPLY_PROVIDER, model: REPLY_MODEL }; + } + const defaults = ctx.get('agentDefaultModel'); + const sel = defaults?.currentSelection?.(); + if (sel?.provider && sel.model) { + return { provider: sel.provider, model: sel.model }; + } + return undefined; + } + + // ─── 投递邮件到 DSH 会话 ─── + + async function deliverMail(data: any, kind: string): Promise<{ sessionID: string; reused: boolean }> { + const mailSessionID = data.session_id; + const existing = mailSessionID ? sessionMap.get(mailSessionID) : undefined; + + if (existing) { + const live = ctx.agents.get(existing.dshSessionId); + if (live) { + const promptText = kind === 'permission' + ? `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || '用户'})。请据此继续后续工作。` + : [ + `本会话收到一封新邮件(AgentMail 续谈)。`, + ``, + `发件人:${data.from_name || 'unknown'}`, + `主题:${data.subject || '(无主题)'}`, + `邮件 ID:${data.mail_id || 'unknown'}`, + ``, + `请先调用 read_inbox 读取完整正文,然后处理其中的请求。`, + `回信不用你自己发:把这一轮做完、把结论说出来就行。`, + ].join('\n'); + live.followup(userMessage(promptText)); + return { sessionID: existing.dshSessionId, reused: true }; + } + } + + // 新开会话 + const sessionId = `mail-${mailSessionID || Date.now()}`; + // cwd 取寻址里的 path 位(to_workspace)。 + // + // 之前这里硬拼 `~/.dsh/mail-sessions/mail-` —— 每封邮件一个全新的空目录。 + // DSH 按 cwd 给会话分组,于是所有邮件会话既不属于任何项目、彼此也不同组, + // 界面上全落进「未分组」。path 位本来就是「希望它在哪儿干活」。 + const { cwd, grouped } = resolveWorkspaceCwd(data.to_workspace, sessionId); + ensureCwd(cwd, grouped); + if (!grouped && data.to_workspace) { + ctx.logger.warn( + `[dsh-mail-bridge] 工作目录 ${data.to_workspace} 不可用,回退到 ${cwd}`); + } + + const selection = modelSelection(); + const agentOpts = selection + ? { provider: selection.provider, model: selection.model } + : {}; + + const promptText = kind === 'permission' + ? `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || '用户'})。请据此继续。` + : [ + `你收到一封新邮件(AgentMail)。`, + ``, + `发件人:${data.from_name || 'unknown'}`, + `主题:${data.subject || '(无主题)'}`, + `邮件 ID:${data.mail_id || 'unknown'}`, + `身份:你是 ${AGENT_NAME}`, + ``, + `请先调用 read_inbox 读取完整正文(附带附件清单,如有附件可用 download_attachment 取回),然后处理其中的请求。`, + ``, + `**回信不用你自己发**:你把本轮工作做完、把结论正常说出来就行,`, + `插件会在这一轮结束时自动把你最后那段话作为回信发回给 ${data.from_name || '发件人'}(不消耗你的发信配额)。`, + `只有在需要主动联系其他人、或要带附件时才调用 send_mail。`, + ].join('\n'); + + const handle = await ctx.agents.create({ + sessionId, + meta: { cwd }, + agentOptions: agentOpts, + // setup 留空:DSH 的 base bundle 已经注册了 agent-loop、llm、tools 等服务。 + // modelSelection 通过 agentOptions 传入即可 —— 挂载 preset 或 + // installModelSelection 反而会让 turn 崩溃(实测)。 + setup: undefined, + }); + + if (mailSessionID) { + sessionMap.set(mailSessionID, { dshSessionId: sessionId, directory: cwd }); + reverseMap.set(sessionId, mailSessionID); + mailDrivenSessions.add(sessionId); + mailContexts.set(mailSessionID, { + replyTo: data.from_name || '', + subject: data.subject || '', + mailID: data.mail_id || '', + }); + } + + handle.agent.followup(userMessage(promptText)); + return { sessionID: sessionId, reused: false }; + } + + // ─── SSE 监听(与 opencode-mail-bridge 相同的 fetch + reader 模式)─── + + let sseAbort: AbortController | null = null; + + function startSSE(onEvent: (type: string, data: any) => void) { + sseAbort?.abort(); + sseAbort = new AbortController(); + + const reconnect = () => { + if (sseAbort?.signal.aborted) return; + + fetch(`${GW}/api/v1/events/stream`, { + headers: client.authHeaders(), + signal: sseAbort?.signal ?? new AbortController().signal, + }).then((res) => { + const reader = res.body?.getReader(); + if (!reader) return; + const decoder = new TextDecoder(); + let buf = ''; + + const read = () => { + reader.read().then(({ done, value }) => { + if (done) { setTimeout(reconnect, 3000); return; } + buf += decoder.decode(value, { stream: true }); + const lines = buf.split('\n'); + buf = lines.pop() || ''; + let evt = '', data = ''; + for (const line of lines) { + if (line.startsWith('event: ')) evt = line.slice(7).trim(); + else if (line.startsWith('data: ')) data = line.slice(6); + else if (line === '' && evt) { + try { onEvent(evt, JSON.parse(data)); } catch {} + evt = ''; data = ''; + } + } + read(); + }).catch(() => setTimeout(reconnect, 5000)); + }; + read(); + }).catch(() => setTimeout(reconnect, 5000)); + }; + + reconnect(); + } + + // ─── 注册模型工具 ─── + + ctx.effect(() => { + // send_mail + ctx.tools.register(defineTool({ + name: 'send_mail', + description: '发送邮件。三维地址 name@path.session:省略 session 投递到默认会话,.new 强制新建,.具体别名 必须已存在。回复来信请传 reply_to。', + parameters: { + to: { type: 'string', required: true, description: '收件人三维地址' }, + subject: { type: 'string', required: true, description: '邮件主题' }, + body: { type: 'string', required: true, description: '邮件正文(Markdown)' }, + cc: { type: 'string', description: '抄送,逗号分隔多个三维地址' }, + reply_to: { type: 'string', description: '回复某封邮件时传其 mail_id' }, + session_alias: { type: 'string', description: '给新会话命名' }, + attachment_ids: { type: 'array', items: { type: 'string' }, description: '附件 ID 列表' }, + }, + 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/send', { + to: args.to, subject: args.subject, body: args.body, + cc: args.cc || '', reply_to: args.reply_to || '', + session_alias: args.session_alias || '', + attachment_ids: args.attachment_ids || [], + }); + noteExplicitSend(toolCtx?.sessionID, args.to, args.reply_to); + const budget = typeof result.budget_remaining === 'number' + ? ` 本任务剩余 ${result.budget_remaining}/${result.budget_max} 个来回。` : ''; + return `邮件已发送(ID: ${result.mail_id})${budget}`; + }, + })); + + // read_inbox + ctx.tools.register(defineTool({ + name: 'read_inbox', + description: '读取收件箱邮件列表。返回最新的邮件,每封含 mail_id、发件人、主题、正文、附件清单。', + parameters: { + status: { type: 'string', description: '过滤状态(all/unread/read)' }, + limit: { type: 'number', description: '返回数量上限' }, + }, + output: { + schema: { type: 'string' }, + render: (_args: any, value: string) => [{ type: 'text', text: value }], + }, + async execute(args: any): Promise { + const { mails } = await client.get( + `/mail/inbox?status=${args.status || 'all'}&limit=${args.limit || 20}` + ); + if (!mails?.length) return '收件箱为空。'; + return mails.map((m: any) => { + const att = m.attachments?.length + ? ` [附件: ${m.attachments.map((a: any) => a.filename).join(', ')}]` : ''; + return `- ID: ${m.mail_id} | ${m.from_name} | ${m.subject}${att}\n ${m.body.slice(0, 200)}`; + }).join('\n'); + }, + })); + + // upload_attachment + ctx.tools.register(defineTool({ + name: 'upload_attachment', + description: '上传本地文件作为邮件附件,返回 attachment_id。', + parameters: { + file_path: { type: 'string', required: true, description: '本地文件路径' }, + }, + output: { + schema: { type: 'string' }, + render: (_args: any, value: string) => [{ type: 'text', text: value }], + }, + async execute(args: any): Promise { + const data = await readFile(args.file_path); + const filename = args.file_path.split('/').pop() || 'file'; + const res = await fetch(`${client.baseURL}/api/v1/attachments`, { + method: 'POST', + headers: { ...client.authHeaders(), 'Content-Type': 'application/octet-stream', 'X-Filename': filename }, + body: data, + }); + const json = await res.json() as any; + if (!res.ok) throw new Error(json?.error || `HTTP ${res.status}`); + const a = json.attachment; + return `已上传 ${a.filename}(${a.size_bytes} 字节)。attachment_id: ${a.attachment_id}`; + }, + })); + + // download_attachment + ctx.tools.register(defineTool({ + name: 'download_attachment', + description: '下载邮件附件到本地文件。', + parameters: { + attachment_id: { type: 'string', required: true, description: '附件 ID' }, + save_path: { type: 'string', required: true, description: '保存路径' }, + }, + output: { + schema: { type: 'string' }, + render: (_args: any, value: string) => [{ type: 'text', text: value }], + }, + async execute(args: any): Promise { + const res = await fetch(`${client.baseURL}/api/v1/attachments/${args.attachment_id}`, { + headers: client.authHeaders(), + }); + if (!res.ok) throw new Error(`下载失败: HTTP ${res.status}`); + const buf = Buffer.from(await res.arrayBuffer()); + await writeFile(args.save_path, buf); + return `已保存到 ${args.save_path}(${buf.length} 字节)`; + }, + })); + + return () => { + for (const n of ['send_mail', 'read_inbox', 'upload_attachment', 'download_attachment']) { + try { ctx.tools.unregister(n); } catch {} + } + }; + }, 'dsh-mail-bridge.tools'); + + // ─── turn 完成后自动转发回复 ─── + // + // 与 opencode-mail-bridge 的 session.idle 同一职责:模型把话说完了, + // 插件把它最后那段话搬到邮件里 —— 不该让模型自己记得调 send_mail。 + ctx.on('agent/status', async (payload: any) => { + if (payload?.status !== 'idle') return; + const agent = payload.agent; + if (!agent?.id) return; + const mailSessionID = reverseMap.get(String(agent.id)); + if (!mailSessionID) return; + const mctx = mailContexts.get(mailSessionID); + if (!mctx?.replyTo) return; + + // 取最后一条 assistant 消息的文本 + const events = agent.session?.events ?? []; + const lastText = lastAssistantText(events); + if (!lastText) return; + + // 本轮模型已亲手回过这条线索 → 不再自动转发(与 opencode 侧同一取舍) + if (shouldSkipAutoRelay(explicitSends.get(String(agent.id)), mctx.replyTo, mctx.mailID)) { + explicitSends.delete(String(agent.id)); + return; + } + // 同一条消息只转一次 + if (relayedSummaries.get(String(agent.id)) === lastText) return; + + try { + await client.post('/mail/send', { + to: mctx.replyTo, + subject: replySubject(mctx.subject), + body: lastText, + reply_to: mctx.mailID || '', + // relay + relay_key:走免配额通道(harness 的搬运不该收费) + relay: 'summary', + relay_key: `${agent.id}:${events.length}`, + }); + relayedSummaries.set(String(agent.id), lastText); + explicitSends.delete(String(agent.id)); + ctx.logger.info(`[dsh-mail-bridge] 总结已回信 ${mctx.replyTo}(不计配额)`); + } catch (e: any) { + ctx.logger.error(`[dsh-mail-bridge] 转发回复失败: ${e?.message || e}`); + } + + // 会话命名回写:DSH 首轮结束后由模型生成摘要标题,把它同步回 AgentMail。 + // 与 opencode 侧同一决定:不另造一套命名,平台叫什么这边就叫什么。 + // + // DSH 没有 opencode 那样的 slug,别名由标题派生(slugFromTitle 会去掉 + // `.` `@` `/` 这些寻址分隔符 —— 留在别名里会让它自己被解析器切开)。 + // 别名与标题一起发:服务端撞名时自动追 -2/-3 后缀,并尊重人工改过的别名。 + const title = modelTitle(events); + if (title && syncedTitles.get(String(agent.id)) !== title) { + syncedTitles.set(String(agent.id), title); + const alias = slugFromTitle(title); + try { + await client.post(`/sessions/${mailSessionID}/sync`, + alias ? { title, alias } : { title }); + ctx.logger.info( + `[dsh-mail-bridge] 会话命名已同步: title=${title}${alias ? ` alias=${alias}` : ''}`); + } catch (e: any) { + ctx.logger.error(`[dsh-mail-bridge] 命名同步失败: ${e?.message || e}`); + } + } + }); + + // ─── 平台原生权限询问 → 转成邮件问人 ─── + // + // 这是 harness 的职责,不该让模型自己调一个 request_permission 工具: + // 模型可能忘了调,也可能在不需要时乱调,而真正被 DSH 拦下的那次询问反而没人看见。 + // + // 与 opencode 的差异:opencode 的 permission.ask 是同步钩子,卡在里面会把整个 + // 请求挂住,所以那边只能“转出去就返回 ask”;而 DSH 的 approval/request 是 + // **异步 waterfall**,返回 Promise 就是它要的,因此可以真的等人。 + ctx.on('approval/request', async (req: any, next: () => Promise) => { + const agentId = String(req?.agent?.id ?? ''); + if (!mailDrivenSessions.has(agentId)) return next(); // 非邮件驱动的会话不接管 + const mailSessionID = reverseMap.get(agentId); + if (!mailSessionID) return next(); + + // DSH 不给询问发 id,用 (会话, 工具, callId) 做幂等键。 + const relayKey = `${agentId}:${req.toolName}:${req.callId ?? 'nocall'}`; + + try { + await client.post('/permission/request', { + question: `请求执行 ${req.toolName}`, + options: ['同意', '拒绕'], + context: [ + `工具:${req.toolName}`, + req.callId ? `调用 ID:${req.callId}` : '', + req.reason ? `理由:${req.reason}` : '', + ].filter(Boolean).join('\n'), + session_id: mailSessionID, + relay_key: relayKey, + }); + } catch (e: any) { + // 转不出去就别把 DSH 挂在那儿等:交给下一个 answerer(本地 UI)接管。 + ctx.logger.error(`[dsh-mail-bridge] 权限询问转发失败: ${e?.message || e}`); + return next(); + } + + ctx.logger.info(`[dsh-mail-bridge] 权限询问已转邮件 ${relayKey}`); + + // 等人类决策;DSH 撤销询问(signal abort)时结算为 cancelled。 + return new Promise((resolve) => { + pendingApprovals.set(relayKey, { resolve, sessionId: agentId }); + req.signal?.addEventListener('abort', () => { + if (pendingApprovals.delete(relayKey)) resolve('cancelled'); + }, { once: true }); + }); + }); + + /** 人类决策回来:先看是不是在等的那条 approval,否则当普通通知投给会话。 */ + function handlePermissionDecision(data: any): void { + const relayKey = String(data?.relay_key ?? ''); + const pending = relayKey ? pendingApprovals.get(relayKey) : undefined; + if (pending) { + pendingApprovals.delete(relayKey); + // AgentMail 的选项文本 → DSH 的 ApprovalOutcome。 + // 只有“同意”才放行,其余(包括认不出的选项)一律 fail closed。 + const decision = String(data?.decision ?? ''); + const outcome = /^(同意|allow|approve|yes)/i.test(decision) ? 'allowed-once' : 'rejected'; + pending.resolve(outcome); + ctx.logger.info(`[dsh-mail-bridge] 权限决策 ${relayKey} -> ${outcome}`); + return; + } + // 没在等(插件重启后丢了 pendingApprovals,或历史数据)→ 当一封通知投进会话。 + deliverMail(data, 'permission') + .catch((e: any) => ctx.logger.error(`[dsh-mail-bridge] 权限决策投递失败: ${e?.message || e}`)); + } + + // ─── 启动 SSE(与 opencode-mail-bridge 相同的事件处理)─── + + ctx.effect(() => { + startSSE((type, data) => { + switch (type) { + case 'new_mail': + deliverMail(data, 'mail') + .then(({ sessionID, reused }) => { + console.error(`[dsh-mail-bridge] ${type} -> ${reused ? '续谈' : '新会话'} ${sessionID}`); + }) + .catch((e: any) => { + ctx.logger.error(`[dsh-mail-bridge] ${type} 处理失败: ${e?.message || e}`); + }); + break; + case 'permission_decision': + handlePermissionDecision(data); + break; + } + }); + return () => { + sseAbort?.abort(); + sseAbort = null; + // 拆插件时没人再能回答待决询问,一律 fail closed, + // 否则 DSH 侧那些 await 永远不会返回。 + for (const [key, pending] of pendingApprovals) { + pending.resolve('unavailable'); + pendingApprovals.delete(key); + } + }; + }, 'dsh-mail-bridge.sse'); +} diff --git a/plugins/dsh-mail-bridge/test/message.test.mjs b/plugins/dsh-mail-bridge/test/message.test.mjs new file mode 100644 index 0000000..1a338ed --- /dev/null +++ b/plugins/dsh-mail-bridge/test/message.test.mjs @@ -0,0 +1,179 @@ +/** + * dsh-mail-bridge 纯函数测试。 + * + * 重点不是覆盖率,而是钉住几条「错了不当场报错」的约定: + * 1. userMessage() 的形状 —— 传错会在 agent-loop 深处抛一个不指向调用点的 + * `Cannot read properties of undefined (reading 'kind')`,实测卡了一下午 + * 2. modelTitle() 必须拒绝 fallback 占位标题 —— 否则会把插件自己的提示词 + * 「你收到一封新邮件(AgentMail)」当成会话标题回写给 AgentMail + * 3. lastAssistantText() 只取 text 块 —— reasoning 是思考过程,不该进邮件 + * + * node --test test/ + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + userMessage, + stripRe, + replySubject, + lastAssistantText, + modelTitle, +} from '../lib/message.js'; + +// ─── userMessage:DSH followup() 的唯一合法形状 ─── + +test('userMessage 产出 content + source 两个字段', () => { + const m = userMessage('你好'); + assert.deepEqual(m, { + content: [{ type: 'text', text: '你好' }], + source: { kind: 'user' }, + }); +}); + +test('不变量:userMessage 必须带 source.kind —— agent-loop 的 preStep 直接读它', () => { + // 这一条是本文件存在的理由。少了 source.kind,DSH 抛的错落在 agent-loop 内部 + // (`Cannot read properties of undefined (reading 'kind')`),既不指向调用点, + // 也不说是哪个字段,turn 会一 start 就 end、模型请求根本不发出去。 + for (const text of ['x', '', '多行\n文本', '🙂']) { + const m = userMessage(text); + assert.equal(typeof m.source?.kind, 'string', 'source.kind 必须是字符串'); + assert.equal(m.source.kind, 'user'); + assert.ok(Array.isArray(m.content), 'content 必须是数组'); + } +}); + +test('不变量:userMessage 返回的不是裸数组(opencode 的 parts 形状)', () => { + // opencode 的 promptAsync 收 parts 数组,DSH 收完整 UserMessage。 + // 把 opencode 的写法照抄过来正是那次故障的起因。 + const m = userMessage('x'); + assert.ok(!Array.isArray(m), 'followup() 不接受裸数组'); +}); + +test('userMessage 把非字符串转成字符串', () => { + assert.equal(userMessage(42).content[0].text, '42'); +}); + +// ─── stripRe / replySubject ─── + +test('stripRe 去掉单个与叠加的 Re: 前缀', () => { + assert.equal(stripRe('Re: 主题'), '主题'); + assert.equal(stripRe('Re: Re: Re: 主题'), '主题'); + assert.equal(stripRe('RE: 主题'), '主题'); + assert.equal(stripRe('主题'), '主题'); +}); + +test('stripRe 不动正文里的 Re:', () => { + assert.equal(stripRe('关于 Re: 这个写法'), '关于 Re: 这个写法'); +}); + +test('replySubject 只加一层 Re:', () => { + assert.equal(replySubject('缓存选型'), 'Re: 缓存选型'); + assert.equal(replySubject('Re: 缓存选型'), 'Re: 缓存选型'); + assert.equal(replySubject('Re: Re: 缓存选型'), 'Re: 缓存选型'); +}); + +test('replySubject 空主题走兜底而不是产出裸 "Re: "', () => { + assert.equal(replySubject(''), 'DSH 回复'); + assert.equal(replySubject(' '), 'DSH 回复'); + assert.equal(replySubject(undefined), 'DSH 回复'); + assert.equal(replySubject('', '自定义'), '自定义'); +}); + +// ─── lastAssistantText ─── + +const assistantMsg = (blocks) => ({ + type: 'assistant/message', + data: { message: { role: 'assistant', content: blocks } }, +}); + +test('lastAssistantText 取最后一条 assistant 消息', () => { + const events = [ + assistantMsg([{ type: 'text', text: '第一轮' }]), + { type: 'tool/call', data: {} }, + assistantMsg([{ type: 'text', text: '第二轮' }]), + ]; + assert.equal(lastAssistantText(events), '第二轮'); +}); + +test('lastAssistantText 丢掉 reasoning 块', () => { + const events = [assistantMsg([ + { type: 'reasoning', text: '让我想想……用户要的是' }, + { type: 'text', text: '结论:可以。' }, + ])]; + assert.equal(lastAssistantText(events), '结论:可以。'); +}); + +test('lastAssistantText 拼接多个 text 块', () => { + const events = [assistantMsg([ + { type: 'text', text: '第一段' }, + { type: 'text', text: '第二段' }, + ])]; + assert.equal(lastAssistantText(events), '第一段\n第二段'); +}); + +test('lastAssistantText 只有 tool-call 时返回空串(没有可回信的内容)', () => { + const events = [assistantMsg([ + { type: 'reasoning', text: '先读收件箱' }, + { type: 'tool-call', id: 'c1', name: 'read_inbox' }, + ])]; + assert.equal(lastAssistantText(events), ''); +}); + +test('lastAssistantText 容错:空日志、非数组、结构缺失', () => { + assert.equal(lastAssistantText([]), ''); + assert.equal(lastAssistantText(undefined), ''); + assert.equal(lastAssistantText(null), ''); + assert.equal(lastAssistantText([{ type: 'assistant/message' }]), ''); + assert.equal(lastAssistantText([{ type: 'assistant/message', data: {} }]), ''); +}); + +test('lastAssistantText 跳过非 assistant/message 事件', () => { + const events = [ + assistantMsg([{ type: 'text', text: '正文' }]), + { type: 'step/end', data: {} }, + { type: 'turn/end', data: {} }, + ]; + assert.equal(lastAssistantText(events), '正文'); +}); + +// ─── modelTitle ─── + +const titleEvent = (title, kind) => ({ + type: 'session/title', + data: { title, source: { kind } }, +}); + +test('modelTitle 取模型生成的标题', () => { + const events = [titleEvent('处理新邮件任务并回复', 'provider')]; + assert.equal(modelTitle(events), '处理新邮件任务并回复'); +}); + +test('不变量:modelTitle 拒绝 fallback 占位标题', () => { + // DSH 在模型生成真标题之前会先落一个 fallback 标题,内容是用户第一句话的截断。 + // 而「用户第一句话」是插件自己拼的提示词,回写过去等于把 + // 「你收到一封新邮件(AgentMail)」当成会话标题。 + const events = [titleEvent('你收到一封新邮件(AgentMail)', 'fallback')]; + assert.equal(modelTitle(events), ''); +}); + +test('modelTitle 取最后一次 session/title —— fallback 之后的 provider 标题算', () => { + const events = [ + titleEvent('你收到一封新邮件(AgentMail)', 'fallback'), + { type: 'assistant/chunk', data: {} }, + titleEvent('缓存层选型评估邮件回复', 'provider'), + ]; + assert.equal(modelTitle(events), '缓存层选型评估邮件回复'); +}); + +test('modelTitle 容错:无标题事件、结构缺失、非字符串', () => { + assert.equal(modelTitle([]), ''); + assert.equal(modelTitle(undefined), ''); + assert.equal(modelTitle([{ type: 'session/title' }]), ''); + assert.equal(modelTitle([{ type: 'session/title', data: {} }]), ''); + assert.equal(modelTitle([{ type: 'session/title', data: { title: 42 } }]), ''); +}); + +test('modelTitle 修掉标题两端空白', () => { + assert.equal(modelTitle([titleEvent(' 带空白的标题 ', 'provider')]), '带空白的标题'); +}); diff --git a/plugins/dsh-mail-bridge/test/session-snapshot.test.mjs b/plugins/dsh-mail-bridge/test/session-snapshot.test.mjs new file mode 100644 index 0000000..d714441 --- /dev/null +++ b/plugins/dsh-mail-bridge/test/session-snapshot.test.mjs @@ -0,0 +1,228 @@ +/** + * 平台会话快照的纯函数测试。 + * + * 这些函数的产出直接决定「写信时能不能选到某条会话」:slug 错了就填出一个 + * 送不到的 session 位(三态语义下会 404),workspace 错了就归到别的工作区去。 + * + * node --test test/ + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + snapshotOpencodeSessions, + snapshotDshSessions, + 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); +}); diff --git a/plugins/dsh-mail-bridge/test/workspace.test.mjs b/plugins/dsh-mail-bridge/test/workspace.test.mjs new file mode 100644 index 0000000..cd3fdcc --- /dev/null +++ b/plugins/dsh-mail-bridge/test/workspace.test.mjs @@ -0,0 +1,112 @@ +/** + * 工作目录解析的回归测试。 + * + * 这是「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 } from '../lib/workspace.js'; + +const fallbackOf = key => join(homedir(), '.dsh', 'mail-sessions', key); + +test('存在的绝对路径直接用作 cwd', () => { + const dir = mkdtempSync(join(tmpdir(), 'ws-test-')); + try { + const got = resolveWorkspaceCwd(dir, '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, 'mail-aaa'); + const b = resolveWorkspaceCwd(dir, 'mail-bbb'); + assert.equal(a.cwd, b.cwd, 'fallbackKey 不同却应得到同一个 cwd'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('path 为空时回退到兜底目录', () => { + const got = resolveWorkspaceCwd('', 'mail-2'); + assert.equal(got.cwd, fallbackOf('mail-2')); + assert.equal(got.grouped, false); +}); + +test('path 缺失/非字符串时回退', () => { + for (const v of [undefined, null, 42, {}]) { + const got = resolveWorkspaceCwd(v, 'mail-3'); + 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', '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, '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, '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} `, '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, 'x'); + assert.equal(got.grouped, true, 'ensureCwd 应已创建该目录'); + + // grouped=true 时不该创建(那种目录本来就存在) + const never = join(base, 'should-not-exist'); + ensureCwd(never, true); + assert.equal(resolveWorkspaceCwd(never, 'x').grouped, false); + } finally { + rmSync(base, { recursive: true, force: true }); + } +}); diff --git a/plugins/dsh-mail-bridge/tsconfig.json b/plugins/dsh-mail-bridge/tsconfig.json new file mode 100644 index 0000000..091fb47 --- /dev/null +++ b/plugins/dsh-mail-bridge/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "declaration": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src"], + "allowJs": true +} diff --git a/plugins/opencode-mail-bridge/index.js b/plugins/opencode-mail-bridge/index.js index be7643e..e844de6 100644 --- a/plugins/opencode-mail-bridge/index.js +++ b/plugins/opencode-mail-bridge/index.js @@ -5,6 +5,7 @@ import { homedir } from "node:os"; import { join, dirname, basename } from "node:path"; // 自动转发去重的纯逻辑放在 lib/ 里:opencode 会把入口模块的每一个导出 // 都当成插件工厂,入口文件多导出一个东西就会 "Plugin export is not a function"。 +import { snapshotOpencodeSessions } from "./lib/session-snapshot.js"; import { explicitSends, noteExplicitSend, @@ -474,10 +475,19 @@ async function resolveSessionForMail(client, directory, data, kind) { const bound = mailSessionID ? sessionMap.get(mailSessionID) : undefined; if (bound) return { sessionID: bound, reused: true }; + // 工作目录取**寻址里的 path 位**,而不是插件启动时那个固定的 directory。 + // + // 三维地址 name@path.session 的 path 就是「希望它在哪儿干活」。用固定的 + // directory 会让所有邮件会话都挤在同一个目录里,与地址写的完全无关; + // 而 opencode 按 directory 归属项目,写错了会话就归到别的项目下。 + const wantDir = typeof data.to_workspace === "string" && data.to_workspace.trim() + ? data.to_workspace.trim() + : directory; + // 故意不传 title:opencode 只在标题缺省时才让模型按首轮对话生成摘要标题, // 传了占位标题就等于掐掉平台自己的命名机制。标题稍后由 session.updated 事件回写。 const created = await client.session.create({ - query: directory ? { directory } : undefined, + query: wantDir ? { directory: wantDir } : undefined, }); const session = created?.data ?? created; const sessionID = session?.id; @@ -704,12 +714,37 @@ export default async function mailBridge(input) { // 所以用一个闭包把 relaySummary 需要的两个参数固定下来。 relaySummaryRef = (sid) => relaySummary(client, directory, sid); - // 心跳。只保活与取待处理邮件数 —— + // 心跳。保活、取待处理邮件数,并上报平台侧的会话快照。 + // // 额度属于具体任务(会话),不属于 Agent,所以这里没有「剩余额度」可报。 // 剩余往返随每次发信响应的 budget_remaining 回传,在那里才有意义。 - const heartbeat = setInterval(() => { - apiPost("/agent/heartbeat", {}).catch(() => {}); - }, 30000); + // + // 会话快照解决的是「工作区下的历史会话在补全里选不到」:Gateway 只看得见 + // 邮件驱动的那部分,人直接在 opencode 界面上开的会话它一无所知。 + // 让插件上报而不是让 Gateway 反向拉取 —— 当前架构是单向的, + // 反向拉取需要 Gateway 保存各平台的地址与凭证。 + async function reportSessions() { + try { + const listed = await client.session.list({ + query: directory ? { directory } : undefined, + }); + const sessions = listed?.data ?? listed ?? []; + return snapshotOpencodeSessions(sessions, (id) => mailDrivenSessions.has(id)); + } catch (e) { + // 拉不到列表就**省略**该字段,而不是传空数组: + // 空数组的语义是「平台侧确实一条会话都没有」,会把服务端的镜像抹掉。 + console.error("[mail-bridge] 会话列表读取失败:", e?.message || e); + return undefined; + } + } + + const beat = async () => { + const platform_sessions = await reportSessions(); + const body = platform_sessions ? { platform_sessions } : {}; + apiPost("/agent/heartbeat", body).catch(() => {}); + }; + beat(); + const heartbeat = setInterval(beat, 30000); startSSE((type, data) => { // 人类决策了一条权限请求 → 回复 opencode 的原生 permission,让它自己恢复执行。 diff --git a/plugins/opencode-mail-bridge/lib/session-snapshot.js b/plugins/opencode-mail-bridge/lib/session-snapshot.js new file mode 100644 index 0000000..e4cbfee --- /dev/null +++ b/plugins/opencode-mail-bridge/lib/session-snapshot.js @@ -0,0 +1,151 @@ +/** + * 平台会话快照:把 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)); +} + +/** 判断一条会话是否为 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 串 → ISO 串;无法解析时返回 undefined。 */ +function toISO(v) { + if (typeof v === 'number' && Number.isFinite(v)) { + return new Date(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/opencode-mail-bridge/package.json b/plugins/opencode-mail-bridge/package.json index 340311d..99a58f8 100644 --- a/plugins/opencode-mail-bridge/package.json +++ b/plugins/opencode-mail-bridge/package.json @@ -11,6 +11,6 @@ "@opencode-ai/plugin": ">=1.15.0" }, "scripts": { - "test": "node test/auto-relay.test.mjs" + "test": "node test/auto-relay.test.mjs && node --test test/session-snapshot.test.mjs" } } diff --git a/plugins/opencode-mail-bridge/test/session-snapshot.test.mjs b/plugins/opencode-mail-bridge/test/session-snapshot.test.mjs new file mode 100644 index 0000000..d714441 --- /dev/null +++ b/plugins/opencode-mail-bridge/test/session-snapshot.test.mjs @@ -0,0 +1,228 @@ +/** + * 平台会话快照的纯函数测试。 + * + * 这些函数的产出直接决定「写信时能不能选到某条会话」:slug 错了就填出一个 + * 送不到的 session 位(三态语义下会 404),workspace 错了就归到别的工作区去。 + * + * node --test test/ + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + snapshotOpencodeSessions, + snapshotDshSessions, + 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); +}); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 0a8686b..ac3598e 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -270,10 +270,9 @@ export async function markMailRead(id: string) { */ export async function getMailThread( id: string, - opts: { dir?: 'around' | 'up' | 'down'; offset?: number; limit?: number } = {} + opts: { offset?: number; limit?: number } = {} ) { const q = new URLSearchParams(); - if (opts.dir) q.set('dir', opts.dir); if (opts.offset !== undefined) q.set('offset', String(opts.offset)); if (opts.limit !== undefined) q.set('limit', String(opts.limit)); const qs = q.toString(); diff --git a/web/src/components/AddressInput.tsx b/web/src/components/AddressInput.tsx index d56e955..5a33819 100644 --- a/web/src/components/AddressInput.tsx +++ b/web/src/components/AddressInput.tsx @@ -1,10 +1,14 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import * as api from '../api/client'; +import type { SessionCandidate } from '../types'; /** * 三段式地址输入:name -> @path -> .session * 每段都向 /contacts/suggest 询问候选,未命中时也允许自由输入。 * 值本身始终是完整字符串 name@path.session。 + * + * session 段的候选带标题与来源标记:一个工作区下可能有十几条会话, + * 光看 brisk-harbor / witty-planet 这类随机短名分不出哪条在谈什么。 */ export default function AddressInput({ value, @@ -22,6 +26,8 @@ export default function AddressInput({ }) { const [open, setOpen] = useState(false); const [items, setItems] = useState([]); + // session 段的富候选,与 items 同序。其他段为空数组。 + const [meta, setMeta] = useState([]); const [kind, setKind] = useState<'name' | 'path' | 'session'>('name'); const [active, setActive] = useState(0); const boxRef = useRef(null); @@ -50,14 +56,29 @@ export default function AddressInput({ if (cancelled) return; const frag = parts.hasDot ? parts.session : parts.hasAt ? parts.path : parts.name; - const filtered = (res.suggestions || []).filter(s => - s.toLowerCase().includes(frag.toLowerCase()) - ); + const lower = frag.toLowerCase(); + const all = res.suggestions || []; + const cands = res.candidates || []; + + // 过滤时保持 suggestions 与 candidates 同序:candidates 是按下标对应的, + // 分别过滤两个数组会让标题错位到别的别名上。 + const keep: number[] = []; + all.forEach((s, i) => { + const c = cands[i]; + // 标题也参与匹配:想找「缓存选型」那条会话时,人记得的是标题而不是随机短名 + const hay = c?.title ? `${s} ${c.title}`.toLowerCase() : s.toLowerCase(); + if (hay.includes(lower)) keep.push(i); + }); + setKind(res.kind); - setItems(filtered); + setItems(keep.map(i => all[i])); + setMeta(cands.length ? keep.map(i => cands[i]).filter(Boolean) : []); setActive(0); } catch { - if (!cancelled) setItems([]); + if (!cancelled) { + setItems([]); + setMeta([]); + } } }; const t = setTimeout(run, 120); @@ -107,7 +128,11 @@ export default function AddressInput({ }; const hint = - kind === 'name' ? 'Agent 名' : kind === 'path' ? '工作区路径' : '会话别名(new 为新建)'; + kind === 'name' + ? 'Agent 名' + : kind === 'path' + ? '工作区路径' + : '会话别名(new 为新建)'; return (
@@ -126,28 +151,58 @@ export default function AddressInput({ /> {open && items.length > 0 && ( -
+
{hint}
- {items.map((s, i) => ( - - ))} + {items.map((s, i) => { + const c = meta[i]; + return ( + + ); + })}
)}
diff --git a/web/src/components/ThreadView.tsx b/web/src/components/ThreadView.tsx index efd85b6..620b832 100644 --- a/web/src/components/ThreadView.tsx +++ b/web/src/components/ThreadView.tsx @@ -5,13 +5,17 @@ import type { ThreadNode } from '../types'; import { CloseIcon, PaperclipIcon, PersonIcon, BotIcon, ShieldIcon, SpinnerIcon } from './icons'; /** - * 对话树视图(按方向分块加载)。 + * 对话树视图(从线索根整树展开,分块加载)。 * * 树由服务端沿 parent_mail_id 展开,因此**可以跨会话** —— 转发把线索引到新会话, * 但仍属同一条线索。这正是树视图比会话内平铺更有价值的地方:能看出线索分叉去了哪里。 * - * 加载策略:首屏取锚点附近一块,往上滑补祖先、往下滑补子孙,直到两端都取完。 - * 一条线索可以有几百封,一次全取要把几 MB 预览塞给前端。 + * 展开的起点是**线索的根**而不是当前这封。早先的实现是「锚点的祖先链 + 锚点的子树」 + * 两个方向各自分页,结果兄弟节点整条分支都在盲区里:一封抄送给两个 Agent 的邮件 + * 会收到两个回复,它们互为兄弟,从其中一个看树永远看不到另一个;挂在原件上的 + * 转发分支同理。从根 BFS 之后,兄弟、抄送产生的平行回复、转发分支都是根的子孙。 + * + * 只剩一个加载方向(往后翻),因此不需要滚动位置补偿 —— 新内容追加在末尾。 * * 不用 react-d3-tree 之类的图形库:这里的树又浅又窄(邮件往来通常是一条主链 * 加几个转发分支),缩进 + 连接线足够表达层级,还能直接复用列表的交互与样式, @@ -20,29 +24,32 @@ import { CloseIcon, PaperclipIcon, PersonIcon, BotIcon, ShieldIcon, SpinnerIcon export default function ThreadView({ mailID, onClose }: { mailID: string; onClose: () => void }) { const [nodes, setNodes] = useState([]); const [hidden, setHidden] = useState(0); - const [moreUp, setMoreUp] = useState(false); - const [moreDown, setMoreDown] = useState(false); - const [nextUp, setNextUp] = useState(0); - const [nextDown, setNextDown] = useState(0); + const [hasMore, setHasMore] = useState(false); + const [nextOffset, setNextOffset] = useState(0); const [err, setErr] = useState(''); const [initial, setInitial] = useState(true); const [loading, setLoading] = useState(false); const scrollRef = useRef(null); - const topSentinel = useRef(null); const bottomSentinel = useRef(null); + const anchorRef = useRef(null); // 请求代次:mailID 变了就作废在飞的响应,避免慢请求后到覆盖新线索 const gen = useRef(0); - // loading 的同步副本。setState 是异步的,两个 sentinel 同时进入视口时 - // 读 state 会双双看到 false 而并发发两个请求。 + // loading 的同步副本。setState 是异步的,哨兵连续进入视口时 + // 读 state 会看到旧的 false 而并发发两个请求。 const busy = useRef(false); + const sortNodes = (list: ThreadNode[]) => + list + .slice() + .sort((a, b) => + a.depth - b.depth || a.created_at.localeCompare(b.created_at) + ); + const merge = useCallback((incoming: ThreadNode[]) => { setNodes(prev => { const seen = new Set(prev.map(n => n.mail_id)); - const added = incoming.filter(n => !seen.has(n.mail_id)); - // 按相对深度排;同深度保持服务端给的时间序 - return [...prev, ...added].sort((a, b) => a.depth - b.depth); + return sortNodes([...prev, ...incoming.filter(n => !seen.has(n.mail_id))]); }); }, []); @@ -55,15 +62,13 @@ export default function ThreadView({ mailID, onClose }: { mailID: string; onClos setInitial(true); busy.current = true; api - .getMailThread(mailID, { dir: 'around', limit: 40 }) + .getMailThread(mailID, { offset: 0, limit: 60 }) .then(p => { if (gen.current !== myGen) return; - setNodes(p.nodes.slice().sort((a, b) => a.depth - b.depth)); + setNodes(sortNodes(p.nodes)); setHidden(p.hidden); - setMoreUp(p.has_more_up); - setMoreDown(p.has_more_down); - setNextUp(p.next_up); - setNextDown(p.next_down); + setHasMore(p.has_more); + setNextOffset(p.next_offset); }) .catch(e => { if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败'); @@ -76,85 +81,55 @@ export default function ThreadView({ mailID, onClose }: { mailID: string; onClos }); }, [mailID]); - const loadMore = useCallback( - async (dir: 'up' | 'down') => { - if (busy.current) return; - if (dir === 'up' && !moreUp) return; - if (dir === 'down' && !moreDown) return; + // 首屏渲染完把当前这封滚进视野。长线索里锚点可能在几十封之后, + // 不滚过去的话用户点开一封邮件却停在整条线索的开头。 + useEffect(() => { + if (initial || !anchorRef.current) return; + anchorRef.current.scrollIntoView({ block: 'center' }); + }, [initial]); - const myGen = gen.current; - busy.current = true; - setLoading(true); + const loadMore = useCallback(async () => { + if (busy.current || !hasMore) return; + const myGen = gen.current; + busy.current = true; + setLoading(true); + try { + const p = await api.getMailThread(mailID, { offset: nextOffset, limit: 60 }); + if (gen.current !== myGen) return; + merge(p.nodes); + setHidden(h => h + p.hidden); + setHasMore(p.has_more); + setNextOffset(p.next_offset); + } catch (e) { + if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败'); + } finally { + if (gen.current === myGen) setLoading(false); + busy.current = false; + } + }, [mailID, merge, hasMore, nextOffset]); - // 往上加载会在列表顶部插入内容,浏览器保持 scrollTop 不变 → 视觉上内容整体跳走。 - // 记住加载前的「滚动高度」,加载后按增量补偿,让用户视线停在原处。 - const el = scrollRef.current; - const beforeHeight = el?.scrollHeight ?? 0; - const beforeTop = el?.scrollTop ?? 0; - - try { - const p = await api.getMailThread(mailID, { - dir, - offset: dir === 'up' ? nextUp : nextDown, - limit: 40 - }); - if (gen.current !== myGen) return; - merge(p.nodes); - setHidden(h => h + p.hidden); - if (dir === 'up') { - setMoreUp(p.has_more_up); - setNextUp(p.next_up); - } else { - setMoreDown(p.has_more_down); - setNextDown(p.next_down); - } - if (dir === 'up' && el) { - // 等这批节点真正渲染出来再补偿,否则读到的还是旧高度 - requestAnimationFrame(() => { - el.scrollTop = beforeTop + (el.scrollHeight - beforeHeight); - }); - } - } catch (e) { - if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败'); - } finally { - if (gen.current === myGen) setLoading(false); - busy.current = false; - } - }, - [mailID, merge, moreUp, moreDown, nextUp, nextDown] - ); - - // 两端各一个哨兵,进入视口就加载对应方向。 - // rootMargin 提前 200px 触发,让加载在用户滑到边界前完成。 + // 底部哨兵进入视口就续取。rootMargin 提前 200px 触发, + // 让加载在用户滑到边界前完成。 useEffect(() => { const root = scrollRef.current; if (!root) return; const obs = new IntersectionObserver( entries => { - for (const e of entries) { - if (!e.isIntersecting) continue; - if (e.target === topSentinel.current) loadMore('up'); - if (e.target === bottomSentinel.current) loadMore('down'); - } + for (const e of entries) if (e.isIntersecting) loadMore(); }, { root, rootMargin: '200px' } ); - if (topSentinel.current) obs.observe(topSentinel.current); if (bottomSentinel.current) obs.observe(bottomSentinel.current); return () => obs.disconnect(); }, [loadMore]); - // 相对深度可能是负数(祖先);缩进按「最浅的那个」归零, - // 否则祖先未加载时首屏内容会整体缩进一大截。 - const baseDepth = nodes.length > 0 ? Math.min(...nodes.map(n => n.depth)) : 0; - return (
对话树 已加载 {nodes.length} 封 - {(moreUp || moreDown) && ',滑动加载更多'} + {hasMore && ',滑动加载更多'} {hidden > 0 && `,${hidden} 封无权查看`}
@@ -179,28 +154,23 @@ export default function ThreadView({ mailID, onClose }: { mailID: string; onClos {!initial && ( <> -
- {moreUp && ( - - )} -
{nodes.map(n => ( - + ))}
- {moreDown && ( + {hasMore && ( )}
@@ -213,18 +183,22 @@ export default function ThreadView({ mailID, onClose }: { mailID: string; onClos function Node({ node, - baseDepth, - anchorID + anchorID, + anchorRef }: { node: ThreadNode; - baseDepth: number; anchorID: string; + anchorRef?: React.RefObject; }) { const openMailByID = useMailStore(s => s.openMailByID); const isPermission = node.mail_type === 'permission_request'; const isAnchor = node.mail_id === anchorID; + const ccCount = node.cc_list?.length ?? 0; + // 转发是一条新线索:主题带 Fwd: 前缀,且落在别的会话里。 + // 树里把它标出来,否则一个分支为什么突然换了收件人无从判断。 + const isForward = node.subject.startsWith('Fwd: '); // 缩进上限 8 级,再深就不缩了 —— 否则长链条会把卡片挤成竖条 - const indent = Math.min(Math.max(node.depth - baseDepth, 0), 8) * 20; + const indent = Math.min(Math.max(node.depth, 0), 8) * 20; const time = new Date(node.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', @@ -233,7 +207,7 @@ function Node({ }); return ( -
+
{indent > 0 && (
)} @@ -256,6 +230,11 @@ function Node({ {node.to_name}
+ {isForward && ( + + 转发 + + )} {node.parent_hidden && ( {time}

{node.subject}

+ {/* 抄送人要显示出来:一封邮件收到两个回复,正是因为它抄送给了两个人。 + 不显示抄送,树上那两个兄弟节点为什么并列就没有解释。 */} + {ccCount > 0 && ( +

+ 抄送 {node.cc_list.map(c => c.raw || c.name).join('、')} +

+ )} {node.body_preview && (

{node.body_preview}

)} diff --git a/web/src/types/index.ts b/web/src/types/index.ts index afd658e..a757402 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -114,27 +114,29 @@ export interface Mail { * 分块加载时根可能还没取到,所以不用「距根深度」。 */ export interface ThreadNode extends Omit { + /** 距**线索根**的层级:0 = 根,1 = 它的直接回复 */ depth: number; attachment_count: number; /** 父邮件不在当前已加载集合里(无权查看,或还没滑到) */ detached?: boolean; - /** 父邮件确实存在但无权查看(区别于「尚未加载」,后者会随上滑补齐) */ + /** 父邮件确实存在但无权查看(区别于「尚未加载」) */ parent_hidden?: boolean; body?: string; } export interface ThreadPage { anchor_mail_id: string; - dir: 'around' | 'up' | 'down'; + /** 线索根的 mail_id:整棵树从它展开 */ + root_mail_id: string; + /** 锚点距根的层数,用于高亮定位 */ + anchor_depth: number; nodes: ThreadNode[]; total: number; /** 因权限被过滤掉的节点数 */ hidden: number; - has_more_up: boolean; - has_more_down: boolean; + has_more: boolean; /** 下一页 offset,原样回传即可 */ - next_up: number; - next_down: number; + next_offset: number; } /** @@ -205,9 +207,33 @@ export interface HumanSession { export type SuggestKind = 'name' | 'path' | 'session'; +/** + * 会话候选项的来源。 + * mail 本侧邮件线索 —— 这个别名一定送得到 + * platform 平台侧会话镜像(人直接在 opencode/DSH 界面上开的) + * new 新建会话的哨兵项 + */ +export type SessionCandidateSource = 'mail' | 'platform' | 'new'; + +export interface SessionCandidate { + /** 填进 session 位的值 */ + alias: string; + /** 给人看,用来分辨两条别名相似的会话在谈什么 */ + title?: string; + source: SessionCandidateSource; + /** 仅 mail 来源有意义 */ + unread?: number; +} + export interface SuggestResult { kind: SuggestKind; suggestions: string[]; + /** + * 带标题与来源的完整候选,与 suggestions 同序。 + * 仅 kind === 'session' 时返回;suggestions 保留纯字符串形式是为了 + * 不打破已部署的前端与第三方客户端。 + */ + candidates?: SessionCandidate[]; } /** 系统初始化状态 */