feat: 工作区归属修复 + 平台会话同步 + 对话树整树展开 + DSH 插件

四个各自独立的生产缺陷,共同的根源都是「本该属于会话的属性没有存在会话上」。

## 1. dsh 指定工作目录完全失效(所有会话落进「未分组」)

插件建会话时用的 cwd 是自己拼的 `~/.dsh/mail-sessions/mail-<uuid>` ——
每封邮件一个全新的空目录。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:插件重启丢了待决映射时要靠它定位会话
This commit is contained in:
2026-09-02 20:05:51 +08:00
parent 9d4718a412
commit ca64d12057
43 changed files with 3940 additions and 290 deletions

6
.gitignore vendored
View File

@ -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.jsdeploy/install.sh 负责这一步。
plugins/*/dist/
# go:embed 的输入目录 = web/dist 的副本,同属构建产物。
#
# 但目录本身要留下go:embed 要求它存在才能编译,否则新克隆连

View File

@ -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 同理)—— 新克隆里不先 tscDSH 加载插件时会直接找不到入口。
# 测试本身只碰 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 整个目录:

113
deploy/reset-demo.sh Executable file
View File

@ -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 "需要 rootsudo $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 而不是 cpcp 会漏掉还在 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 "账号与密钥未改动,插件无需重新配置。"

View File

@ -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 但那是终身额度,语义不同,
// 不能直接搬过来当单任务预算。

View File

@ -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);

View File

@ -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 持密钥主动连 GatewayGateway 从不外呼)。让 Gateway 去调平台接口
-- 需要它保存各平台的地址与凭证,那是另一套信任模型,暂不引入。
--
-- 与 sessions 表分开存:这里是**别人家的**会话,其 id 属于平台的 id 空间,
-- 没有本侧的 owner / 预算 / 邮件。混进 sessions 会让每一处
-- 「按会话鉴权」都要先判断这条到底是不是真的本侧会话。
CREATE TABLE IF NOT EXISTS agent_platform_sessions (
agent_name TEXT NOT NULL,
-- 平台侧的会话 idopencode 的 ses_xxx / DSH 的 session id
platform_id TEXT NOT NULL,
-- 平台侧 cwd即三维地址的 path 位
workspace TEXT NOT NULL DEFAULT '',
-- 平台自己的可寻址短名opencode 的 slugDSH 由模型标题派生)
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);

View File

@ -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 持密钥主动连 GatewayGateway 从不外呼)。反向拉取需要 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

View File

@ -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,
})
}

View File

@ -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)
}

View File

@ -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

View File

@ -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 {

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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()
}

View File

@ -0,0 +1,222 @@
package repo
import (
"context"
"strings"
"time"
"github.com/agentmail/gateway/internal/db"
)
// ---------- 平台会话镜像 ----------
//
// Agent 平台自己也在开会话:有些经由邮件驱动,有些是人直接在平台界面上开的。
// 写信时想续谈某条会话,得先知道那个工作区下有哪些会话可续 —— 而 Gateway
// 只看得见邮件驱动的那部分。
//
// **由插件在心跳里上报Gateway 不反向拉取。**
// 当前架构是单向的Agent 持密钥主动连 GatewayGateway 从不外呼);
// 让 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 <mails 反推>)`。
// 反推只看 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
}

View File

@ -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)
}
}

View File

@ -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_workspaceAgent 回信时 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, `

View File

@ -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

View File

@ -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("两个回复都应在深度 1A=%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")
}
}

View File

@ -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

View File

@ -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;

View File

@ -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 '';
}

View File

@ -0,0 +1,14 @@
export interface RelayRecord {
names: Set<string>;
replyTos: Set<string>;
}
export declare const explicitSends: Map<string, RelayRecord>;
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;

View File

@ -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;
}

View File

@ -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;

View File

@ -0,0 +1,151 @@
/**
* 平台会话快照:把 harness 自己的会话列表整理成 Gateway 的上报格式。
*
* 为什么需要它:写信时想续谈某条会话,得先知道那个工作区下有哪些会话可续。
* Gateway 只看得见邮件驱动的那部分 —— 人直接在 opencode/DSH 界面上开的会话
* 它一无所知,于是那些会话的别名在补全里根本不出现,无法选择。
*
* 为什么是插件上报而不是 Gateway 拉取当前架构是单向的Agent 持密钥主动连
* GatewayGateway 从不外呼)。反向拉取需要 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);
}

View File

@ -0,0 +1,6 @@
export function resolveWorkspaceCwd(
workspace: string | undefined,
fallbackKey: string
): { cwd: string; grouped: boolean };
export function ensureCwd(cwd: string, grouped: boolean): void;

View File

@ -0,0 +1,65 @@
/**
* 邮件寻址里的工作目录(三维地址 name@path.session 的 path 位)。
*
* 这个模块存在的理由是一次真实故障:插件建会话时用的 cwd 是自己拼的
* `~/.dsh/mail-sessions/mail-<uuid>` —— 每封邮件一个全新的空目录。
* 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 自己报错,这里不该吞掉真实原因
}
}

102
plugins/dsh-mail-bridge/package-lock.json generated Normal file
View File

@ -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"
}
}
}
}

View File

@ -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"
}
}

View File

@ -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<string, string> {
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<string, unknown>): Promise<any> {
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<any> {
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<void> {
await this.post('/agent/register', {
name: this.agentName,
secret: this.agentSecret || '',
workspaces: [],
platform: 'dsh',
});
}
}
// ─── 会话映射(与 opencode-mail-bridge 相同结构)───
const sessionMap = new Map<string, { dshSessionId: string; directory: string }>();
const reverseMap = new Map<string, string>();
const mailDrivenSessions = new Set<string>();
const mailContexts = new Map<string, { replyTo: string; subject: string; mailID: string }>();
const relayedSummaries = new Map<string, string>();
const syncedTitles = new Map<string, string>();
// 权限询问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<string, PendingApproval>();
// ─── 运行时导入 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 要求插件声明依赖的服务:没有 injectctx.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 持密钥主动连
// GatewayGateway 从不外呼),反向拉取需要它保存各平台的地址与凭证。
/**
* 收集本机 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<any[] | undefined> {
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<string, string>();
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<void> {
let body: Record<string, unknown> = {};
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-<uuid>` —— 每封邮件一个全新的空目录。
// 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<string> {
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<string> {
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<string> {
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<string> {
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<ApprovalOutcome> 就是它要的,因此可以真的等人。
ctx.on('approval/request', async (req: any, next: () => Promise<string>) => {
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<string>((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');
}

View File

@ -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';
// ─── userMessageDSH 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.kindDSH 抛的错落在 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')]), '带空白的标题');
});

View File

@ -0,0 +1,228 @@
/**
* 平台会话快照的纯函数测试。
*
* 这些函数的产出直接决定「写信时能不能选到某条会话」slug 错了就填出一个
* 送不到的 session 位(三态语义下会 404workspace 错了就归到别的工作区去。
*
* 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');
});
// ─── DSHsubagent 过滤与 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);
});

View File

@ -0,0 +1,112 @@
/**
* 工作目录解析的回归测试。
*
* 这是「dsh 指定工作目录完全失效,所有对话都落在未分组下」那次故障的直接回归:
* 插件曾无视寻址里的 path 位,每封邮件自己拼一个 ~/.dsh/mail-sessions/mail-<uuid>
* 而 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 });
}
});

View File

@ -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
}

View File

@ -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;
// 故意不传 titleopencode 只在标题缺省时才让模型按首轮对话生成摘要标题,
// 传了占位标题就等于掐掉平台自己的命名机制。标题稍后由 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让它自己恢复执行。

View File

@ -0,0 +1,151 @@
/**
* 平台会话快照:把 harness 自己的会话列表整理成 Gateway 的上报格式。
*
* 为什么需要它:写信时想续谈某条会话,得先知道那个工作区下有哪些会话可续。
* Gateway 只看得见邮件驱动的那部分 —— 人直接在 opencode/DSH 界面上开的会话
* 它一无所知,于是那些会话的别名在补全里根本不出现,无法选择。
*
* 为什么是插件上报而不是 Gateway 拉取当前架构是单向的Agent 持密钥主动连
* GatewayGateway 从不外呼)。反向拉取需要 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);
}

View File

@ -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"
}
}

View File

@ -0,0 +1,228 @@
/**
* 平台会话快照的纯函数测试。
*
* 这些函数的产出直接决定「写信时能不能选到某条会话」slug 错了就填出一个
* 送不到的 session 位(三态语义下会 404workspace 错了就归到别的工作区去。
*
* 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');
});
// ─── DSHsubagent 过滤与 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);
});

View File

@ -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();

View File

@ -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<string[]>([]);
// session 段的富候选,与 items 同序。其他段为空数组。
const [meta, setMeta] = useState<SessionCandidate[]>([]);
const [kind, setKind] = useState<'name' | 'path' | 'session'>('name');
const [active, setActive] = useState(0);
const boxRef = useRef<HTMLDivElement>(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 (
<div ref={boxRef} className="relative">
@ -126,28 +151,58 @@ export default function AddressInput({
/>
{open && items.length > 0 && (
<div className="absolute z-20 mt-1 w-full max-h-56 overflow-y-auto bg-white border border-gray-200 rounded-md shadow-lg">
<div className="absolute z-20 mt-1 w-full max-h-72 overflow-y-auto bg-white border border-gray-200 rounded-md shadow-lg">
<div className="px-2.5 py-1 text-[10px] text-gray-400 border-b border-gray-100">
{hint}
</div>
{items.map((s, i) => (
<button
key={s}
onMouseDown={e => {
e.preventDefault();
apply(s);
}}
onMouseEnter={() => setActive(i)}
className={`w-full text-left px-2.5 py-1.5 text-sm font-mono ${
i === active ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'
}`}
>
{s}
{kind === 'session' && s === 'new' && (
<span className="ml-2 text-[10px] text-gray-400 font-sans"></span>
)}
</button>
))}
{items.map((s, i) => {
const c = meta[i];
return (
<button
key={s}
onMouseDown={e => {
e.preventDefault();
apply(s);
}}
onMouseEnter={() => setActive(i)}
className={`w-full text-left px-2.5 py-1.5 ${
i === active ? 'bg-blue-50' : 'hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-1.5">
<span
className={`text-sm font-mono truncate ${
i === active ? 'text-blue-700' : 'text-gray-700'
}`}
>
{s}
</span>
<div className="flex-1" />
{/* 平台侧会话本侧还没有邮件线索:标出来,让人知道这一封是「接入」
一条已经在跑的会话,而不是继续一条已有的邮件往来 */}
{c?.source === 'platform' && (
<span
className="shrink-0 px-1 py-0.5 rounded bg-purple-100 text-purple-700 text-[9px]"
title="平台侧已有的会话,本站还没有对应的邮件往来"
>
</span>
)}
{c?.source === 'new' && (
<span className="shrink-0 text-[10px] text-gray-400 font-sans"></span>
)}
{(c?.unread ?? 0) > 0 && (
<span className="shrink-0 px-1 py-0.5 rounded bg-red-500 text-white text-[9px]">
{c!.unread}
</span>
)}
</div>
{c?.title && c.source !== 'new' && (
<p className="text-[10px] text-gray-400 truncate mt-0.5">{c.title}</p>
)}
</button>
);
})}
</div>
)}
</div>

View File

@ -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<ThreadNode[]>([]);
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<HTMLDivElement>(null);
const topSentinel = useRef<HTMLDivElement>(null);
const bottomSentinel = useRef<HTMLDivElement>(null);
const anchorRef = useRef<HTMLDivElement>(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 (
<div className="flex-1 min-w-0 flex flex-col bg-gray-50">
<div className="px-4 md:px-6 py-3 border-b border-gray-200 bg-white flex items-center gap-2">
<span className="text-sm font-semibold text-gray-900"></span>
<span className="text-xs text-gray-400">
{nodes.length}
{(moreUp || moreDown) && ',滑动加载更多'}
{hasMore && ',滑动加载更多'}
{hidden > 0 && `${hidden} 封无权查看`}
</span>
<div className="flex-1" />
@ -179,28 +154,23 @@ export default function ThreadView({ mailID, onClose }: { mailID: string; onClos
{!initial && (
<>
<div ref={topSentinel} className="h-px" />
{moreUp && (
<button
onClick={() => loadMore('up')}
className="w-full mb-2 py-1.5 rounded border border-dashed border-gray-300 text-xs text-gray-500 hover:border-blue-300 hover:text-blue-600"
>
</button>
)}
<div className="space-y-1.5">
{nodes.map(n => (
<Node key={n.mail_id} node={n} baseDepth={baseDepth} anchorID={mailID} />
<Node
key={n.mail_id}
node={n}
anchorID={mailID}
anchorRef={n.mail_id === mailID ? anchorRef : undefined}
/>
))}
</div>
{moreDown && (
{hasMore && (
<button
onClick={() => loadMore('down')}
onClick={loadMore}
className="w-full mt-2 py-1.5 rounded border border-dashed border-gray-300 text-xs text-gray-500 hover:border-blue-300 hover:text-blue-600"
>
</button>
)}
<div ref={bottomSentinel} className="h-px" />
@ -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<HTMLDivElement>;
}) {
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 (
<div className="flex items-stretch" style={{ paddingLeft: indent }}>
<div ref={anchorRef} className="flex items-stretch" style={{ paddingLeft: indent }}>
{indent > 0 && (
<div className="w-3 shrink-0 border-l border-b border-gray-200 rounded-bl mr-1.5 -mt-1.5 mb-3" />
)}
@ -256,6 +230,11 @@ function Node({
<span className="text-[10px] text-gray-400"></span>
<span className="text-xs font-mono text-gray-500 truncate">{node.to_name}</span>
<div className="flex-1" />
{isForward && (
<span className="px-1 py-0.5 rounded bg-purple-100 text-purple-700 text-[9px]">
</span>
)}
{node.parent_hidden && (
<span
className="px-1 py-0.5 rounded bg-gray-100 text-gray-500 text-[9px]"
@ -279,6 +258,13 @@ function Node({
<span className="text-[10px] text-gray-400 shrink-0">{time}</span>
</div>
<p className="text-xs text-gray-800 mt-0.5 truncate">{node.subject}</p>
{/* 抄送人要显示出来:一封邮件收到两个回复,正是因为它抄送给了两个人。
不显示抄送,树上那两个兄弟节点为什么并列就没有解释。 */}
{ccCount > 0 && (
<p className="text-[10px] text-gray-400 mt-0.5 truncate">
{node.cc_list.map(c => c.raw || c.name).join('、')}
</p>
)}
{node.body_preview && (
<p className="text-[11px] text-gray-400 mt-0.5 line-clamp-2">{node.body_preview}</p>
)}

View File

@ -114,27 +114,29 @@ export interface Mail {
* 分块加载时根可能还没取到,所以不用「距根深度」。
*/
export interface ThreadNode extends Omit<Mail, 'body'> {
/** 距**线索根**的层级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[];
}
/** 系统初始化状态 */