feat: agent 邮件寻址能力全面补齐 + .new 别名替换

## 别名替换(让 .new 邮件可寻址)

repo/autoalias.go: AutoAliasFor + EnsureSessionAlias
- .new 建完会话立刻给别名(形如 dsh-重构导入路径)
- 名字与主题都要:只用主题跨 Agent 撞名,只用名字看不出聊什么
- sanitizeAliasPart 只留 unicode.IsLetter/IsDigit,其余折 -
- 撞名追加 -2/-3,全占用退 session-<uuid前8位>
- 不复用 SyncSessionAlias:那个假定已存在且跳过 manual
- 条件写入 WHERE alias IS NULL OR '',并发安全
- resolveTarget 的 .new 与默认会话两条路径都调

notifyRecipients 加三个字段(每个收件方拿到自己那个地址的版本):
- session_alias / reply_address / self_address
- 别名为空时退回省略 session 位,绝不写 new

FormatAddress(name,path,session) 空 path 也必须留 @ 与 .

## Agent 侧寻址发现(五个只读端点)

handler/agent_discovery.go:
- /agent/contacts + /agent/contacts/suggest(三段式补全)
- /agent/mail/{id} + /agent/mail/{id}/thread
- /agent/sessions/{id}/participants
- 不复用人类路由:scope 不同、审计需求不同
- 一律只读:归档/改名/权限决策仍只有人能做

repo/participants.go: SessionParticipants 逐封扫 from/to/cc
- Roles 用集合、MailCount 只数发信(0=还没开口的人)
- 发件人 path 不取 from_workspace(那列存的是 Agent 名)

repo.SuggestPaths 重写:mails.to_workspace(按 MAX(created_at) 倒序)
+ agents.workspaces 并集。原只读 workspaces,官方插件传 [] 永远空

## 共用模块(三插件逐字节相同)

lib/addressing.js: formatAddress/roleOf/replyAddressFor/selfAddressFor/participantsOfMail
lib/discovery.js: renderNameSuggestions/renderPathSuggestions/renderSessionSuggestions/
                  renderParticipants/renderContacts/renderThread

lib/inbox-format.js: renderMail 新增收件人/身份/可投递地址三段
  - selfName 参数(兼容旧调用不传的情况)

check-shared-libs.sh 纳入 addressing + discovery

## 插件侧

opencode: suggest_address + list_contacts + session_participants + read_thread + read_mail
dsh: 同上 + forward_mail(此前只有 opencode 有)+ upload_attachment 改真 multipart
pi: 同上(createMailTools 加 agentName 参数)

dsh: ctx.agents.create id collision 改为 readSession 探测后 resume
dsh: 关键路径日志改 console.error(ctx.logger 不进 journalctl)

## 测试

repo: autoalias_test.go 11 + participants_test.go 7 = 18 例
plugins: addressing.test 17 + discovery.test 23 + inbox-format.test 31 = 71 例
go test ./... + npm test(opencode 155 + dsh 173 + pi 199)全绿
端到端验证:admin 发 dsh@....new 抄送 opencode@....new
  → dsh 用 session_participants 取到地址 → send_mail 给 opencode
  → 地址取自工具返回值(.crisp-planet),未手工拼写
This commit is contained in:
2026-09-03 12:09:12 +08:00
parent 22ddb1b89c
commit e6fd2fafdc
81 changed files with 11355 additions and 122 deletions

View File

@ -21,6 +21,11 @@
会话别名负责寻址,因此全局唯一。默认由 Agent 平台自己的命名机制提供 —— opencode 等平台
本就会由模型为会话生成摘要标题和 slugAgentMail 直接复用,不另造一套。
平台没有可用名字时(例如 pi 用 SDK 起的会话),桥退一级用邮件主题派生别名,
再把服务端**定稿**的那个值写回平台。定稿而非各自命名,是因为别名要保证唯一:
撞名时服务端会追 `-2`,而人在界面上手工改过的别名永远优先 —— 两侧各自命名的话,
邮箱里显示 `fix-leak-2`、平台里显示 `fix-leak`,按界面上看到的名字发信会「无法送达」。
## 快速开始
### 开发
@ -83,6 +88,14 @@ DATABASE_URL= # 留空 = 内置 SQLit
### 接入 Agent
已有三个平台的桥接实现:
| 平台 | 形态 | 接入方式 |
|---|---|---|
| opencode | 插件 | 配置的 `plugin` 列表里加本地路径 |
| DeepSeek Harness | Cordis 插件 | profile 的 `cordis.patch.yml` |
| pi | **常驻守护进程** | `systemctl enable --now pi-mail-bridge` |
以 opencode 为例:
```bash
@ -90,17 +103,23 @@ DATABASE_URL= # 留空 = 内置 SQLit
"plugin": ["file:///path/to/agentmail/plugins/opencode-mail-bridge"]
```
pi 不是插件而是独立服务,因为 pi 扩展被加载进**一条已存在的**会话,
而三维地址要求每封邮件的 `path` 位成为会话工作目录 —— 扩展改不了这一点。
桥用 pi 的 SDK`createAgentSession`)按邮件起会话,一个进程里并存多条
不同工作目录的会话。`deploy/install.sh` 会装好它的 systemd 单元。
插件提供六个工具(`send_mail` / `read_inbox` / `forward_mail` / `upload_attachment` /
`download_attachment` / `connect_to_server`),并通过 SSE 监听新邮件:
收到邮件时自动在 opencode 侧开会话处理,回信落回同一邮件会话。
收到邮件时自动在平台侧开会话处理,回信落回同一邮件会话。
两类消息由插件**自动**转发,不需要模型自己调工具,也不消耗发信配额:
- **平台原生的权限询问**opencode 拦下一个危险操作时(`permission.ask`
插件把它转成邮件问人,人在网页上点「同意/一直同意/拒绝」,插件再回复 opencode 让它继续。
- **平台原生的权限询问**平台拦下一个危险操作时(opencode 的 `permission.ask`
DSH 的 `approval/request`、pi 的 `tool_call` 钩子),插件把它转成邮件问人,
人在网页上点「同意/一直同意/拒绝」,插件再回复平台让它继续。
这是 harness 的职责 —— 让模型自己调一个 `request_permission` 工具的话,
它可能忘了调,而真正被拦下的那次询问反而没人看见。
- **本轮的最终总结**:一轮跑完`session.idle`时把最后那段话作为回信发回去。
- **本轮的最终总结**:一轮跑完时把最后那段话作为回信发回去。
模型已经把话说完了,插件只是搬运。
配额约束的是**模型的自主发信**,不是 harness 的转发 —— 否则配额用尽时 Agent 连交代都做不了。
@ -120,7 +139,8 @@ DATABASE_URL= # 留空 = 内置 SQLit
三种生命周期:`permanent`(长期)/ `one_time`(首次使用后失效)/ `timed`(限时)。
环境变量见 `deploy/install.sh` 生成的 `/etc/agentmail/opencode.env`
环境变量见 `deploy/install.sh` 生成的 `/etc/agentmail/opencode.env`
`/etc/agentmail/pi.env`
## 项目结构
@ -140,9 +160,10 @@ agentmail/
│ ├── middleware/ # Agent / 用户双认证
│ ├── sse/ # 事件推送(按收件人分流)
│ └── static/ # go:embed 的前端产物
├── plugins/ # 各平台桥接插件lib/ 下的纯函数模块逐字节共用)
│ ├── opencode-mail-bridge/ # opencode
── dsh-mail-bridge/ # DeepSeek HarnessCordis
├── plugins/ # 各平台桥接lib/ 下的纯函数模块逐字节共用)
│ ├── opencode-mail-bridge/ # opencode(插件)
── dsh-mail-bridge/ # DeepSeek HarnessCordis 插件
│ └── pi-mail-bridge/ # pi常驻守护进程用 SDK 起会话)
├── web/ # 前端React + Vite + Tailwind
│ └── test/manual/ # 浏览器实测脚本(量真实盒子与命中区,不进 npm test
└── deploy/ # systemd 单元 + 安装脚本

View File

@ -1,23 +1,42 @@
#!/usr/bin/env bash
# 共用模块必须逐字节相同 —— 见 docs/PLUGIN-CONTRACT.md 第六节。
#
# 一侧改了另一侧没改,个平台的行为就会悄悄分叉:同一封邮件在 opencode 那边
# 一侧改了另一侧没改,个平台的行为就会悄悄分叉:同一封邮件在 opencode 那边
# 标了已读、在 DSH 那边没标,而两处代码看起来都"对"。
#
# 三方比对以 opencode 为基准逐个对比,而不是两两对比:后者在三方都不同时
# 会打出三条互相矛盾的差异,读的人无从判断谁是对的。
set -euo pipefail
A=plugins/opencode-mail-bridge
B=plugins/dsh-mail-bridge
BASE=plugins/opencode-mail-bridge
PEERS=(plugins/dsh-mail-bridge plugins/pi-mail-bridge)
fail=0
for f in relay-dedup inbox-format session-snapshot workspace model-scope catchup; do
if ! diff -q "$A/lib/$f.js" "$B/lib/$f.js" >/dev/null 2>&1; then
echo "共用模块已分叉lib/$f.js" >&2
diff "$A/lib/$f.js" "$B/lib/$f.js" | head -20 >&2
for peer in "${PEERS[@]}"; do
for f in relay-dedup inbox-format session-snapshot workspace model-scope catchup addressing discovery; do
if [[ ! -f "$peer/lib/$f.js" ]]; then
echo "共用模块缺失:$peer/lib/$f.js" >&2
fail=1
continue
fi
if ! diff -q "$BASE/lib/$f.js" "$peer/lib/$f.js" >/dev/null 2>&1; then
echo "共用模块已分叉lib/$f.js$BASE vs $peer" >&2
diff "$BASE/lib/$f.js" "$peer/lib/$f.js" | head -20 >&2
fail=1
fi
done
for f in inbox-format session-snapshot workspace model-scope catchup; do
if ! diff -q "$A/test/$f.test.mjs" "$B/test/$f.test.mjs" >/dev/null 2>&1; then
echo "共用测试已分叉test/$f.test.mjs" >&2
# 测试同样要同源:共用模块的行为约定写在测试里,
# 只同步实现不同步测试,等于允许一侧偷偷放宽约定。
for f in inbox-format session-snapshot workspace model-scope catchup addressing discovery; do
if [[ ! -f "$peer/test/$f.test.mjs" ]]; then
echo "共用测试缺失:$peer/test/$f.test.mjs" >&2
fail=1
continue
fi
if ! diff -q "$BASE/test/$f.test.mjs" "$peer/test/$f.test.mjs" >/dev/null 2>&1; then
echo "共用测试已分叉test/$f.test.mjs$BASE vs $peer" >&2
fail=1
fi
done
[[ $fail -eq 0 ]] && echo " 共用模块两侧同源" || exit 1
done
[[ $fail -eq 0 ]] && echo " 共用模块三方同源opencode / dsh / pi" || exit 1

View File

@ -1,6 +1,6 @@
#!/usr/bin/env bash
#
# 把 AgentMail Gateway 与 opencode serve 安装为 systemd 服务。
# 把 AgentMail Gateway 与各平台的 mail-bridge 安装为 systemd 服务。
#
# sudo ./deploy/install.sh
#
@ -19,8 +19,8 @@ echo "==> 构建前端"
( cd "$REPO/web" && npm run typecheck && npm test && npm run build )
echo "==> 校验插件共用模块同源"
# lib/ 下的纯函数模块在个插件里逐字节相同(见 docs/PLUGIN-CONTRACT.md 第六节)。
# 一侧改了另一侧没改,个平台的行为就会悄悄分叉。
# lib/ 下的纯函数模块在个插件里逐字节相同(见 docs/PLUGIN-CONTRACT.md 第六节)。
# 一侧改了另一侧没改,个平台的行为就会悄悄分叉。
"$REPO/deploy/check-shared-libs.sh"
# 插件的纯函数测试(自动转发去重等)。
@ -45,6 +45,20 @@ else
( cd "$REPO/plugins/dsh-mail-bridge" \
&& npm install --no-audit --no-fund && npx tsc && npm test )
fi
# pi 插件:纯 ESM无构建步骤唯一的外部依赖是全局装的 pi SDK。
#
# 它不 npm install@earendil-works/pi-coding-agent 是全局包peerDependency
# 装在项目里会得到第二份 SDK两份各自维护 ~/.pi/agent 的会话索引缓存。
# 因此这里只软链一次SDK 不在时跳过测试turn/naming 是纯函数,
# 但 lib 的测试也一起跑,没必要为缺 SDK 的机器留半套门禁)。
PI_SDK=/usr/lib/node_modules/@earendil-works/pi-coding-agent
if [[ -d "$PI_SDK" ]]; then
install -d "$REPO/plugins/pi-mail-bridge/node_modules/@earendil-works"
ln -sfn "$PI_SDK" "$REPO/plugins/pi-mail-bridge/node_modules/@earendil-works/pi-coding-agent"
( cd "$REPO/plugins/pi-mail-bridge" && npm test )
else
echo " 未找到 pi SDK$PI_SDK),跳过 pi 插件测试"
fi
echo "==> 前端产物嵌入 Gateway"
# 只清构建产物,不能 rm -rf 整个目录:
@ -110,9 +124,37 @@ else
echo " $ETC/opencode.env 已存在,保留不动"
fi
if [[ ! -f "$ETC/pi.env" ]]; then
cat > "$ETC/pi.env" <<EOF
# pi mail-bridge 插件配置
AGENTMAIL_GATEWAY_URL=http://127.0.0.1:8180
AGENTMAIL_AGENT_NAME=pi
# 接入密钥:留空时插件会在 \$AGENTMAIL_CONFIG_DIR/agent.key 本地生成一把并打印到日志,
# 拿着它到 Web 后台「Agent 密钥」登记即可接入journalctl -u pi-mail-bridge | grep 密钥)。
AGENTMAIL_AGENT_KEY=
AGENTMAIL_CONFIG_DIR=/opt/agentmail/pi-config
# 处理来信时首选的模型。留空表示用 pi 自己的默认(~/.pi/agent/settings.json
# 管理员在后台划定模型范围时范围优先于这里的设置B-2.2)。
AGENTMAIL_REPLY_PROVIDER=llmsproxy
AGENTMAIL_REPLY_MODEL=AUTO
# 单轮上限。超时**不算失败**pi 仍在后台跑,转发挂在 agent_end 上,
# 说完了自然会转出去。这个值只决定「多久之后不再阻塞投递流水线」。
AGENTMAIL_TURN_TIMEOUT_MS=60000
EOF
chmod 0600 "$ETC/pi.env"
install -d -m 0700 "$PREFIX/pi-config"
echo " 已生成 $ETC/pi.env接入密钥首启时本地生成"
else
echo " $ETC/pi.env 已存在,保留不动"
fi
echo "==> 安装 systemd 单元"
install -m 0644 "$REPO/deploy/agentmail-gateway.service" /etc/systemd/system/
install -m 0644 "$REPO/deploy/opencode-serve.service" /etc/systemd/system/
install -m 0644 "$REPO/deploy/pi-mail-bridge.service" /etc/systemd/system/
systemctl daemon-reload
echo "==> 启用并启动"
@ -122,6 +164,11 @@ if command -v opencode >/dev/null 2>&1; then
else
echo " 未找到 opencode跳过 opencode-serve装好后执行systemctl enable --now opencode-serve"
fi
if [[ -d "$PI_SDK" ]]; then
systemctl enable --now pi-mail-bridge.service
else
echo " 未找到 pi SDK跳过 pi-mail-bridge装好后执行systemctl enable --now pi-mail-bridge"
fi
sleep 3
echo

View File

@ -0,0 +1,37 @@
[Unit]
Description=pi mail-bridge (AgentMail ↔ @earendil-works/pi-coding-agent)
After=network-online.target agentmail-gateway.service
Wants=network-online.target
[Service]
Type=simple
# 桥自己不需要工作目录 —— 每条会话的 cwd 来自邮件寻址的 path 位。
# 但 systemd 要求一个存在的目录,且 pi 的 SettingsManager 会在这里找
# 项目级配置,因此指向仓库而不是 /。
WorkingDirectory=/home/program/agentmail/plugins/pi-mail-bridge
ExecStart=/usr/bin/node /home/program/agentmail/plugins/pi-mail-bridge/src/index.mjs
# pi 靠 HOME 定位 ~/.pi/agentsettings.json、auth.json、models.json、sessions/)。
# systemd 不会自动注入 HOME不显式给就
# - 读不到 provider 凭证 → 每封邮件都"没有可用模型"
# - 会话落到 /.pi 或直接失败 → C-8 的会话快照永远是空的
Environment=HOME=/root
# pi 的全局扩展 pi-a2a / pi-acp 绑死 127.0.0.1:12010 / 12011。
# 桥用 noExtensions:true 起会话,本进程不会去 bind 那两个端口;
# 这两个变量是给「同机还跑着 pi CLI」的情况留的隔离位
# 万一将来放开扩展加载,端口也不会和交互式 pi 撞。
Environment=PI_A2A_PORT=13010
Environment=PI_ACP_PORT=13011
EnvironmentFile=/etc/agentmail/pi.env
Restart=always
RestartSec=10
# 桥是长驻守护进程,启动即注册 + 立刻打一次心跳 + 订阅 SSEB-1
# 不像 opencode 那样惰加载,因此不需要 ExecStartPost 预热。
[Install]
WantedBy=multi-user.target

View File

@ -96,11 +96,20 @@ name@path.session
| **C-8** | 权限/审批钩子 | `permission_hook` | 不转发权限询问 | 危险操作只能靠平台本地 UI 决策 |
| **C-9** | 钩子可异步等待 | `async_permission` | 转出去后立即返回「待决」,决策经 SSE 回来后再补 | 模型会先被拒一次再重试 |
| **C-10** | 列出会话 | `list_sessions` | 不上报平台会话快照 | 写信时只能续谈邮件驱动的会话 |
| **C-11** | 模型标题 | `model_title` | 会话别名退化为随机短名或平台 id | 补全列表里分不清哪条在谈什么 |
| **C-11** | 模型标题 | `model_title` | `D-5` 阶梯:退到邮件主题派生别名 | 补全列表里的名字来自人写的主题,而非模型对内容的概括 |
| **C-12** | 列出可用模型 | `list_models` | 不上报模型目录 | 管理员无法在配置页划定模型范围 |
| **C-13** | 指定单轮模型 | `per_turn_model` | 不做降级尝试 | 主力模型故障时该 Agent 整体不可用 |
| **C-14** | 会话内文件读写工具 | `file_tools` | 附件下载后模型看不到 | 附件功能形同虚设 |
> **C-11 可能是「部分具备」。** pi 是实例:它确实会生成会话标题,但生成器在
> **pi-web** 包里,不在 `pi-coding-agent` 内核里。人在 pi-web 界面上开的会话有标题,
> 桥用 SDK 起的会话没有。判定的依据只能是**桥自己起的那条会话**上
> `sessionName` 到底有没有值,不是「这个平台有没有这个功能」。
>
> 具备 `C-11` 的平台还要回答第二个问题:**别名由谁定稿**。别名负有寻址唯一性义务
> (撞名追 `-2`、`manual` 来源永远优先),平台侧没有这个约束。两侧各自命名会分叉,
> 因此必须由服务端定稿、插件把响应里的值**回写**进平台(见 `D-5`、`W-7`)。
### 能力自检脚本
接入前先回答这七个问题。任何一个答不出来,先去读平台文档,不要开始写代码:
@ -113,10 +122,15 @@ name@path.session
5. 怎么取到最后一条 assistant 消息的纯文本? → C-5
6. 注册工具时 execute 能拿到 session id 吗? → C-6
7. 权限钩子是同步还是异步?能不能真的等人? → C-8/C-9
8. 往一条**空闲**会话里注入一轮,用的是哪个方法? → C-1见 9.11
```
> 问题 4 在次适配里被漏掉过,两次都造成「无效模型被判成成功」——
> 问题 4 在次适配里被漏掉过两次,两次都造成「无效模型被判成成功」——
> 详见 `D-3` 与第九节。
>
> 问题 8 是 pi 适配加上的:平台常常有两个注入入口(一个起新轮、一个往正在跑的轮次里
> 排队),而排队那个在会话空闲时**静默什么也不做** —— 不报错、不超时、没有回信。
> 见 9.11。
---
@ -443,17 +457,68 @@ SSE 只推连上之后的事件。插件重启前发来的邮件不会再推一
心跳里**省略** `platform_sessions`(不是传 `[]`,见 `W-3`)。
后果:写信时的会话补全只能看到邮件驱动的那些。
### D-5 无模型标题(缺 `C-11`
### D-5 别名来源阶梯(`C-11` 缺失或不可用时
| 优先级 | 别名来源 |
|---|---|
| 1 | 平台自带的 slug`witty-planet` |
| 2 | 由模型标题派生(`slugFromTitle` |
| 3 | 不回写,保持服务端自动生成的别名 |
| 优先级 | 别名来源 | 何时用 |
|---|---|---|
| 1 | 平台自带的 slug`witty-planet` | 平台创建会话时就给 |
| 2 | 由平台标题派生(`slugFromTitle` | 有标题且标题可用 |
| 3 | 由**邮件主题**派生 | 平台没有标题,或标题不可用(见下) |
| 4 | 不回写,保持服务端自动生成的别名 | 以上都没有 |
**不得**用占位标题回写(`"新会话"``"处理邮件"` 之类):那会覆盖掉一个
本来可能更有意义的名字,且之后平台真的生成标题时无从判断该不该覆盖。
> **第 3 级是 pi 适配加上的。** 原先的阶梯假定「平台要么有标题,要么完全没有命名机制」。
> pi 是第三种情况:内核不生成标题(生成器在 pi-web 里),于是 SDK 起的会话
> `sessionName` 恒为 `undefined`。只走前两级的话别名永远是空的 ——
> `name@path.<别名>` 续谈无从下手,`.new` 又是一次性的,那条会话事实上只能收一封信。
>
> **「标题不可用」是真实存在的一类。** pi-web 的标题生成器会泄漏思维链,
> 本机 82 条会话里捞到过这两条真实样本:
>
> ```
> The user is asking me to generate a title for a coding-agent
> 我们只需要生成标题不包含其他内容。标题应反映请求内容测试opencode的源。简短
> ```
>
> 它们语法上是合法标题(`cleanSessionName` 只取首行 + 截 60 字符),
> 派生出的别名却是一句废话。判废逻辑在 `lib/session-snapshot.js` 的
> `isUnusableName()`:命中就降到下一级,**不是**改写成别的。
#### 别名必须由服务端定稿
具备 `C-11` 的平台也要走这一步。插件提议、服务端定稿、插件把响应里的值回写:
```
插件观察到平台的名字
→ POST /sessions/{id}/sync { alias: slug, title: 原文 }
→ 读**响应里**的 alias可能被改写过
→ 与平台当前名字不同 → 写回平台
```
两个原因让单向推送无法收敛:
| 服务端行为 | 后果 |
|---|---|
| 别名撞名时追 `-2``-3``SyncSessionAlias` | 平台侧没有唯一性约束,不会跟着改 |
| `alias_source='manual'`(人手工改过)永远优先 | 服务端**原样返回当前别名**,忽略提议 |
不回写的话:邮箱里显示 `fix-leak-2`、平台界面上显示 `fix-leak`
用户按界面上看到的名字发信会收到「无法送达」。
回写有三条实测约束pi 的形态,其它平台需各自确认):
| # | 约束 | 违反的后果 |
|---|---|---|
| 1 | 只能用平台的改名 API不能自己写会话文件 | 首条 assistant 消息落盘前文件不存在pi 首次落盘用 `openSync(file,"wx")`,抢先创建让它抛 `EEXIST` |
| 2 | 活着的会话管理器从不重读文件 | 它自己后续的 `session_info` 会覆盖外部改名 |
| 3 | 空名字是**清除**语义 | 「没拿到定稿值」绝不能落成一次空写入 |
还要防自激循环:改名通常会触发「名字变了」事件,而那个事件正是同步的触发源。
插件必须记住**上次提交的内容指纹**并在相同时跳过。指纹不能用平台名字本身 ——
名字为空时(上面 pi 的常态)它无法区分「还没提交过」与「提交过、内容没变」。
### D-6 工作目录不存在
| # | 要求 | 强度 |
@ -584,7 +649,10 @@ Agent 侧只会收到两个事件:
"mail_id": "9ea5ff18-...", "session_id": "7b5081e0-...",
"from_name": "admin", "subject": "重构导入路径",
"mail_type": "normal", "role": "to",
"to_workspace": "/home/program/agentmail"
"to_workspace": "/home/program/agentmail",
"session_alias": "refactor-imports",
"reply_address": "admin@.refactor-imports",
"self_address": "pi@/home/program/agentmail.refactor-imports"
}
```
@ -593,6 +661,13 @@ Agent 侧只会收到两个事件:
| `role` | `to``cc` |
| `to_workspace` | **收件方那个地址的 path 位**(抄送方拿到的是自己那个地址的) |
| `mail_type` | `normal` / `permission_request` / … |
| `session_alias` | 这条会话今后的寻址名 |
| `reply_address` | 「把回信发回这条会话」的现成地址 |
| `self_address` | 对方应当用来称呼自己的地址,供转发/报告时引用 |
> **`reply_address` 应当放进提示词。** 插件会自动转发本轮总结(`B-5`
> 但模型仍然会主动发信 —— 要抄送第三方、或分多封交代不同的事时。让它自己拼三维地址
> 的话,`.new` 会被拼进去,于是回信静默开出一条**新**会话,原来的线索里再无下文。
**`permission_decision`**
@ -911,21 +986,38 @@ GET /api/v1/attachments/{id}
## 八、平台差异对照 / Platform Matrix
次真实适配的对照表。接新平台时逐行回答「我这边是什么」。
次真实适配的对照表。接新平台时逐行回答「我这边是什么」。
| 关注点 | opencode | DeepSeek Harness |
|---|---|---|
| 插件形态 | `export default async function(input)` | Cordis`export const inject` + `apply(ctx, config)` |
| 建会话 | `client.session.create({ query: { directory } })` | `ctx.agents.create({ sessionId, meta: { cwd }, agentOptions })` |
| 注入一轮 | `client.session.promptAsync({ parts })` | `agent.followup(UserMessage)` |
| 工具定义 | zod schema | `defineTool()` + spec 格式参数 |
| 轮次结束 | `session.idle` 事件 | `agent/status``idle` |
| 模型失败信号 | `session.error` 事件 | `turn/end``reason.kind === 'error'` |
| 权限钩子 | `permission.ask`**同步,不能等** | `approval/request`(异步 waterfall**能等** |
| 会话列表 | `client.session.list()` | `ctx.sessionQuery.listSessions()` |
| 模型目录 | `client.config.providers()``models` 是**对象** | `ctx.llm.listProviders()` + `listModels()` |
| 别名来源 | `session.slug`(创建时就有) | 模型标题派生 |
| 日志可见性 | `console.error` | `console.error``ctx.logger` 不进 journalctl |
| 关注点 | opencode | DeepSeek Harness | pi |
|---|---|---|---|
| 插件形态 | `export default async function(input)` | Cordis`export const inject` + `apply(ctx, config)` | **常驻守护进程**,不是插件(见下) |
| 建会话 | `client.session.create({ query: { directory } })` | `ctx.agents.create({ sessionId, meta: { cwd }, agentOptions })` | `createAgentSession({ cwd, sessionManager, … })` |
| 注入一轮 | `client.session.promptAsync({ parts })` | `agent.followup(UserMessage)` | `session.prompt(text)`;忙时 `prompt(text, {streamingBehavior:'followUp'})` |
| 工具定义 | zod schema | `defineTool()` + spec 格式参数 | `defineTool()` + **TypeBox** schema |
| 轮次结束 | `session.idle` 事件 | `agent/status``idle` | `prompt()` 的 promise resolve事件是 `agent_end` |
| 模型失败信号 | `session.error` 事件 | `turn/end``reason.kind === 'error'` | `prompt()` reject **或** 末条 assistant 的 `stopReason==='error'` |
| 权限钩子 | `permission.ask`**同步,不能等** | `approval/request`(异步 waterfall**能等** | `tool_call` 扩展事件(**能 await**,实测) |
| 会话列表 | `client.session.list()` | `ctx.sessionQuery.listSessions()` | `SessionManager.listAll()`**不传参**,传字符串会被当自定义目录) |
| 模型目录 | `client.config.providers()``models` 是**对象** | `ctx.llm.listProviders()` + `listModels()` | `modelRuntime.getAvailable()`**不是** `getModels()`1221 条里只有 1 条能用) |
| 别名来源 | `session.slug`(创建时就有) | 模型标题派生 | **邮件主题派生**SDK 会话没有平台标题,见下) |
| 日志可见性 | `console.error` | `console.error``ctx.logger` 不进 journalctl | `console.error` |
> **pi 为什么是守护进程而不是扩展**pi 扩展被加载进**一条已经存在的**会话,
> 那条会话的 cwd 由启动 pi 的人决定。而 `B-3.1` 要求每封邮件的 `to_workspace`
> 成为会话 cwd —— 扩展做不到「按邮件新开一条 cwd 不同的会话」。
> 桥因此用 SDK 起会话,一个进程里并存多条不同 cwd 的会话(实测可行)。
> 代价是它需要自己的 systemd 单元(`deploy/pi-mail-bridge.service`)。
>
> **pi 的 `C-11` 是有条件的**pi 会生成会话标题,但生成器在 **pi-web** 包里
> `sessionNameGenerator.js`),不在 `pi-coding-agent` 内核里。桥用 SDK 起的会话
> 走不到那条路径,`session.sessionName` 一直是 `undefined`。因此 pi 侧的别名走
> `D-5` 阶梯的第二级(邮件主题派生),并把 Gateway 的定稿值**回写**进
> `session.setSessionName()` —— 这一步让 pi-web 界面上显示的名字与邮箱里一致。
>
> **回写只能用 `setSessionName()`**,三条实测约束:首条 assistant 消息落盘前
> 会话文件还不存在pi 首次落盘用 `openSync(file,"wx")`,外部抢先创建会让它抛
> `EEXIST`;活着的 `SessionManager` 从不重读文件,它自己后续的 `session_info`
> 会覆盖外部改名;空名字是**清除**语义,不是「不改」。
---
@ -1038,6 +1130,39 @@ socat 被 `Requires` 带停后再没起来。加 `PartOf` 后又发现 `start`
它把目录名当模块解析。必须写 glob`node --test 'test/*.test.mjs'`
### 9.11 pi 的 `followUp()` 在会话空闲时什么也不做(静默丢邮件)
`session.followUp(text)` 只往 `followUpQueue` 里塞消息,而那个队列**只在运行中的
轮次末尾**被 drain`pi-agent-core/agent.js` 的 run 循环,以及 `continue()`)。
会话空闲时(上一轮早已结束)塞进去的消息永远没人取。
表现极具欺骗性:日志打了「续谈成功」,`prompt()` 没报错,收件箱里却只有来信、
没有回复。既没有异常也没有超时。
> **正确做法**:按 `session.isStreaming` 分流 —— 空闲用 `prompt(text)` 直接起一轮,
> 正在跑用 `prompt(text, { streamingBehavior: 'followUp' })` 排到当轮之后
> (缺 `streamingBehavior` 时 pi 会抛 `Agent is already processing.`)。
> 不要用 `steer`:那会打断当前轮,而当前轮正在处理**上一封邮件**。
### 9.12 pi 的全局扩展会 `listen` 固定端口
`~/.pi/agent/extensions/` 下的 `pi-a2a` / `pi-acp` 在加载时就 bind
`127.0.0.1:12010` / `12011`。同机已有 pi 在跑时,任何新起的 pi 进程(包括
`pi --help`)都会 `EADDRINUSE` 并把整条会话拖死。
> 桥用 `noExtensions: true` + 内联 `extensionFactories` 起会话:既避开端口冲突,
> 也不继承那套给人类交互用的扩展TUI 命令、快捷键、状态栏对邮件没有意义)。
> 邮件工具走 `customTools`,权限钩子走内联工厂。
### 9.13 `SessionManager.listAll(dir)` 的参数不是 agentDir
它的字符串参数是**自定义会话目录**,会直接在里面找 `.jsonl`。传
`getAgentDir()``~/.pi/agent`)得到的是空列表 —— 会话在它的 `sessions/`
子目录下按 cwd 分目录存放。**不传参数**才会走默认的逐 cwd 扫描。
同理不要用 `list(cwd)`:桥的进程 cwd 与会话 cwd 无关,按前者过滤会漏掉
所有真正在干活的会话。
---
## 附:文档关系
@ -1050,4 +1175,4 @@ socat 被 `Requires` 带停后再没起来。加 `PartOf` 后又发现 `start`
| [`PHASE7-REMAINING.md`](PHASE7-REMAINING.md) | 未完成项与已知取舍 |
参照实现:`plugins/opencode-mail-bridge/``plugins/dsh-mail-bridge/`
`deploy/remote-agent-demo.py`(纯标准库的协议层对照)。
`plugins/pi-mail-bridge/``deploy/remote-agent-demo.py`(纯标准库的协议层对照)。

View File

@ -113,6 +113,20 @@ func main() {
// 邮件场景下的可用模型范围。上报走心跳agent/heartbeat 的 models 字段),
// 这里只读 —— 给非插件的第三方客户端与排查用。
r.Get("/agent/models/allowed", handler.GetAllowedModels)
// ---- 寻址发现(只读)----
//
// 没有这一组时send_mail 的 to 是个只能靠记忆拼写的自由文本:
// 想回给抄送方只能从收件箱里拄一段 `opencode@/home.new`
// 而 `.new` 是一次性的,拄过去只会再建一条会话。
// 人类侧 AddressInput 逐段查 /contacts/suggest 从活数据里选,
// 这一组就是把同一份能力给 Agent。均为只读
// 归档、改别名、权限决策仍然只有人能做。
r.Get("/agent/contacts", handler.AgentListContacts)
r.Get("/agent/contacts/suggest", handler.AgentSuggestAddress)
r.Get("/agent/mail/{id}", handler.AgentGetMail)
r.Get("/agent/mail/{id}/thread", handler.AgentGetMailThread)
r.Get("/agent/sessions/{id}/participants", handler.AgentSessionParticipants)
})
// ---- 人类登录态 ----
@ -187,6 +201,12 @@ func main() {
// 邮件场景下每个 Agent 可用的模型范围(勾选平台上报的目录)
r.Get("/admin/agents/{name}/models", handler.AdminListAgentModels)
r.Put("/admin/agents/{name}/models", handler.AdminSetAgentModels)
// 停用 / 恢复一个 Agent。停用是可逆的归档邮件与会话保留
// 但从补全里消失、密钥被撤销、重新注册被拒。
// 没有「彻底删除」—— Agent 名与人类用户名共用命名空间,
// 删掉后同名注册者会让历史邮件看起来像是他发的。
r.Put("/admin/agents/{name}/status", handler.AdminSetAgentStatus)
})
})

View File

@ -0,0 +1,330 @@
package handler
import (
"net/http"
"strings"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/models"
"github.com/agentmail/gateway/internal/repo"
"github.com/google/uuid"
)
// Agent 侧的寻址发现与线索读取。
//
// # 为什么需要这一组端点
//
// 在这之前Agent 能读的只有自己的收件箱。`/agents`、`/contacts`、
// `/contacts/suggest`、`/mail/{id}/thread`、`/sessions/{id}` 全部挂在
// `middleware.UserAuth` 后面Agent 密钥一律 401。后果是 `send_mail` 的 `to`
// 成了一个**只能靠记忆拼写的自由文本字段**
//
// - 想回给抄送方,只能从收件箱渲染出的 `抄送: opencode@/home.new` 里抄一段,
// 而 `.new` 是一次性的,抄过去只会再建一条会话;
// - 想知道对方接受哪个工作目录,无从查询,只能猜。生产上真实发生过一次:
// dsh 猜了 `opencode@/home`,地址解析通过、投递成功,但 `/home` 不是
// opencode 的工作目录 —— **猜错比报错更糟,它会静默变成新会话的 workspace**。
//
// 人类侧从来没有这个问题:`AddressInput` 三段式逐段查 `/contacts/suggest`
// name / path / session 每一段都从活数据里选。这一组端点就是把同一份能力
// 给 Agent。
//
// # 为什么不直接给 Agent 复用人类那几条路由
//
// 两条理由:
//
// 1. **作用域不同。** 人类侧 `ListContactsFor(scope=username)` 的 scope 是
// 「我参与过的会话」,管理员还能 `?all=true` 看全部。Agent 没有管理员概念,
// 也不该看到自己没参与过的线索。把 AgentAuth 加进人类路由组,等于让
// `middleware.GetUser` 返回 nil 的请求走进一堆假定 user 非空的 handler。
// 2. **审计与演进。** Agent 能读什么是插件契约的一部分PLUGIN-CONTRACT 的
// 能力矩阵),独立成组才能在一处看全。
//
// # 一律只读
//
// 这里没有任何写端点。归档、改别名、决策权限都仍然只有人能做 ——
// Agent 可以「看见并寻址」,但不能替人整理邮箱。
// GET /api/v1/agent/contacts
//
// 本 Agent 参与过的全部会话,每条给出可直接投递的 `address`。
// 与人类侧 `/contacts` 同源(`repo.ListContactsFor`scope 固定为自己。
func AgentListContacts(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
archived := r.URL.Query().Get("archived") == "true"
contacts, err := repo.ListContactsFor(r.Context(), agentName, archived)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list contacts")
return
}
// 联系人条目里的 agent_name 是「会话对面那个人」,但 ListContactsFor 取的是
// 会话首封邮件的 to_name人类侧视角对面是 Agent。Agent 自己调用时,
// 首封邮件的 to_name 往往就是自己,对面反而是 from_name。
// 因此这里补一个 peer 字段明确「该跟谁说话」,不改原字段以免动到前端。
out := make([]map[string]any, 0, len(contacts))
for _, c := range contacts {
peer := c.AgentName
if peer == agentName {
peer = c.LastFrom
}
out = append(out, map[string]any{
"session_id": c.SessionID,
"session_alias": c.SessionAlias,
"subject": c.Subject,
"path": c.Path,
"status": c.Status,
"mail_count": c.MailCount,
"unread_count": c.UnreadCount,
"last_activity": c.LastActivity,
"last_from": c.LastFrom,
"max_rounds": c.MaxRounds,
"used_rounds": c.UsedRounds,
// peer 是这条会话里可与之通信的另一方
"peer": peer,
// address 是投回这条会话的现成地址。别名为空的老会话给不出可寻址的
// 形式,此时置空而不是拼一个 `.new` —— 那会开新线索而不是续谈。
"address": addressForSession(peer, c.Path, c.SessionAlias),
})
}
JSON(w, http.StatusOK, map[string]any{"contacts": out})
}
// addressForSession 拼「投回这条会话」的地址;无别名时返回空串。
//
// 刻意不退化成 `name@path`(默认会话):默认会话是「该 name@path 当前最活跃的
// 那条」,与调用方想回的那条不一定是同一条。给一个看着能用其实指向别处的地址,
// 比给空串危险。
func addressForSession(name, path, alias string) string {
if alias == "" {
return ""
}
return models.FormatAddress(name, path, alias)
}
// GET /api/v1/agent/contacts/suggest?name=&path=
//
// 三段式寻址补全,与人类侧 `/contacts/suggest` 同一套语义:
//
// 不带 name → 候选收件人名(在线 Agent + 活跃用户,去掉自己)
// 带 name 不带 path → 该 name 用过的工作目录
// name + path 都带 → 该 name@path 下可续谈的会话别名,`new` 永远在最后
//
// **这是「精准发信」的关键一环**:模型不再拼地址,而是逐段选。
func AgentSuggestAddress(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
name := strings.TrimSpace(r.URL.Query().Get("name"))
path := strings.TrimSpace(r.URL.Query().Get("path"))
if name == "" {
agents, err := repo.ListAgents(r.Context(), "")
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list agents")
return
}
users, _ := repo.ListActiveUsernames(r.Context())
names := make([]string, 0, len(agents)+len(users))
for _, a := range agents {
if a.Name == agentName {
continue // 不建议给自己发信
}
names = append(names, a.Name)
}
names = append(names, users...)
JSON(w, http.StatusOK, map[string]any{
"kind": "name",
"suggestions": emptySlice(names),
})
return
}
if path == "" {
paths, _ := repo.SuggestPaths(r.Context(), name)
JSON(w, http.StatusOK, map[string]any{
"kind": "path",
"suggestions": emptySlice(paths),
})
return
}
// 可见性传自己的名字:只提示自己参与过的会话。
// 传空会把别人的私下线索也列出来,那是越权。
sessions, err := repo.SuggestSessionCandidates(r.Context(), agentName, name, path)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to suggest sessions")
return
}
aliases := make([]string, 0, len(sessions)+1)
addresses := make([]string, 0, len(sessions)+1)
for _, c := range sessions {
aliases = append(aliases, c.Alias)
addresses = append(addresses, models.FormatAddress(name, path, c.Alias))
}
// new 总在最后:它不是一条已存在的会话。排在前面会让模型在想续谈时
// 顺手开出一条新线索 —— 生产上已经发生过。
aliases = append(aliases, "new")
addresses = append(addresses, models.FormatAddress(name, path, "new"))
sessions = append(sessions, repo.SessionCandidate{
Alias: "new", Source: "new", Title: "新建会话",
})
JSON(w, http.StatusOK, map[string]any{
"kind": "session",
"suggestions": emptySlice(aliases),
// addresses 与 suggestions 同序,可直接塞进 send_mail 的 to
"addresses": emptySlice(addresses),
"candidates": emptySlice(sessions),
})
}
// GET /api/v1/agent/mail/{id}/thread
//
// 与人类侧 `/mail/{id}/thread` 同一份实现,可见性判据换成
// 「本 Agent 参与过该会话」。抄送协作要靠它回答「谁已经回了、谁还没回」。
func AgentGetMailThread(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
serveMailThread(w, r, func(sid uuid.UUID) (bool, error) {
return repo.AgentCanAccessSession(r.Context(), agentName, sid)
})
}
// GET /api/v1/agent/mail/{id}
//
// 读单封邮件全文(含抄送清单与附件)。收件箱只给摘要,
// 而要回给抄送方就必须先看清这封信到底发给了谁。
func AgentGetMail(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
mailID, ok := pathUUID(w, r, "id")
if !ok {
return
}
mail, err := repo.GetMailByID(r.Context(), mailID)
if err != nil {
Error(w, http.StatusNotFound, "Mail not found")
return
}
allowed, err := repo.AgentCanAccessSession(r.Context(), agentName, mail.SessionID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to check permission")
return
}
if !allowed {
Error(w, http.StatusForbidden, "无权访问该邮件")
return
}
fillAttachments(r, mail)
alias := repo.SessionAliasOf(r.Context(), mail.SessionID)
JSON(w, http.StatusOK, map[string]any{
"mail": mail,
"session_alias": alias,
// 回信地址与「我这个身份」都给现成的,省得插件自己拼。
// mail.ToWorkspace 是收件方那个地址的 path 位。
"reply_address": models.FormatAddress(mail.FromName, "", alias),
"self_address": models.FormatAddress(agentName, mail.ToWorkspace, alias),
"participants": participantsOf(mail, alias),
})
}
// GET /api/v1/agent/sessions/{id}/participants
//
// 列出该会话的全部参与方及各自的可投递地址。
//
// 这是「发送给抄收方 / 转发方」缺的最后一块:知道有谁、以及**用什么地址找到他**。
// 逐封邮件扫收件人与抄送,因为参与方是随往来变化的(一封转发就多一个人)。
func AgentSessionParticipants(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
sessionID, ok := pathUUID(w, r, "id")
if !ok {
return
}
allowed, err := repo.AgentCanAccessSession(r.Context(), agentName, sessionID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to check permission")
return
}
if !allowed {
Error(w, http.StatusForbidden, "无权访问该会话")
return
}
parts, err := repo.SessionParticipants(r.Context(), sessionID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list participants")
return
}
alias := repo.SessionAliasOf(r.Context(), sessionID)
out := make([]map[string]any, 0, len(parts))
for _, p := range parts {
out = append(out, map[string]any{
"name": p.Name,
"path": p.Path,
"roles": p.Roles, // from / to / cc 的并集
"is_self": p.Name == agentName,
"mail_count": p.MailCount,
// address 用**该参与方自己的 path**,不是调用方的:
// 抄送给 opencode@/a 与主发给 dsh@/b 是两个工作区,
// 用错 path 会让对方在别人的目录里开会话。
"address": addressForSession(p.Name, p.Path, alias),
})
}
JSON(w, http.StatusOK, map[string]any{
"session_id": sessionID,
"session_alias": alias,
"participants": out,
})
}
// participantsOf 从单封邮件里摘出参与方地址,供 AgentGetMail 直接返回。
// 与 SessionParticipants 的区别:这里只看这一封(发件人 + 收件人 + 抄送),
// 用于「回这封信时该带上谁」;那里看整条会话。
func participantsOf(m *models.Mail, alias string) []map[string]any {
out := []map[string]any{}
add := func(role, name, path string) {
if name == "" {
return
}
out = append(out, map[string]any{
"role": role,
"name": name,
"path": path,
"address": addressForSession(name, path, alias),
})
}
// from_workspace 对 Agent 存的是 Agent 名而非路径(历史遗留),
// 拿它当 path 会拼出错地址,所以发件人一侧留空 path 走默认。
add("from", m.FromName, "")
add("to", m.ToName, m.ToWorkspace)
for _, c := range m.CCList {
add("cc", c.Name, c.Path)
}
return out
}

View File

@ -1,11 +1,15 @@
package handler
import (
"database/sql"
"errors"
"net/http"
"strings"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/models"
"github.com/agentmail/gateway/internal/repo"
"github.com/go-chi/chi/v5"
)
// ---------- Agent ----------
@ -106,6 +110,14 @@ func RegisterAgent(w http.ResponseWriter, r *http.Request) {
}
if err := repo.CreateOrUpdateAgent(r.Context(), req.Name, secret, req.Platform, req.Workspaces); err != nil {
// 已停用的 Agent 不得靠重新注册复活。回 403 而不是 500
// 这是一个明确的策略拒绝,插件应当停止重试并把原因打出来。
if errors.Is(err, repo.ErrAgentDisabled) {
Error(w, http.StatusForbidden,
"Agent \""+req.Name+"\" 已被管理员停用,无法注册。"+
"如需重新启用,请在管理页「默认预算」里恢复它。")
return
}
Error(w, http.StatusInternalServerError, "Failed to register agent")
return
}
@ -208,3 +220,57 @@ func ListAgents(w http.ResponseWriter, r *http.Request) {
"agents": emptySlice(agents),
})
}
// ---------- 停用 / 恢复 ----------
type setAgentStatusRequest struct {
// Disabled true = 停用false = 恢复
Disabled bool `json:"disabled"`
}
// PUT /api/v1/admin/agents/{name}/status —— 停用或恢复一个 Agent
//
// 停用是可逆的「归档」,不是删除:
// - 邮件、会话、权限记录、转发幂等键全部保留(往来里有一半是人自己写的)
// - 从地址补全、GET /agents、可授权范围里消失
// - 全部密钥被撤销,插件拿不到新任务也发不出信
// - 重新注册会被拒(否则插件下次启动就把它复活了)
//
// 不提供彻底删除Agent 名与人类用户名共用命名空间,删掉之后历史邮件的
// from_name 指向一个不存在的名字,此时有人注册同名 Agent或人类账号
// 那些旧邮件会看起来像是他发的。
func AdminSetAgentStatus(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(chi.URLParam(r, "name"))
if name == "" {
Error(w, http.StatusBadRequest, "Missing agent name")
return
}
var req setAgentStatusRequest
if !DecodeBody(w, r, &req) {
return
}
revoked, err := repo.SetAgentDisabled(r.Context(), name, req.Disabled)
if errors.Is(err, sql.ErrNoRows) {
Error(w, http.StatusNotFound, "Agent 不存在: "+name)
return
}
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to update agent status")
return
}
resp := map[string]any{
"agent_name": name,
"disabled": req.Disabled,
}
if req.Disabled {
resp["keys_revoked"] = revoked
resp["detail"] = "已停用。邮件与会话保留;该 Agent 的密钥已全部撤销," +
"恢复后需要重新签发。"
} else {
resp["detail"] = "已恢复为离线状态。需要重新签发密钥,插件连上后自动转为在线。"
}
JSON(w, http.StatusOK, resp)
}

View File

@ -170,7 +170,7 @@ func doForward(w http.ResponseWriter, r *http.Request, mailID uuid.UUID, actor,
attachedCount = n
}
notifyRecipients(to, ccList, sessionID, newID, actor, subject)
notifyRecipients(r.Context(), to, ccList, sessionID, newID, actor, subject)
JSON(w, http.StatusOK, map[string]any{
"mail_id": newID.String(),

View File

@ -1,6 +1,7 @@
package handler
import (
"context"
"errors"
"fmt"
"net/http"
@ -92,14 +93,32 @@ func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, sub
if err != nil {
// 建失败要把名额还回去:那次新建实际上没有发生
repo.ReleaseNewSession(r.Context(), byAgent)
}
return id, nil, err
}
// `.new` 是一次性动作:它建完会话就用完了,之后要再投进这条会话只能靠
// `name@path.<别名>`。未命名会话既查不到FindNamedSessionFor 的
// `session_alias = $1` 对 NULL 不成立)也补全不出来,收件方与抄送方
// 除了回复那一封之外再也无法寻址到它 —— 再发一次 `.new` 只会建第三条会话。
// 因此这里立刻给一个别名,平台随后仍可用 SyncSessionAlias 改写它。
if aliasPtr == nil {
// 命名失败不该让发信失败:邮件本身能送达,代价只是这条会话暂时
// 只能用 reply_to 续谈,比整封退回轻。
_, _ = repo.EnsureSessionAlias(r.Context(), id, repo.AutoAliasFor(addr.Name, subject))
}
return id, nil, nil
case models.SessionDefault:
// 默认会话「从未通信则建立」也会产生新会话,但一个 name@path 只有一条,
// 不构成暴开的手段,因此不计入速率限制。
id, err := repo.FindOrCreateDefaultSession(r.Context(), addr.Name, addr.Path, fromAgent, subject)
if err != nil {
return id, nil, err
}
// 默认会话同样需要可寻址的别名:省略 session 位能投进来,但要**指名**
// 投进这一条(而不是「该 name@path 当前的默认会话」)仍然只能靠别名。
// 已有别名时 EnsureSessionAlias 直接返回,复用旧会话不会被改名。
_, _ = repo.EnsureSessionAlias(r.Context(), id, repo.AutoAliasFor(addr.Name, subject))
return id, nil, nil
default: // models.SessionNamed
id, err := repo.FindNamedSessionFor(r.Context(), addr.Name, addr.Path, addr.Session)
@ -238,7 +257,7 @@ func SendMail(w http.ResponseWriter, r *http.Request) {
return
}
notifyRecipients(to, ccList, sessionID, mailID, agentName, req.Subject)
notifyRecipients(r.Context(), to, ccList, sessionID, mailID, agentName, req.Subject)
// 回传会话别名与本任务剩余往返,让发件方知道后续用什么地址续谈、还能发几封
resp := map[string]any{
@ -273,8 +292,17 @@ func SendMail(w http.ResponseWriter, r *http.Request) {
// 三维地址 name@path.session 的 path 就是工作目录,插件要靠它建会话。
// 抄送给 opencode@/a 与主发给 dsh@/b 是两个不同的工作区,共用一份 payload
// 会让抄送方在别人的目录里开会话。
func notifyRecipients(to models.Address, cc []models.Address, sessionID, mailID uuid.UUID, from, subject string) {
payload := func(role, workspace string) map[string]interface{} {
//
// 同理,**每个收件方拿到的 reply_address 也是自己那个地址**,并且 session 位已经
// 把 `new` 换成真实别名:`.new` 建完会话就失效了,把原文那个 `x@/p.new`
// 送给参与方只会让它下一次又建一条新会话。
func notifyRecipients(ctx context.Context, to models.Address, cc []models.Address, sessionID, mailID uuid.UUID, from, subject string) {
// 别名在此时已由 resolveTarget 保证存在(`.new` 与默认会话都过 EnsureSessionAlias
// 仍可能为空的情形:命名写入失败(已吐日志)。此时退回省略 session 位,
// 而不是把 "new" 写进去 —— 后者会让参与方反复建新会话。
alias := repo.SessionAliasOf(ctx, sessionID)
payload := func(role, workspace, forName string) map[string]interface{} {
return map[string]interface{}{
"mail_id": mailID.String(),
"session_id": sessionID.String(),
@ -286,6 +314,14 @@ func notifyRecipients(to models.Address, cc []models.Address, sessionID, mailID
// 不带这一项的后果:插件只能自己拼一个临时目录,于是每封邮件都落在
// 不同的空目录里DSH / opencode 按 cwd 分组时全进「未分组」。
"to_workspace": workspace,
// session_alias 是这条会话今后的寻址名。没有它的话,收到 `.new`
// 邮件的一方只持有一个 send_mail 不接受的 session_id。
"session_alias": alias,
// reply_address 是「把回信发回这条会话」的现成地址。
// 插件不必自己拼(拼错了就是静默开新会话)。
"reply_address": models.FormatAddress(from, "", alias),
// self_address 是对方应当用来称呼自己的地址,供转发/报告时引用。
"self_address": models.FormatAddress(forName, workspace, alias),
}
}
@ -297,7 +333,7 @@ func notifyRecipients(to models.Address, cc []models.Address, sessionID, mailID
// 参与方去重:收件人 + 所有抄送 + 发件人自己(刷新他的发件箱)
seen := map[string]bool{}
sse.Default.SendToRecipient(to.Name, "new_mail", payload("to", to.Path))
sse.Default.SendToRecipient(to.Name, "new_mail", payload("to", to.Path, to.Name))
sse.Default.SendToRecipient(to.Name, "session_update", update)
seen[to.Name] = true
@ -306,7 +342,7 @@ func notifyRecipients(to models.Address, cc []models.Address, sessionID, mailID
continue
}
seen[c.Name] = true
sse.Default.SendToRecipient(c.Name, "new_mail", payload("cc", c.Path))
sse.Default.SendToRecipient(c.Name, "new_mail", payload("cc", c.Path, c.Name))
sse.Default.SendToRecipient(c.Name, "session_update", update)
}

View File

@ -121,7 +121,7 @@ func MeSendMail(w http.ResponseWriter, r *http.Request) {
return
}
notifyRecipients(to, ccList, sessionID, mailID, user.Username, req.Subject)
notifyRecipients(r.Context(), to, ccList, sessionID, mailID, user.Username, req.Subject)
resp := map[string]any{
"mail_id": mailID.String(),

View File

@ -57,6 +57,19 @@ func GetMailThread(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
serveMailThread(w, r, func(sid uuid.UUID) (bool, error) {
return repo.UserCanAccessSession(r.Context(), user, sid)
})
}
// serveMailThread 是人类与 Agent 两条对话树路径的公共实现。
//
// 差别只在**会话可见性判据**:人类走 UserCanAccessSession管理员全可见、
// 其余看参与过的会话Agent 走 AgentCanAccessSession只看自己参与过的
// 其余全部逻辑——上溯线索根、BFS 分页、锚点路径回填、detached 标记——两侧必须
// 完全一致:让 Agent 看到一棵与人类不同形状的树,只会让双方对「谁回了谁」
// 产生分歧,而这正是抄送协作要靠对话树解决的问题。
func serveMailThread(w http.ResponseWriter, r *http.Request, canAccess func(uuid.UUID) (bool, error)) {
mailID, ok := pathUUID(w, r, "id")
if !ok {
return
@ -69,7 +82,7 @@ func GetMailThread(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusNotFound, "Mail not found")
return
}
allowed, err := repo.UserCanAccessSession(r.Context(), user, mail.SessionID)
allowed, err := canAccess(mail.SessionID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to check permission")
return
@ -118,7 +131,7 @@ func GetMailThread(w http.ResponseWriter, r *http.Request) {
if v, ok := seen[sid]; ok {
return v
}
v, err := repo.UserCanAccessSession(r.Context(), user, sid)
v, err := canAccess(sid)
if err != nil {
v = false // 查不出来就当看不到:宁可少给,不可多给
}

View File

@ -115,6 +115,36 @@ func ParseAddress(s string) (Address, error) {
}, nil
}
// FormatAddress 把三段拼回可寻址的 name@path.session。
//
// **必须走这个函数而不是自己拼字符串**path 为空时(人类用户没有工作区)
// 朴素拼接得到 "admin.silent-harbor",而它没有 @ParseAddress 会把整串当成
// 名字session 位丢失,地址静默失效。空 path 也必须留下那个 @ 与 . ——
// "admin@.silent-harbor" 才解析成 name=admin path="" session=silent-harbor。
//
// session 传空则省略该位(默认会话语义)。
func FormatAddress(name, path, session string) string {
name = strings.TrimSpace(name)
path = strings.TrimSpace(path)
session = strings.TrimSpace(session)
if name == "" {
return ""
}
if session == "" {
if path == "" {
return name
}
return name + "@" + path
}
return name + "@" + path + "." + session
}
// WithSession 返回同一收件方在指定会话下的地址。
// 用于把 .new 换成刚建出来的会话别名 —— 参与方拿到的地址必须是能再次投递的那个。
func (a Address) WithSession(session string) string {
return FormatAddress(a.Name, a.Path, session)
}
// ParseAddressList 解析逗号/分号/空白分隔的多个地址(用于 CC
func ParseAddressList(s string) ([]Address, error) {
raw := strings.TrimSpace(s)

View File

@ -0,0 +1,253 @@
package repo
import (
"context"
"database/sql"
"errors"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid"
)
// 停用是可逆的「归档」,不是删除。这组测试钉住三件事:
// 停用后从候选里消失、密钥被撤销、重新注册不能复活它。
func TestSetAgentDisabledHidesFromCandidates(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
for _, n := range []string{"keeper", "goner"} {
if err := CreateOrUpdateAgent(ctx, n, "s", "test", nil); err != nil {
t.Fatalf("注册 %s: %v", n, err)
}
}
if _, err := SetAgentDisabled(ctx, "goner", true); err != nil {
t.Fatalf("停用: %v", err)
}
// 默认列表地址补全、GET /agents、可授权范围都走这条不含已停用的
got, err := ListAgents(ctx, "")
if err != nil {
t.Fatalf("ListAgents: %v", err)
}
names := map[string]bool{}
for _, a := range got {
names[a.Name] = true
}
if names["goner"] {
t.Error("已停用的 Agent 仍出现在默认列表里 —— 人会把任务派给一个不会响应的地址")
}
if !names["keeper"] {
t.Error("停用一个把别的也弄没了")
}
// statusFilter="all" 时要能看到 —— 那是管理页恢复它的唯一入口
all, err := ListAgents(ctx, "all")
if err != nil {
t.Fatalf("ListAgents(all): %v", err)
}
found := false
for _, a := range all {
if a.Name == "goner" {
found = true
if a.Status != "disabled" {
t.Errorf("状态应为 disabled实际 %q", a.Status)
}
}
}
if !found {
t.Error("statusFilter=all 也看不到已停用的,就再也无法恢复它了")
}
}
func TestSetAgentDisabledRevokesKeys(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
t.Fatalf("注册: %v", err)
}
admin := seedAdminForTest(t, ctx)
for i := 0; i < 2; i++ {
if _, err := CreateAgentKey(ctx, "bot", "permanent", "k", 0, admin, ""); err != nil {
t.Fatalf("建密钥: %v", err)
}
}
revoked, err := SetAgentDisabled(ctx, "bot", true)
if err != nil {
t.Fatalf("停用: %v", err)
}
if revoked != 2 {
t.Errorf("应撤销 2 把密钥,实际 %d", revoked)
}
keys, err := ListAgentKeys(ctx, "bot")
if err != nil {
t.Fatalf("ListAgentKeys: %v", err)
}
if len(keys) != 0 {
t.Errorf("停用后仍留着 %d 把密钥 —— 插件还能用它调 /mail/send"+
"停用的语义是「不再参与工作」而不只是「不出现在补全里」", len(keys))
}
}
func TestDisabledAgentCannotReRegister(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
t.Fatalf("首次注册: %v", err)
}
if _, err := SetAgentDisabled(ctx, "bot", true); err != nil {
t.Fatalf("停用: %v", err)
}
// 插件启动时会重新注册。不拒的话 status 被写回 online停用等于没做。
err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil)
if !errors.Is(err, ErrAgentDisabled) {
t.Fatalf("已停用的 Agent 重新注册应当被拒,实际 err=%v", err)
}
disabled, err := AgentDisabled(ctx, "bot")
if err != nil {
t.Fatalf("AgentDisabled: %v", err)
}
if !disabled {
t.Error("注册尝试把停用状态冲掉了")
}
}
func TestHeartbeatDoesNotReviveDisabledAgent(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
t.Fatalf("注册: %v", err)
}
if _, err := SetAgentDisabled(ctx, "bot", true); err != nil {
t.Fatalf("停用: %v", err)
}
// 心跳是 30 秒一次的。不排除 disabled 的话停用最多维持半分钟。
if _, err := HeartbeatAgent(ctx, "bot"); err != nil {
t.Fatalf("心跳本身不该报错: %v", err)
}
disabled, _ := AgentDisabled(ctx, "bot")
if !disabled {
t.Error("心跳把已停用的 Agent 改回在线了")
}
}
func TestRestoreAgentGoesOfflineNotOnline(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
t.Fatalf("注册: %v", err)
}
if _, err := SetAgentDisabled(ctx, "bot", true); err != nil {
t.Fatalf("停用: %v", err)
}
if _, err := SetAgentDisabled(ctx, "bot", false); err != nil {
t.Fatalf("恢复: %v", err)
}
all, _ := ListAgents(ctx, "all")
for _, a := range all {
if a.Name != "bot" {
continue
}
// 恢复成 online 会让界面显示一个其实没在跑的 Agent 为在线;
// 它是否真的活着由下一次心跳决定。
if a.Status != "offline" {
t.Errorf("恢复后应为 offline实际 %q", a.Status)
}
}
// 恢复后能重新注册
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
t.Errorf("恢复后应当能重新注册: %v", err)
}
}
func TestSetAgentDisabledUnknownAgent(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
_, err := SetAgentDisabled(ctx, "nope", true)
if !errors.Is(err, sql.ErrNoRows) {
t.Errorf("停用不存在的 Agent 应回 ErrNoRows实际 %v", err)
}
}
// 停用不得动邮件与会话 —— 往来里有一半是人自己写的。
func TestSetAgentDisabledKeepsMailAndSessions(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
t.Fatalf("注册: %v", err)
}
sid, err := CreateSession(ctx, nil, "bot", "一件事", "/tmp/ws")
if err != nil {
t.Fatalf("建会话: %v", err)
}
if _, err := CreateMail(ctx, sid, nil,
"human", "", "bot", "/tmp/ws", "主题", "正文", nil); err != nil {
t.Fatalf("建邮件: %v", err)
}
if _, err := SetAgentDisabled(ctx, "bot", true); err != nil {
t.Fatalf("停用: %v", err)
}
mails, err := ListInbox(ctx, "bot", "all", 10)
if err != nil {
t.Fatalf("ListInbox: %v", err)
}
if len(mails) != 1 {
t.Errorf("停用把邮件删了:剩 %d 封。那些往来里有一半是人自己写的", len(mails))
}
}
// 模型范围与平台会话镜像也保留:恢复后不必重配。
func TestSetAgentDisabledKeepsModelScope(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
t.Fatalf("注册: %v", err)
}
if err := SetAllowedModels(ctx, "bot", []ModelRef{{Provider: "p", Model: "m"}}); err != nil {
t.Fatalf("设范围: %v", err)
}
if _, err := SetAgentDisabled(ctx, "bot", true); err != nil {
t.Fatalf("停用: %v", err)
}
allowed, err := ListAllowedModels(ctx, "bot")
if err != nil {
t.Fatalf("ListAllowedModels: %v", err)
}
if len(allowed) != 1 {
t.Errorf("停用把模型范围清了,恢复后管理员得重配一遍:%+v", allowed)
}
}
// seedAdminForTest 插一个管理员并返回它的 user_idCreateAgentKey 要 created_by
func seedAdminForTest(t *testing.T, ctx context.Context) uuid.UUID {
t.Helper()
var id uuid.UUID
err := db.DB.QueryRowContext(ctx,
`INSERT INTO users (username, display_name, password_hash, role)
VALUES ('key-admin', 'Admin', 'x', 'admin') RETURNING user_id`).Scan(&id)
if err != nil {
t.Fatalf("seed admin: %v", err)
}
return id
}

View File

@ -0,0 +1,177 @@
package repo
import (
"context"
"fmt"
"strings"
"unicode"
"unicode/utf8"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid"
)
// 自动别名 —— 让 `.new` 建出来的会话立刻可被寻址。
//
// # 为什么必须自动命名
//
// `session` 位三态里 `new` 是**一次性动作**:它建出会话就用完了。之后要再投进
// 同一条会话,只有两条路 —— `reply_to` 某封具体邮件,或者 `name@path.<别名>`。
// 而 `CreateSession(alias=nil)` 建出来的会话别名是 NULL于是
//
// - `FindNamedSessionFor` 查不到它(`WHERE session_alias = $1` 对 NULL 不成立)
// - `SuggestSessionCandidates` 跳过它(`session_alias IS NOT NULL AND <> ''`
// - 参与方拿到的 `new_mail` 里 `session_alias` 是空串
//
// 结果是:被抄送方收到一封 `x@/p.new` 的邮件,**除了回复那一封之外无法再投进这条
// 会话**。再发一次 `x@/p.new` 只会建第三条会话。这不是能力缺失,是寻址断链。
//
// 原先的设计假定平台插件会通过 `POST /sessions/{id}/sync` 把模型生成的标题回写成
// 别名,于是「未命名」只是短暂状态。但两件事让这个假定不成立:
//
// 1. 人类发的邮件根本没有平台侧,永远等不到回写;
// 2. 回写发生在模型跑完第一轮之后,而抄送方**在那之前**就要决定回信地址。
//
// 因此本侧先给一个可用的别名,平台随后仍可用 `SyncSessionAlias` 改写它 ——
// `alias_source` 保持 `platform` 正是为此:自动名不是人定的名,不该挡住平台命名。
//
// # 为什么不复用 SyncSessionAlias
//
// 那个函数假定「会话已存在、现在要改名」,并且会跳过 `manual`。这里的场景是
// 「刚建完、还没有名字」,且必须在**建会话的同一个请求里**完成,否则中间那一瞬
// 发出的 SSE 仍然带空别名。
// aliasMaxBytes 与 normalizeAlias 的截断上限一致sessions.session_alias 为 VARCHAR(128))。
const aliasMaxBytes = 128
// autoAliasAttempts 是撞名后追加 -2、-3… 的尝试次数上限。
// 与 SyncSessionAlias 取同一个数量级:同一主题在同一天内开几十条会话已属异常,
// 真到了上限说明调用方在刷会话,此时报错比继续找空位更有价值。
const autoAliasAttempts = 50
// AutoAliasFor 依据收件人与主题拼一个候选别名(未做唯一性检查)。
//
// 形如 `dsh-重构导入路径`:前缀用收件方名字,后缀用主题。**两者都要**——
// 只用主题时「服务恢复验证」这类通用主题会在不同 Agent 之间反复撞名,
// 只用名字则同一个 Agent 的所有会话都叫 `dsh-2`、`dsh-3`,看不出在聊什么。
//
// 主题为空(少见但合法)时退回单独的名字,由调用方靠后缀去重。
func AutoAliasFor(toName, subject string) string {
base := sanitizeAliasPart(toName)
topic := sanitizeAliasPart(subject)
switch {
case base == "" && topic == "":
// 两边都拿不出可用字符(例如主题全是标点、名字为空)。
// 返回空串让调用方走随机兜底,不要在这里编造。
return ""
case base == "":
return truncateAlias(topic)
case topic == "":
return truncateAlias(base)
default:
return truncateAlias(base + "-" + topic)
}
}
// EnsureSessionAlias 保证会话拥有一个可寻址的别名,返回最终别名。
//
// 已有别名时原样返回,不做任何写入 —— 这让它可以被无条件调用,
// 包括「默认会话」路径上那条可能是刚建的、也可能是复用的会话。
//
// 撞名时追加 -2、-3… 后缀;`want` 为空或全部被占用时退回
// `session-<uuid 前 8 位>`:一个能寻址的丑名字,远胜于没有名字。
func EnsureSessionAlias(ctx context.Context, id uuid.UUID, want string) (string, error) {
if cur := SessionAliasOf(ctx, id); cur != "" {
return cur, nil
}
cands := make([]string, 0, autoAliasAttempts+1)
if want != "" {
for i := 0; i < autoAliasAttempts; i++ {
if i == 0 {
cands = append(cands, want)
continue
}
cands = append(cands, truncateAlias(fmt.Sprintf("%s-%d", want, i+1)))
}
}
// 兜底uuid 前 8 位。碰撞概率可忽略,且与 want 无关,
// 因此即便主题里一个可用字符都没有也总能拿到别名。
cands = append(cands, "session-"+id.String()[:8])
for _, c := range cands {
// 条件写入:`session_alias IS NULL OR = ''` 保证并发下只有一方写成功,
// 另一方 RowsAffected=0随后重读拿到对方写的名字 ——
// 两个请求都返回同一个别名,而不是各自以为自己命名成功。
res, err := db.DB.ExecContext(ctx,
`UPDATE sessions SET session_alias = $1, updated_at = NOW()
WHERE session_id = $2 AND (session_alias IS NULL OR session_alias = '')`,
c, id)
if err != nil {
if db.IsUniqueViolation(err) {
continue // 别名被别的会话占了,试下一个后缀
}
return "", err
}
if n, _ := res.RowsAffected(); n == 0 {
// 期间别人(并发请求或平台同步)已经命名过,尊重那个名字
if cur := SessionAliasOf(ctx, id); cur != "" {
return cur, nil
}
// 写不进去且读不到名字,只可能是会话刚被删
return "", fmt.Errorf("会话 %s 已不存在,无法分配别名", id)
}
return c, nil
}
return "", fmt.Errorf("别名 %q 连同 -2..-%d 后缀与 uuid 兜底均被占用", want, autoAliasAttempts)
}
// sanitizeAliasPart 把任意文本压成别名可用的片段。
//
// 规则与 normalizeAlias 一致(非法字符换 -、压缩连续 -、去首尾 -
// 另外多做两件事:
//
// - **去掉 Markdown / 标点噪声**:主题里的 `[联调]`、`—`、`` 变成一串
// 破折号毫无信息量。只保留字母、数字与非标点的 Unicode 字符(中文、日文等)。
// - **压缩空白**`Re: 服务恢复验证` → `Re-服务恢复验证`,而不是 `Re--服务恢复验证`。
//
// 保留中文是刻意的:本项目的会话主题多为中文,转拼音需要额外依赖,
// 而 `dsh-重构导入路径` 作为地址完全可用(三维寻址只忌 `. / @` 与空白)。
func sanitizeAliasPart(s string) string {
var b strings.Builder
lastDash := false
for _, r := range s {
keep := unicode.IsLetter(r) || unicode.IsDigit(r)
if keep {
b.WriteRune(r)
lastDash = false
continue
}
// 其余一切(空白、标点、符号、寻址保留字符)都折成单个 -
if !lastDash && b.Len() > 0 {
b.WriteByte('-')
lastDash = true
}
}
out := strings.Trim(b.String(), "-")
// "new" 是寻址保留字,作为整体别名时必须避开。
// 加前缀而不是拒绝:调用方给的素材没有错,是这个词恰好被占用。
if out == "new" {
return "session-new"
}
return out
}
// truncateAlias 按字节截断且不切坏多字节字符(中文主题很容易超 128 字节)。
func truncateAlias(s string) string {
if len(s) <= aliasMaxBytes {
return strings.Trim(s, "-")
}
cut := s[:aliasMaxBytes]
for len(cut) > 0 && !utf8.ValidString(cut) {
cut = cut[:len(cut)-1]
}
return strings.Trim(cut, "-")
}

View File

@ -0,0 +1,283 @@
package repo
import (
"context"
"strings"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid"
)
// 这一组测试守的是一条不变量:**`.new` 建出来的会话必须立刻可寻址**。
//
// 破坏方式很隐蔽 —— 邮件照样送达、收件人照样能回复那一封,只有「指名投进同一条
// 会话」这个动作静默失败(`FindNamedSessionFor` 查不到未命名会话),再发一次
// `.new` 就多一条平行会话。所以这里的断言都落在「事后能不能按别名找回来」上,
// 而不是「有没有报错」。
func TestAutoAliasForCombinesNameAndSubject(t *testing.T) {
// 名字与主题都要在:只用主题时「服务恢复验证」这类通用主题会跨 Agent 撞名,
// 只用名字则同一个 Agent 的会话全叫 dsh-2、dsh-3看不出在聊什么。
got := AutoAliasFor("dsh", "重构导入路径")
if got != "dsh-重构导入路径" {
t.Fatalf("want dsh-重构导入路径, got %q", got)
}
}
func TestAutoAliasForStripsAddressingChars(t *testing.T) {
// 别名要参与 name@path.session 的切分,含 . / @ 或空白会让地址解析歧义。
// 主题里的 Markdown 与标点噪声([联调]、—、:)也不该变成一串破折号。
cases := []struct{ in, want string }{
{"[联调] llmsproxy / ModelRouter — 请提供部署现状", "x-联调-llmsproxy-ModelRouter-请提供部署现状"},
{"a.b.c", "x-a-b-c"},
{"has spaces", "x-has-spaces"},
{"user@host", "x-user-host"},
{"Re: 服务恢复验证", "x-Re-服务恢复验证"},
}
for _, c := range cases {
got := AutoAliasFor("x", c.in)
if got != c.want {
t.Errorf("AutoAliasFor(x, %q) = %q, want %q", c.in, got, c.want)
}
if strings.ContainsAny(got, ". \t/@") {
t.Errorf("别名 %q 含寻址保留字符,会破坏地址解析", got)
}
}
}
func TestAutoAliasForAvoidsReservedNew(t *testing.T) {
// "new" 是 session 位的保留字。别名若正好是它,`x@/p.new` 就同时是
// 「投进这条会话」与「再建一条」两种意思。
if got := AutoAliasFor("", "new"); got == "new" {
t.Fatal("别名不得为保留字 new")
}
}
func TestAutoAliasForEmptyMaterial(t *testing.T) {
// 素材里一个可用字符都没有时返回空串,交由 EnsureSessionAlias 走 uuid 兜底,
// 而不是在这里编造一个名字。
if got := AutoAliasFor("", "···"); got != "" {
t.Fatalf("want empty, got %q", got)
}
}
func TestAutoAliasForTruncatesAtByteLimit(t *testing.T) {
// session_alias 是 VARCHAR(128),而中文主题很容易超;按字节截断时
// 不能把多字节字符切坏(切坏后写库会得到非法 UTF-8
got := AutoAliasFor("bot", strings.Repeat("中", 200))
if len(got) > aliasMaxBytes {
t.Fatalf("别名 %d 字节,超过上限 %d", len(got), aliasMaxBytes)
}
if !utf8Valid(got) {
t.Fatal("截断切坏了多字节字符")
}
}
func utf8Valid(s string) bool {
for _, r := range s {
if r == '\uFFFD' {
return false
}
}
return true
}
func TestEnsureSessionAliasMakesNewSessionAddressable(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
// 复刻 `.new` 的真实路径CreateSession(alias=nil) —— 别名是 NULL。
sid, err := CreateSession(ctx, nil, "admin", "重构导入路径", "/home/program/agentmail")
if err != nil {
t.Fatalf("建会话: %v", err)
}
seedMailForSession(t, sid, "admin", "dsh", "/home/program/agentmail")
// 命名前:按别名找不回来(这正是线上那条断链)
if _, err := FindNamedSessionFor(ctx, "dsh", "/home/program/agentmail", "dsh-重构导入路径"); err == nil {
t.Fatal("未命名会话竟然能按别名找到,测试前提不成立")
}
alias, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("dsh", "重构导入路径"))
if err != nil {
t.Fatalf("命名: %v", err)
}
if alias == "" {
t.Fatal("别名为空")
}
// 命名后:收件方能指名投回这条会话,而不是又开一条
got, err := FindNamedSessionFor(ctx, "dsh", "/home/program/agentmail", alias)
if err != nil {
t.Fatalf("按别名寻址: %v", err)
}
if got != sid {
t.Fatalf("别名 %q 指向 %s应指向 %s", alias, got, sid)
}
}
func TestEnsureSessionAliasKeepsExistingName(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
// 调用方显式命名过(发信时传了 session_alias标记为 manual。
// 自动命名绝不能覆盖它 —— 人记住的地址不该下一秒失效。
want := "llmsproxy-joint"
sid, err := CreateSession(ctx, &want, "dsh", "联调", "/home/program/llmsproxy")
if err != nil {
t.Fatalf("建会话: %v", err)
}
got, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("opencode", "别的主题"))
if err != nil {
t.Fatalf("命名: %v", err)
}
if got != want {
t.Fatalf("已有别名被改写成 %q应保持 %q", got, want)
}
}
func TestEnsureSessionAliasIsIdempotent(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
// 默认会话路径上 EnsureSessionAlias 会被每封邮件调用一次
// (会话可能是刚建的也可能是复用的),因此重复调用必须返回同一个名字。
sid, _ := CreateSession(ctx, nil, "admin", "服务恢复验证", "/tmp/ws")
first, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("dsh", "服务恢复验证"))
if err != nil {
t.Fatalf("首次命名: %v", err)
}
second, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("dsh", "服务恢复验证"))
if err != nil {
t.Fatalf("二次命名: %v", err)
}
if first != second {
t.Fatalf("重复调用给出两个别名: %q vs %q", first, second)
}
}
func TestEnsureSessionAliasSuffixesOnCollision(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
// 同一个 Agent + 同一主题会反复出现(「服务恢复验证」发两次)。
// 别名全局唯一(负责寻址),撞名必须让位而不是报错 —— 发信不该因为
// 主题重复而失败。
want := AutoAliasFor("dsh", "服务恢复验证")
a, _ := CreateSession(ctx, nil, "admin", "服务恢复验证", "/tmp/ws")
aliasA, err := EnsureSessionAlias(ctx, a, want)
if err != nil {
t.Fatalf("首个会话命名: %v", err)
}
b, _ := CreateSession(ctx, nil, "admin", "服务恢复验证", "/tmp/ws")
aliasB, err := EnsureSessionAlias(ctx, b, want)
if err != nil {
t.Fatalf("第二个会话命名: %v", err)
}
if aliasA == aliasB {
t.Fatalf("两条会话拿到同一个别名 %q", aliasA)
}
if aliasB != want+"-2" {
t.Fatalf("撞名后缀应为 %s-2实际 %q", want, aliasB)
}
// 两个别名各自指向自己那条会话,没有相互覆盖
for alias, expect := range map[string]uuid.UUID{aliasA: a, aliasB: b} {
var got uuid.UUID
err := db.DB.QueryRowContext(ctx,
`SELECT session_id FROM sessions WHERE session_alias = $1`, alias).Scan(&got)
if err != nil {
t.Fatalf("查别名 %q: %v", alias, err)
}
if got != expect {
t.Errorf("别名 %q 指向 %s应指向 %s", alias, got, expect)
}
}
}
func TestEnsureSessionAliasFallsBackToUUID(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
// 主题与名字都拿不出可用字符时AutoAliasFor 返回空串),
// 仍必须得到一个能寻址的别名 —— 丑名字远胜于没有名字。
sid, _ := CreateSession(ctx, nil, "admin", "···", "/tmp/ws")
alias, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("", "···"))
if err != nil {
t.Fatalf("兜底命名: %v", err)
}
if alias == "" {
t.Fatal("兜底后别名仍为空")
}
if !strings.HasPrefix(alias, "session-") {
t.Fatalf("兜底别名应形如 session-xxxxxxxx实际 %q", alias)
}
}
func TestEnsureSessionAliasSurfacesInSuggestions(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
// 补全候选只收「有别名的非归档会话」(`session_alias IS NOT NULL AND <> ''`)。
// 自动命名的另一半价值就在这里:命名前这条会话在人类的三段式补全里
// 也是不可见的,人同样只能靠回复某封邮件才能续谈。
seedUser(t, ctx, "admin")
sid, _ := CreateSession(ctx, nil, "admin", "重构导入路径", "/home/program/agentmail")
seedMailForSession(t, sid, "admin", "dsh", "/home/program/agentmail")
before, err := SuggestSessionCandidates(ctx, "admin", "dsh", "/home/program/agentmail")
if err != nil {
t.Fatalf("补全(命名前): %v", err)
}
for _, c := range before {
if c.Source == "mail" {
t.Fatalf("未命名会话不该出现在补全里,却拿到 %+v", c)
}
}
alias, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("dsh", "重构导入路径"))
if err != nil {
t.Fatalf("命名: %v", err)
}
after, err := SuggestSessionCandidates(ctx, "admin", "dsh", "/home/program/agentmail")
if err != nil {
t.Fatalf("补全(命名后): %v", err)
}
found := false
for _, c := range after {
if c.Alias == alias {
found = true
}
}
if !found {
t.Fatalf("命名后 %q 仍未出现在补全候选里: %+v", alias, after)
}
}
// seedMailForSession 往会话里塞一封邮件。
// FindNamedSessionFor 与 SuggestSessionCandidates 都要求「该收件人参与过」,
// 只建会话不建邮件的话两者都查不到,测试会得出错误结论。
func seedMailForSession(t *testing.T, sid uuid.UUID, from, to, workspace string) {
t.Helper()
if _, err := CreateMail(context.Background(), sid, nil,
from, "", to, workspace, "主题", "正文", nil); err != nil {
t.Fatalf("seed mail: %v", err)
}
}
// seedUser 插一个用户。SuggestSessionCandidates 的可见性条件要查 users 表。
func seedUser(t *testing.T, ctx context.Context, username string) {
t.Helper()
_, err := db.DB.ExecContext(ctx,
`INSERT INTO users (username, display_name, password_hash, role)
VALUES ($1, $1, 'x', 'admin')`, username)
if err != nil {
t.Fatalf("seed user %s: %v", username, err)
}
}

View File

@ -0,0 +1,135 @@
package repo
import (
"context"
"encoding/json"
"sort"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/models"
"github.com/google/uuid"
)
// Participant 是一条会话里的一个参与方。
//
// Path 是该参与方**自己那个地址的 path 位**,不是别人的:一封主发给 dsh@/b、
// 抄送给 opencode@/a 的邮件里,两人的工作目录不同,混用会让对方在别人的目录里
// 开会话(生产上已发生过一次,见 PLUGIN-CONTRACT 9.3)。
type Participant struct {
Name string `json:"name"`
Path string `json:"path"`
// Roles 是该参与方在这条会话里出现过的全部身份from / to / cc 的并集。
// 用集合而非单值:同一个人常常既发过信也被抄送过,只留最后一个身份会让
// 「谁是这件事的负责人」这个判断出错。
Roles []string `json:"roles"`
// MailCount 是该参与方作为发件人的邮件数。用来回答「谁还没回」——
// 参与方列表里 from 计数为 0 的那个就是还没开口的人。
MailCount int `json:"mail_count"`
}
// SessionParticipants 列出会话的全部参与方及各自的地址素材。
//
// 为什么要逐封扫而不是看 sessions 表:**参与方是随往来增长的**。会话建立时
// 只有发件人与收件人一封抄送、一次转发都会带进新的人。sessions 表里只有
// from_agent 一个名字,回答不了「这条线索上现在有谁」。
//
// 排序按首次出现顺序created_at。这让主收件人稳定排在抄送方之前
// 模型据此判断「谁是负责人、谁是配合方」——按名字排序会丢掉这个信息。
func SessionParticipants(ctx context.Context, sessionID uuid.UUID) ([]Participant, error) {
rows, err := db.DB.QueryContext(ctx, `
SELECT from_name, COALESCE(from_workspace,''),
to_name, COALESCE(to_workspace,''),
cc_list
FROM mails
WHERE session_id = $1
ORDER BY created_at ASC, mail_id ASC
`, sessionID)
if err != nil {
return nil, err
}
defer rows.Close()
type acc struct {
p Participant
roles map[string]bool
order int
}
seen := map[string]*acc{}
next := 0
// note 记录一次「某人以某身份出现」。
//
// path 只在**当前为空且新值非空**时补写:同一个人可能在不同邮件里带不同
// path先被抄送到 /a后被主发到 /b。保留首个非空值而不是最后一个
// 与排序口径一致(首次出现顺序),也避免一封转发把地址改指到别处。
note := func(name, path, role string, isSender bool) {
if name == "" {
return
}
a, ok := seen[name]
if !ok {
a = &acc{
p: Participant{Name: name, Path: path},
roles: map[string]bool{},
order: next,
}
next++
seen[name] = a
}
if a.p.Path == "" && path != "" {
a.p.Path = path
}
a.roles[role] = true
if isSender {
a.p.MailCount++
}
}
for rows.Next() {
var fromName, fromWS, toName, toWS string
var ccRaw []byte
if err := rows.Scan(&fromName, &fromWS, &toName, &toWS, &ccRaw); err != nil {
return nil, err
}
// **发件人一侧不取 from_workspace 当 path。** Agent 回信时那一列存的是
// Agent 名而不是路径历史遗留FindOrCreateDefaultSession 的注释里也提到
// 同一个坑)。拿它拼地址会得到 `dsh@dsh.alias` 这种投不出去的东西。
note(fromName, "", "from", true)
note(toName, toWS, "to", false)
if len(ccRaw) > 0 {
var cc []models.Address
if json.Unmarshal(ccRaw, &cc) == nil {
for _, c := range cc {
note(c.Name, c.Path, "cc", false)
}
}
}
}
if err := rows.Err(); err != nil {
return nil, err
}
out := make([]Participant, 0, len(seen))
for _, a := range seen {
a.p.Roles = sortedKeys(a.roles)
out = append(out, a.p)
}
sort.Slice(out, func(i, j int) bool {
return seen[out[i].Name].order < seen[out[j].Name].order
})
return out, nil
}
// sortedKeys 给出稳定顺序的角色列表。
// map 迭代顺序随机,不排序的话同一条会话每次返回的 roles 顺序都不同,
// 插件侧做 diff 或缓存时会误判为「参与方变了」。
func sortedKeys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}

View File

@ -0,0 +1,170 @@
package repo
import (
"context"
"testing"
"github.com/agentmail/gateway/internal/models"
"github.com/google/uuid"
)
// 参与方列表要回答的是「这条线索上现在有谁、用什么地址找到他、谁还没开口」。
// 三个问题里每一个都曾经答错过:
// - 有谁sessions 表只有 from_agent 一个名字,抄送方与转发引入的人都不在里面
// - 什么地址:拿 from_workspace 当 path 会拼出 dsh@dsh.alias 这种投不出去的东西
// - 谁还没回:只留最后一个身份的话,既发过信又被抄送过的人会被算成纯配合方
func TestSessionParticipantsIncludesCC(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, _ := CreateSession(ctx, nil, "admin", "抄收联调", "/home/program/llmsproxy")
// 复刻线上那封admin 主发 dsh抄送 opencode
mustMail(t, sid, "admin", "", "dsh", "/home/program/llmsproxy",
[]models.Address{{Name: "opencode", Path: "/home", Session: "new", Raw: "opencode@/home.new"}})
parts, err := SessionParticipants(ctx, sid)
if err != nil {
t.Fatalf("列参与方: %v", err)
}
byName := map[string]Participant{}
for _, p := range parts {
byName[p.Name] = p
}
for _, want := range []string{"admin", "dsh", "opencode"} {
if _, ok := byName[want]; !ok {
t.Errorf("参与方缺 %s实得 %+v", want, parts)
}
}
// 抄送方的 path 必须是它自己那个地址的 path 位,不是主收件人的。
// 用错的后果:对方在别人的工作目录里开会话。
if got := byName["opencode"].Path; got != "/home" {
t.Errorf("opencode 的 path = %q应为 /home它自己地址的 path 位)", got)
}
if got := byName["dsh"].Path; got != "/home/program/llmsproxy" {
t.Errorf("dsh 的 path = %q应为 /home/program/llmsproxy", got)
}
}
func TestSessionParticipantsSenderPathStaysEmpty(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, _ := CreateSession(ctx, nil, "admin", "回信", "/tmp/ws")
// Agent 回信时 from_workspace 存的是 Agent 名而非路径(历史遗留)。
// 若把它当 path地址会拼成 dsh@dsh.alias —— 投不出去。
mustMail(t, sid, "dsh", "dsh", "admin", "", nil)
parts, _ := SessionParticipants(ctx, sid)
for _, p := range parts {
if p.Name == "dsh" && p.Path == "dsh" {
t.Fatal("发件人的 path 取了 from_workspace那列存的是 Agent 名),会拼出无效地址")
}
}
}
func TestSessionParticipantsMergesRoles(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, _ := CreateSession(ctx, nil, "admin", "往返", "/tmp/ws")
mustMail(t, sid, "admin", "", "dsh", "/tmp/ws", nil) // admin=from, dsh=to
mustMail(t, sid, "dsh", "dsh", "admin", "", nil) // dsh=from, admin=to
parts, _ := SessionParticipants(ctx, sid)
for _, p := range parts {
if len(p.Roles) != 2 {
t.Errorf("%s 的 roles = %v双方都该同时有 from 与 to", p.Name, p.Roles)
}
}
}
func TestSessionParticipantsCountsOnlySends(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, _ := CreateSession(ctx, nil, "admin", "谁还没回", "/tmp/ws")
mustMail(t, sid, "admin", "", "dsh", "/tmp/ws",
[]models.Address{{Name: "opencode", Path: "/tmp/ws", Raw: "opencode@/tmp/ws"}})
mustMail(t, sid, "dsh", "dsh", "admin", "", nil)
parts, _ := SessionParticipants(ctx, sid)
got := map[string]int{}
for _, p := range parts {
got[p.Name] = p.MailCount
}
// MailCount 只数「作为发件人」的邮件:抄送方 opencode 一封都没发,
// 计数为 0 正是「还没开口的人」这个判断的依据。
if got["opencode"] != 0 {
t.Errorf("opencode 只被抄送未发信MailCount 应为 0实为 %d", got["opencode"])
}
if got["admin"] != 1 || got["dsh"] != 1 {
t.Errorf("admin/dsh 各发过一封,实为 %d/%d", got["admin"], got["dsh"])
}
}
func TestSessionParticipantsKeepsFirstSeenOrder(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, _ := CreateSession(ctx, nil, "admin", "顺序", "/tmp/ws")
mustMail(t, sid, "admin", "", "dsh", "/tmp/ws",
[]models.Address{{Name: "opencode", Path: "/home", Raw: "opencode@/home"}})
parts, _ := SessionParticipants(ctx, sid)
// 按首次出现排序,让主收件人稳定排在抄送方之前 ——
// 模型据此判断谁是负责人、谁是配合方;按名字排序会丢掉这个信息。
want := []string{"admin", "dsh", "opencode"}
if len(parts) != len(want) {
t.Fatalf("参与方数量 %d期望 %d: %+v", len(parts), len(want), parts)
}
for i, w := range want {
if parts[i].Name != w {
t.Errorf("第 %d 位是 %s期望 %s", i, parts[i].Name, w)
}
}
}
func TestSessionParticipantsPrefersFirstNonEmptyPath(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, _ := CreateSession(ctx, nil, "admin", "改指", "/tmp/a")
// 同一个人先被抄送到 /home后被主发到 /tmp/b。
// 保留首个非空值,与排序口径一致,也避免一封转发把地址改指到别处。
mustMail(t, sid, "admin", "", "dsh", "/tmp/a",
[]models.Address{{Name: "opencode", Path: "/home", Raw: "opencode@/home"}})
mustMail(t, sid, "admin", "", "opencode", "/tmp/b", nil)
parts, _ := SessionParticipants(ctx, sid)
for _, p := range parts {
if p.Name == "opencode" && p.Path != "/home" {
t.Fatalf("opencode 的 path = %q应保持首次出现的 /home", p.Path)
}
}
}
func TestSessionParticipantsEmptySession(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
// 会话刚建、还没有邮件。返回空列表而不是报错:
// 调用方拿到空表能正常渲染「暂无参与方」,拿到 error 只能整个失败。
sid, _ := CreateSession(ctx, nil, "admin", "空会话", "/tmp/ws")
parts, err := SessionParticipants(ctx, sid)
if err != nil {
t.Fatalf("空会话应正常返回: %v", err)
}
if len(parts) != 0 {
t.Fatalf("空会话应无参与方,实得 %+v", parts)
}
}
func mustMail(t *testing.T, sid uuid.UUID, from, fromWS, to, toWS string, cc []models.Address) {
t.Helper()
if _, err := CreateMail(context.Background(), sid, nil,
from, fromWS, to, toWS, "主题", "正文", cc); err != nil {
t.Fatalf("建邮件: %v", err)
}
}

View File

@ -43,6 +43,9 @@ type AgentStats struct {
SentTotal int `json:"sent_total"`
// ActiveSessions 该 Agent 参与的未归档会话数,配合默认值判断设多少合适
ActiveSessions int `json:"active_sessions"`
// Status 是 agents.statusonline / offline / disabled。
// 管理页靠它决定显示「停用」还是「恢复」。
Status string `json:"status"`
}
// DefaultRoundsFor 读取该 Agent 的新任务默认预算。
@ -127,8 +130,12 @@ func BumpSentCount(ctx context.Context, agentName string) {
// ListAgentStats 列出所有 Agent 的默认预算与统计(管理员视图)。
func ListAgentStats(ctx context.Context) ([]AgentStats, error) {
// 带上 status管理页靠它区分「在线 / 离线 / 已停用」并决定显示
// 「停用」还是「恢复」按钮。不过滤 disabled —— 这里是唯一能把已停用的
// Agent 恢复回来的地方,过滤掉就再也找不到它了。
rows, err := db.DB.QueryContext(ctx,
`SELECT agent_name, COALESCE(default_rounds, 0), COALESCE(used_rounds, 0)
`SELECT agent_name, COALESCE(default_rounds, 0), COALESCE(used_rounds, 0),
COALESCE(status, 'offline')
FROM agents ORDER BY agent_name`)
if err != nil {
return nil, err
@ -138,7 +145,7 @@ func ListAgentStats(ctx context.Context) ([]AgentStats, error) {
out := []AgentStats{}
for rows.Next() {
var st AgentStats
if err := rows.Scan(&st.AgentName, &st.DefaultRounds, &st.SentTotal); err != nil {
if err := rows.Scan(&st.AgentName, &st.DefaultRounds, &st.SentTotal, &st.Status); err != nil {
return nil, err
}
out = append(out, st)

View File

@ -16,7 +16,28 @@ import (
// ---------- Agent ----------
// ErrAgentDisabled 表示该 Agent 已被管理员停用。
//
// 停用是可逆的「归档」:邮件、会话、权限记录全部保留,只是不再接受新任务。
// 与删除分开是因为往来邮件里有一半是人自己写的 —— 停用 Agent 不该删掉
// 用户的东西;而 Agent 名与人类用户名共用命名空间,历史邮件里的 from_name
// 指向一个已删除的名字时,下一个同名注册者会看起来像是当初的发信人。
var ErrAgentDisabled = errors.New("agent disabled")
func CreateOrUpdateAgent(ctx context.Context, name, secret, platform string, workspaces []models.Workspace) error {
// 已停用的 Agent 不得靠重新注册复活。
//
// 少了这一步的后果:管理员停用后,那个平台的插件下次启动就会重新注册
// 注册是插件启动流程的一部分status 被写回 online停用等于没做。
// 必须让插件收到一个明确的错误,而不是静默成功。
disabled, dErr := AgentDisabled(ctx, name)
if dErr != nil {
return dErr
}
if disabled {
return ErrAgentDisabled
}
wsJSON, _ := json.Marshal(workspaces)
// 注意 DO UPDATE 里【不】碰 default_rounds
// 那是管理员配的值Agent 重启重新注册不应该把它冲回默认。
@ -34,8 +55,13 @@ func CreateOrUpdateAgent(ctx context.Context, name, secret, platform string, wor
}
func HeartbeatAgent(ctx context.Context, agentName string) (int, error) {
// 只把【非停用】的 Agent 标成在线。
//
// 不加这个条件的话,停用后插件的心跳会把 status 从 disabled 改回 online
// —— 而心跳是每 30 秒一次的,停用最多维持半分钟。
_, err := db.DB.ExecContext(ctx,
`UPDATE agents SET last_seen = NOW(), status = 'online' WHERE agent_name = $1`,
`UPDATE agents SET last_seen = NOW(), status = 'online'
WHERE agent_name = $1 AND status <> 'disabled'`,
agentName)
if err != nil {
return 0, err
@ -43,13 +69,83 @@ func HeartbeatAgent(ctx context.Context, agentName string) (int, error) {
return CountUnread(ctx, agentName)
}
// AgentDisabled 该 Agent 是否已被停用。Agent 不存在时返回 false ——
// 「还没注册」与「被停用」是两件事,前者应当能正常注册。
func AgentDisabled(ctx context.Context, agentName string) (bool, error) {
var status string
err := db.DB.QueryRowContext(ctx,
`SELECT status FROM agents WHERE agent_name = $1`, agentName).Scan(&status)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
return status == "disabled", nil
}
// SetAgentDisabled 停用或恢复一个 Agent。
//
// 停用时连带撤销它的全部密钥:留着密钥的话,那个平台的插件仍然能用它调
// /mail/send —— 停用的语义是「这个 Agent 不再参与工作」,不只是「不出现在
// 补全列表里」。恢复时不会把密钥变回来,管理员需要重新签发。
//
// 返回撤销的密钥数,供界面提示。
func SetAgentDisabled(ctx context.Context, agentName string, disabled bool) (int, error) {
if !disabled {
// 恢复:回到 offline 而不是 online —— 它是否真的在线由下一次心跳决定,
// 直接写 online 会让界面显示一个其实没在跑的 Agent 为在线。
_, err := db.DB.ExecContext(ctx,
`UPDATE agents SET status = 'offline' WHERE agent_name = $1 AND status = 'disabled'`,
agentName)
return 0, err
}
tx, err := db.DB.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx,
`UPDATE agents SET status = 'disabled' WHERE agent_name = $1`, agentName)
if err != nil {
return 0, err
}
if n, _ := res.RowsAffected(); n == 0 {
return 0, sql.ErrNoRows
}
keyRes, err := tx.ExecContext(ctx,
`DELETE FROM agent_keys WHERE agent_name = $1`, agentName)
if err != nil {
return 0, err
}
revoked, _ := keyRes.RowsAffected()
if err := tx.Commit(); err != nil {
return 0, err
}
return int(revoked), nil
}
// ListAgents 列出 Agent。
//
// statusFilter 为空时默认**排除已停用的** —— 这个函数的三个调用点
// 地址补全、GET /agents、可授权范围都是在回答「现在能派活给谁」
// 而停用的 Agent 不该出现在那里。要连停用一起看,传 statusFilter="all"。
func ListAgents(ctx context.Context, statusFilter string) ([]models.Agent, error) {
// 带上 default_rounds前端补全收件人时要显示「派给它的任务默认几个来回」
// 否则人得先去管理员页查一遍才敢派活。
q := `SELECT agent_id, agent_name, workspaces, platform, status,
COALESCE(default_rounds, 0) FROM agents`
args := []any{}
if statusFilter != "" {
switch statusFilter {
case "":
q += ` WHERE status <> 'disabled'`
case "all":
// 不加条件
default:
q += ` WHERE status = $1`
args = append(args, statusFilter)
}
@ -861,22 +957,76 @@ func FindSessionByAddress(ctx context.Context, name, path, alias string) (uuid.U
return id, err
}
// SuggestPaths 返回某 agent 已注册的工作区名(用于发信补全)
// SuggestPaths 给出某个收件方可用的工作目录候选(三段式补全的 path 位)。
//
// 两个来源并集,**历史优先**
//
// 1. mails.to_workspace 里真实投递过的目录(按最近使用倒序)
// 2. agents.workspaces 里注册时自报的目录
//
// 早先只看第 2 项,而它对两个官方插件**永远是空的** —— 契约W-2
// 明确要求 `workspaces: []`,因为工作目录由每封邮件的 to_workspace 决定而不是
// 注册时固定。于是补全的第二段对 dsh / opencode 一直给空列表,
// 人得手敲路径Agent 更只能猜 —— 生产上 dsh 猜了 `opencode@/home`
// 解析通过、投递成功,但那不是 opencode 的工作目录。
//
// 按最近使用倒序而非字典序:同一个 Agent 常在几个项目间切,刚用过的那个
// 几乎总是下一封想用的那个。
func SuggestPaths(ctx context.Context, agentName string) ([]string, error) {
var wsJSON []byte
err := db.DB.QueryRowContext(ctx,
`SELECT workspaces FROM agents WHERE agent_name = $1`, agentName).Scan(&wsJSON)
if err != nil {
return []string{}, err
out := []string{}
seen := map[string]bool{}
// 来源 1真实投递历史。即使 Agent 未注册(人类收件方)也能给出候选。
//
// 只 SELECT 路径一列,排序用的 MAX(created_at) 不进结果集:
// SQLite 把时间戳存为 TEXT把它 Scan 进 time.Time 会失败,
// 而那个值除了排序之外无用 —— 取回来只是多一个能静默失败的环节。
rows, err := db.DB.QueryContext(ctx, `
SELECT to_workspace
FROM mails
WHERE to_name = $1 AND COALESCE(to_workspace, '') <> ''
GROUP BY to_workspace
ORDER BY MAX(created_at) DESC
`, agentName)
if err == nil {
defer rows.Close()
for rows.Next() {
var p string
if rows.Scan(&p) != nil {
continue
}
if !seen[p] {
seen[p] = true
out = append(out, p)
}
}
}
// 来源 2注册时自报。报了但还没收过信的目录靠这一步进入候选
// 否则新接入的 Agent 在第一封邮件之前仍然无路径可选。
var wsJSON []byte
if err := db.DB.QueryRowContext(ctx,
`SELECT workspaces FROM agents WHERE agent_name = $1`, agentName).Scan(&wsJSON); err == nil {
var ws []models.Workspace
if len(wsJSON) > 0 {
json.Unmarshal(wsJSON, &ws)
}
out := make([]string, 0, len(ws))
for _, w := range ws {
out = append(out, w.Name)
// 三维地址的 path 位是**路径**,不是工作区的展示名。
// 这里取 Path 而不是 Nameremotebot 报的是
// {name:"demo", path:"/tmp/remotebot-ws"},取 Name 会给出 "demo"
// —— 一个拉起会话时不存在的目录。
p := w.Path
if p == "" {
p = w.Name
}
if p != "" && !seen[p] {
seen[p] = true
out = append(out, p)
}
}
}
return out, nil
}

View File

@ -0,0 +1,13 @@
export interface MailParticipant {
role: string;
name: string;
path: string;
address: string;
is_self: boolean;
}
export function formatAddress(name: string, path?: string, session?: string): string;
export function roleOf(mail: any, selfName: string): 'to' | 'cc' | 'unknown';
export function replyAddressFor(mail: any, alias?: string): string;
export function selfAddressFor(mail: any, selfName: string, alias?: string): string;
export function participantsOfMail(mail: any, selfName?: string, alias?: string): MailParticipant[];

View File

@ -0,0 +1,141 @@
/**
* 三维寻址的构造与判读 —— 所有平台插件共用。
*
* 为什么这些函数必须共用、且必须是纯函数:
*
* 地址拼错不会报错。`name@path.session` 的每一段都可以省略,任何组合都能被
* `ParseAddress` 解析出**某个**结果,于是拼错的代价不是失败而是**投到别处**。
* 生产上真实发生过两次:
*
* 1. 插件把 `.new` 原样当作回信地址 —— `.new` 是一次性动作,回过去只会
* 再建一条平行会话,双方从此各说各话。
* 2. path 为空时朴素拼接得到 `admin.silent-harbor` —— 没有 `@`
* 整串被当成名字session 位静默丢失。
*
* 两次都是「拼字符串」造成的,所以拼地址这件事收进这里,各平台不再自己拼。
*/
/**
* 拼一个可寻址的 `name@path.session`。
*
* **空 path 也必须留下 `@` 与 `.`**`admin@.silent-harbor` 才解析成
* name=admin path="" session=silent-harbor。省掉 `@` 得到的
* `admin.silent-harbor` 会被整串当作名字。
*
* session 省略时不写那一位(默认会话语义)。
*
* @param {string} name 收件方名Agent 名或人类用户名)
* @param {string} [path] 工作目录,可为空
* @param {string} [session] 会话别名;空则省略该位
* @returns {string} 地址name 为空时返回空串
*/
export function formatAddress(name, path, session) {
const n = String(name ?? '').trim();
const p = String(path ?? '').trim();
const s = String(session ?? '').trim();
if (!n) return '';
if (!s) return p ? `${n}@${p}` : n;
return `${n}@${p}.${s}`;
}
/**
* 判断自己在这封邮件里是收件人还是抄送方。
*
* 为什么需要它:被抄送方与主收件人的**职责不同**。线上那封联调邮件里,
* admin 主发 dsh、抄送 opencode分工是「dsh 提供源码解读、opencode 提供部署
* 现状、最后由 dsh 汇报」。收件箱若不区分身份,两方都会以为自己是负责人,
* 或者都以为自己只是旁观者。
*
* @param {any} mail `/mail/inbox` 返回的一封邮件
* @param {string} selfName 自己的 Agent 名
* @returns {'to'|'cc'|'unknown'}
*/
export function roleOf(mail, selfName) {
const self = String(selfName ?? '').trim();
if (!self) return 'unknown';
if (mail?.to_name === self) return 'to';
if (Array.isArray(mail?.cc_list) && mail.cc_list.some(c => c?.name === self)) {
return 'cc';
}
return 'unknown';
}
/**
* 给出「把回信发回这条会话」的地址。
*
* 发件人一侧**不带 path**Agent 回信时 `from_workspace` 存的是 Agent 名而不是
* 路径(历史遗留),拿它拼会得到 `dsh@dsh.alias` 这种投不出去的东西。
* 人类发件人本来就没有工作目录。
*
* 别名为空时退回 `name`(默认会话)而不是编一个 —— 但注意这与「投回同一条会话」
* 不等价,默认会话是该 name 当前最活跃的那条。调用方要区分时看返回值有没有 `.`。
*
* @param {any} mail 一封邮件
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
* @returns {string}
*/
export function replyAddressFor(mail, alias) {
const a = alias ?? mail?.session_alias ?? '';
return formatAddress(mail?.from_name, '', a);
}
/**
* 给出自己在这条会话里的地址,供转发说明或向第三方引用时使用。
*
* 用 `to_workspace`(自己那个地址的 path 位)而不是发件人的:
* 抄送给 `opencode@/a` 与主发给 `dsh@/b` 是两个不同的工作区。
*
* @param {any} mail 一封邮件
* @param {string} selfName 自己的 Agent 名
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
* @returns {string}
*/
export function selfAddressFor(mail, selfName, alias) {
const a = alias ?? mail?.session_alias ?? '';
// 抄送方拿到的 to_workspace 是主收件人的,自己的 path 在 cc_list 里。
// 不取对的那个会让「我是谁」这句话指向别人的工作目录。
let path = mail?.to_workspace ?? '';
if (mail?.to_name !== selfName && Array.isArray(mail?.cc_list)) {
const mine = mail.cc_list.find(c => c?.name === selfName);
if (mine) path = mine.path ?? '';
}
return formatAddress(selfName, path, a);
}
/**
* 列出这封邮件的全部参与方及各自可投递的地址。
*
* 这是「回给抄收方」缺的那块信息:知道有谁,**以及用什么地址找到他**。
* 抄送方的 path 取它自己那个地址的 path 位。
*
* 自己会被标 `is_self`,而不是从列表里剔掉 —— 剔掉的话模型无法确认
* 「这封信是不是也发给了我」,也就无法判断自己是不是该回。
*
* @param {any} mail 一封邮件
* @param {string} [selfName] 自己的名字,用于标记 is_self
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
* @returns {{role: string, name: string, path: string, address: string, is_self: boolean}[]}
*/
export function participantsOfMail(mail, selfName, alias) {
const a = alias ?? mail?.session_alias ?? '';
const self = String(selfName ?? '').trim();
const out = [];
const add = (role, name, path) => {
const n = String(name ?? '').trim();
if (!n) return;
out.push({
role,
name: n,
path: String(path ?? ''),
address: formatAddress(n, path, a),
is_self: !!self && n === self,
});
};
// 发件人一侧 path 留空,理由同 replyAddressFor
add('from', mail?.from_name, '');
add('to', mail?.to_name, mail?.to_workspace);
if (Array.isArray(mail?.cc_list)) {
for (const c of mail.cc_list) add('cc', c?.name, c?.path);
}
return out;
}

View File

@ -0,0 +1,6 @@
export function renderNameSuggestions(names: readonly string[]): string;
export function renderPathSuggestions(paths: readonly string[], name: string): string;
export function renderSessionSuggestions(data: any, name: string, path: string): string;
export function renderParticipants(data: any): string;
export function renderContacts(data: any, limit?: number): string;
export function renderThread(data: any, selfName?: string): string;

View File

@ -0,0 +1,237 @@
/**
* 寻址发现工具 —— 所有平台插件共用的**纯逻辑**部分。
*
* 三个 Agent 侧只读端点(`/agent/contacts`、`/agent/contacts/suggest`、
* `/agent/sessions/{id}/participants`)的返回值怎么渲染给模型看,与平台 SDK 无关,
* 所以收进这里。各平台只负责把自己的工具定义壳套上去。
*
* # 这一组端点解决的问题
*
* 在它们存在之前,`send_mail` 的 `to` 是一个**只能靠记忆拼写的自由文本字段**。
* 人类侧从来不是这样三段式输入框逐段查候选name / path / session 每一段都从
* 活数据里选。Agent 只能猜,而猜错不会报错 —— 生产上 dsh 猜了
* `opencode@/home`,地址解析通过、投递成功,但那不是 opencode 的工作目录,
* 那个错误路径静默变成了新会话的 workspace。
*
* # 渲染的取舍
*
* 一律输出**可直接粘进 `to` 的完整地址**,而不是把三段分开列。模型看到
* `opencode@/home.silent-harbor` 会整串复制;看到 `name=opencode path=/home
* session=silent-harbor` 则要自己拼,而自己拼就是问题的来源。
*/
/**
* 渲染候选收件人清单(`kind: "name"`)。
*
* 只给名字,不给地址:此时还不知道 path 与 session硬拼出来的
* 裸名字地址会投到「默认会话」—— 那不一定是调用方想要的那条。
* 明确提示下一步该查什么,模型才会继续往下走而不是就地拼一个。
*
* @param {string[]} names
* @returns {string}
*/
export function renderNameSuggestions(names) {
const list = Array.isArray(names) ? names.filter(Boolean) : [];
if (list.length === 0) return '当前没有可投递的收件人。';
return [
`可投递的收件人(${list.length} 个):`,
list.map(n => `- ${n}`).join('\n'),
'',
'下一步:用 suggest_address 带上 name 查它可用的工作目录path 位)。',
].join('\n');
}
/**
* 渲染工作目录候选(`kind: "path"`)。
*
* 空列表要说清「这不代表不能发」path 位允许为空(人类用户没有工作目录),
* 不解释的话模型会卡在这一步,或者编一个路径出来。
*
* @param {string[]} paths
* @param {string} name 正在查的收件人名,用于拼下一步的提示
* @returns {string}
*/
export function renderPathSuggestions(paths, name) {
const list = Array.isArray(paths) ? paths.filter(Boolean) : [];
if (list.length === 0) {
return [
`${name} 没有记录在案的工作目录。`,
'这不代表不能给它发信 —— path 位可以留空(人类用户就没有工作目录)。',
`直接用 suggest_address(name="${name}", path="") 查它的会话,或直接发给 ${name}`,
].join('\n');
}
return [
`${name} 用过的工作目录(按最近使用排序):`,
list.map(p => `- ${p}`).join('\n'),
'',
`下一步:用 suggest_address(name="${name}", path="<上面某一个>") 查该目录下可续谈的会话。`,
].join('\n');
}
/**
* 渲染会话候选(`kind: "session"`)。
*
* **`addresses` 与 `suggestions` 同序**,服务端保证。这里优先用 `addresses`
* 那是服务端拼好的完整地址,插件不必自己拼(自己拼过一次,拼错了)。
*
* `new` 永远在最后且带一句警告:它不是一条已存在的会话。排在前面会让模型
* 在想续谈时顺手开出一条新线索 —— 生产上已经发生过。
*
* @param {object} data `/agent/contacts/suggest` 的返回体
* @param {string} name
* @param {string} path
* @returns {string}
*/
export function renderSessionSuggestions(data, name, path) {
const aliases = Array.isArray(data?.suggestions) ? data.suggestions : [];
const addresses = Array.isArray(data?.addresses) ? data.addresses : [];
const candidates = Array.isArray(data?.candidates) ? data.candidates : [];
// 只有 new 一项 = 这个 name@path 下还没有任何可续谈的会话
const existing = aliases.filter(a => a !== 'new');
if (existing.length === 0) {
return [
`${name}${path ? '@' + path : ''} 下还没有可续谈的会话。`,
`要开一条新线索用 ${addressAt(addresses, aliases, 'new') || `${name}@${path}.new`}`,
'并在 send_mail 里传 session_alias 给它命名,之后就能按名字续谈。',
].join('\n');
}
const lines = [`${name}${path ? '@' + path : ''} 下可续谈的会话:`];
for (let i = 0; i < aliases.length; i++) {
const alias = aliases[i];
const addr = addresses[i] || '';
const c = candidates[i] || {};
if (alias === 'new') continue; // new 单独放最后
const bits = [];
if (c.title) bits.push(c.title);
if (typeof c.unread === 'number' && c.unread > 0) bits.push(`${c.unread} 封未读`);
if (c.source === 'platform') bits.push('平台侧会话');
lines.push(`- ${addr || alias}${bits.length ? ` ${bits.join('')}` : ''}`);
}
lines.push('');
lines.push('把上面某个地址原样填进 send_mail 的 to 即可投进那条会话。');
const newAddr = addressAt(addresses, aliases, 'new');
if (newAddr) {
lines.push(`若确实要开一条**新**线索(而不是接着上面某条谈)才用 ${newAddr}`);
}
return lines.join('\n');
}
/** 按别名在同序的 addresses 里取地址。 */
function addressAt(addresses, aliases, alias) {
const i = aliases.indexOf(alias);
return i >= 0 ? addresses[i] || '' : '';
}
/**
* 渲染会话参与方清单。
*
* 这是「发送给抄收方 / 转发方」缺的最后一块:知道有谁、**用什么地址找到他**、
* 以及谁还没开口。`mail_count` 为 0 的那个就是还没回应的人 —— 服务端只数
* 「作为发件人」的邮件,正是为了让这个判断成立。
*
* @param {object} data `/agent/sessions/{id}/participants` 的返回体
* @returns {string}
*/
export function renderParticipants(data) {
const parts = Array.isArray(data?.participants) ? data.participants : [];
if (parts.length === 0) return '该会话还没有参与方(可能是一条刚建立的空会话)。';
const alias = data?.session_alias || '';
const lines = [`会话 #${alias || '未命名'} 的参与方:`];
for (const p of parts) {
const tags = [];
if (p.is_self) tags.push('就是你');
if (Array.isArray(p.roles) && p.roles.length) {
tags.push(p.roles.map(roleLabel).join('/'));
}
if (p.mail_count === 0 && !p.is_self) tags.push('尚未回应');
const addr = p.address ? p.address : '(无可投递地址:该会话尚未命名)';
lines.push(`- ${p.name} ${addr}${tags.length ? ` [${tags.join('')}]` : ''}`);
}
lines.push('');
lines.push('要联系其中某一方,把它的地址原样填进 send_mail 的 to。');
return lines.join('\n');
}
/**
* 渲染联系人清单(本 Agent 参与过的全部会话)。
*
* 按未读优先、其次最近活跃排序:模型问「我还有什么没处理」时,
* 有未读的那些才是答案。
*
* @param {object} data `/agent/contacts` 的返回体
* @param {number} limit 最多列出多少条
* @returns {string}
*/
export function renderContacts(data, limit = 20) {
const list = Array.isArray(data?.contacts) ? data.contacts.slice() : [];
if (list.length === 0) return '还没有任何往来会话。';
list.sort((a, b) => {
const ua = a?.unread_count || 0;
const ub = b?.unread_count || 0;
if (ua !== ub) return ub - ua;
return String(b?.last_activity || '').localeCompare(String(a?.last_activity || ''));
});
const shown = list.slice(0, limit);
const lines = [`往来会话(共 ${list.length}${list.length > shown.length ? `,列出前 ${shown.length}` : ''}`];
for (const c of shown) {
const bits = [];
if (c.unread_count > 0) bits.push(`${c.unread_count} 封未读`);
if (c.subject) bits.push(c.subject);
if (c.max_rounds > 0) {
const left = Math.max(0, c.max_rounds - (c.used_rounds || 0));
bits.push(`${left}/${c.max_rounds} 个来回`);
}
const addr = c.address || '(未命名会话,只能用 reply_to 续谈)';
lines.push(`- ${addr}${bits.length ? ` ${bits.join('')}` : ''}`);
}
return lines.join('\n');
}
/** 角色的中文标签。模型读到「抄送方」比读到 cc 更容易判对分工。 */
function roleLabel(role) {
switch (role) {
case 'from': return '发件人';
case 'to': return '收件人';
case 'cc': return '抄送方';
default: return String(role);
}
}
/**
* 渲染对话树,回答「谁已经回了、谁还没回」。
*
* 缩进表示层级。**detached 必须标出来**:那表示父邮件不在本次结果里
* (无权查看或尚未加载),不标的话模型会以为这是一条独立线索。
*
* @param {object} data `/agent/mail/{id}/thread` 的返回体
* @param {string} [selfName] 自己的名字,用于标出哪几封是自己发的
* @returns {string}
*/
export function renderThread(data, selfName = '') {
const nodes = Array.isArray(data?.nodes) ? data.nodes : [];
if (nodes.length === 0) return '这条线索上没有可见的邮件。';
const lines = [`线索共 ${data?.total ?? nodes.length}${data?.hidden ? `(另有 ${data.hidden} 封无权查看)` : ''}`];
for (const n of nodes) {
const depth = typeof n?.depth === 'number' ? Math.max(0, n.depth) : 0;
const indent = ' '.repeat(Math.min(depth, 8));
const marks = [];
if (selfName && n?.from_name === selfName) marks.push('你发的');
if (n?.mail_id === data?.anchor_mail_id) marks.push('当前这封');
if (n?.detached) marks.push(n.parent_hidden ? '父邮件无权查看' : '父邮件尚未加载');
lines.push(
`${indent}- ${n?.from_name ?? '?'}${n?.to_name ?? '?'}: ${n?.subject ?? '(无主题)'}` +
` [${n?.mail_id ?? '?'}]${marks.length ? ` (${marks.join('')})` : ''}`
);
}
if (data?.has_more) {
lines.push('');
lines.push(`还有更多,用 offset=${data.next_offset} 继续取。`);
}
return lines.join('\n');
}

View File

@ -2,6 +2,6 @@ export declare const DEFAULT_INBOX_STATUS: string;
export declare const DEFAULT_INBOX_LIMIT: number;
export function formatSize(n: number | undefined): string;
export function renderMail(mail: any, bodyLimit?: number): string;
export function renderInbox(mails: readonly any[], bodyLimit?: number): string;
export function renderMail(mail: any, bodyLimit?: number, selfName?: string): string;
export function renderInbox(mails: readonly any[], bodyLimit?: number, selfName?: string): string;
export function idsToMarkRead(status: string | undefined, mails: readonly any[]): string[];

View File

@ -6,6 +6,8 @@
* 渲染出的文本与标记已读的时机都该一致。新接一个平台时直接复用这里。
*/
import { roleOf, replyAddressFor, participantsOfMail } from './addressing.js';
/** 人类可读的字节数,用于附件清单展示。 */
export function formatSize(n) {
if (typeof n !== 'number' || !Number.isFinite(n)) return '?';
@ -19,19 +21,41 @@ export function formatSize(n) {
*
* @param {any} m `/mail/inbox` 返回的一封邮件
* @param {number} bodyLimit 正文截断长度
* @param {string} [selfName] 自己的 Agent 名。给了就能判定「我是收件人还是抄送方」
* 并给出参与方地址;不给则退化成旧行为(兼容未传该参数的调用方)。
* @returns {string}
*/
export function renderMail(m, bodyLimit = 200) {
export function renderMail(m, bodyLimit = 200, selfName = '') {
const alias = m?.session_alias || '';
const lines = [
`[${m?.status ?? 'unknown'}] ${m?.from_name ?? 'unknown'}: ${m?.subject ?? '(无主题)'}`,
`邮件 ID: ${m?.mail_id ?? 'unknown'}`,
`会话: #${m?.session_alias || '未命名'}`,
`会话: #${alias || '未命名'}`,
];
// 收件人必须显示。不显示的后果:被抄送方既不知道主收件人是谁,
// 也无法向对方转达或汇报 —— 线上那封联调邮件要求「由收件人汇报」,
// 抄送方却看不到收件人叫什么。
if (m?.to_name) {
let toLine = `收件人: ${m.to_name}`;
if (m?.to_workspace) toLine += `@${m.to_workspace}`;
lines.push(toLine);
}
// 抄送要显示:一封邮件为什么同时到了几个人手上,只有抄送能解释。
// 不显示的话模型会以为这是私下发给它一个人的,回信时漏掉其他参与方。
if (Array.isArray(m?.cc_list) && m.cc_list.length > 0) {
lines.push('抄送: ' + m.cc_list.map(c => c?.raw || c?.name || '?').join('、'));
}
// 自己的身份。抄送方与主收件人的职责不同,不区分的话两方都会
// 以为自己是负责人,或者都以为自己只是旁观者。
if (selfName) {
const role = roleOf(m, selfName);
if (role === 'to') lines.push('你的身份: 收件人(主办)');
else if (role === 'cc') lines.push('你的身份: 抄送方(配合)');
}
// **必须给出 attachment_id**:只说「有附件」模型就无从下载。
if (Array.isArray(m?.attachments) && m.attachments.length > 0) {
lines.push(
@ -42,22 +66,52 @@ export function renderMail(m, bodyLimit = 200) {
);
lines.push('下载附件请用 download_attachment 工具。');
}
// 列表接口只给 body_preview省带宽单封接口才有 body。两者都兜住。
const body = m?.body_preview || m?.body || '';
lines.push(`内容: ${String(body).slice(0, bodyLimit)}`);
// 可投递地址放在最后,紧贴正文 —— 模型读完内容紧接着就要决定发给谁。
//
// 这一段是「精准发信」的关键:之前模型只能从抄送行里拄一个
// `opencode@/home.new` 拄过去,而 `.new` 是一次性的,回过去只会再建一条
// 平行会话。这里给的地址全部已经把 session 位换成真实别名。
if (selfName && alias) {
const parts = participantsOfMail(m, selfName, alias);
const others = parts.filter(p => !p.is_self && p.address);
if (others.length > 0) {
lines.push(
'可投递地址: ' +
others.map(p => `${p.address}${roleLabel(p.role)}`).join('、')
);
lines.push(`直接回信给发件人用 ${replyAddressFor(m, alias)},或传 reply_to=${m?.mail_id ?? ''}`);
}
}
return lines.join('\n');
}
/** 角色的中文标签。模型读到「抄送方」比读到 cc 更容易判对分工。 */
function roleLabel(role) {
switch (role) {
case 'from': return '发件人';
case 'to': return '收件人';
case 'cc': return '抄送方';
default: return role;
}
}
/**
* 渲染整个收件箱。
* @param {any[]} mails
* @param {number} bodyLimit
* @param {string} [selfName] 自己的 Agent 名,透传给 renderMail
* @returns {string}
*/
export function renderInbox(mails, bodyLimit = 200) {
export function renderInbox(mails, bodyLimit = 200, selfName = '') {
const list = Array.isArray(mails) ? mails : [];
if (list.length === 0) return '收件箱为空。';
return list.map(m => renderMail(m, bodyLimit)).join('\n\n');
return list.map(m => renderMail(m, bodyLimit, selfName)).join('\n\n');
}
/**

View File

@ -13,6 +13,7 @@ export declare const MAX_CATALOG: number;
export function snapshotOpencodeModels(config: any): CatalogEntry[];
export function snapshotDshModels(entries: readonly any[]): CatalogEntry[];
export function snapshotPiModels(models: readonly any[]): CatalogEntry[];
export function modelAttemptOrder(
allowed: readonly ModelRoute[] | undefined,

View File

@ -65,6 +65,37 @@ export function snapshotDshModels(entries) {
return dedupeAndCap(out);
}
/**
* 把 pi 的模型列表整理成上报格式。
*
* pi 侧的取法是 `await modelRuntime.getAvailable()` —— **不是** `getModels()`。
* 两者差别很大:本机实测目录里有 1221 个模型,而带凭证、真能调起来的只有 1 个。
* 上报 `getModels()` 的结果会让管理员在配置页选中一个注定失败的路由,
* 而失败要到真发邮件时才暴露(模型目录上报的全部意义就是避免这件事)。
*
* pi 的 Model 对象上provider 在 `provider` 字段、模型 id 在 `id` 字段,
* 展示名在 `name`。形状与 DSH 侧一致,但语义来源不同,因此单独一个函数
* ——照抄 snapshotDshModels 会让「必须用 getAvailable」这条约束无处记录。
*
* @param {any[]} models `await modelRuntime.getAvailable()` 的结果
* @returns {object[]}
*/
export function snapshotPiModels(models) {
const list = Array.isArray(models) ? models : [];
const out = [];
for (const m of list) {
const provider = typeof m?.provider === 'string' ? m.provider : '';
const model = typeof m?.id === 'string' ? m.id : '';
if (!provider || !model) continue;
out.push({
provider,
model,
display_name: typeof m?.name === 'string' ? m.name : '',
});
}
return dedupeAndCap(out);
}
/**
* 决定这一轮按什么顺序尝试模型。
*

View File

@ -19,4 +19,11 @@ export function snapshotDshSessions(
isMailDriven?: (id: string) => boolean
): PlatformSessionReport[];
export function snapshotPiSessions(
entries: readonly any[],
isMailDriven?: (id: string) => boolean
): PlatformSessionReport[];
export function isUnusableName(name: string): boolean;
export function slugFromTitle(title: string): string;

View File

@ -83,6 +83,83 @@ export function snapshotDshSessions(entries, isMailDriven = () => false) {
return dedupeBySlug(sortAndCap(out));
}
/**
* 把 pi 的 `SessionManager.list()/listAll()` 结果整理成上报格式。
*
* pi 的会话名字来自会话文件里最后一条 `session_info` 条目:
* - pi-web 在一条会话的首次 prompt 时用模型生成一个 2-6 词的标题
* - TUI 的 `/name`、启动参数 `--name`、`/resume` 里的改名也写同一处
* - **pi 内核SDK自己不生成**:桥用 createAgentSession 起的会话没有名字,
* 要由桥按「Gateway 定稿的别名」回写(见 index 的 syncNaming
*
* 与另两个平台的差异pi 的 SessionInfo 里**没有 subagent 标记**。
* pi-subagents 把子会话写在自定义 sessionDirrun 根目录)下,默认会话目录
* 列不到它们,因此这里不需要 S-2 那样的显式过滤。
*
* @param {any[]} entries SessionInfo 列表 `[{ id, cwd, name, modified }]`
* @param {(id: string) => boolean} isMailDriven
* @returns {object[]}
*/
export function snapshotPiSessions(entries, isMailDriven = () => false) {
const list = Array.isArray(entries) ? entries : [];
const out = [];
for (const e of list) {
const id = typeof e?.id === 'string' ? e.id : '';
if (!id) continue;
const name = typeof e?.name === 'string' ? e.name : '';
// 没有名字的会话不报S-1pi 的列表在无名时显示首条消息,
// 而首条消息对邮件驱动的会话就是桥自己拼的提示词 —— 拿它当别名毫无区分度。
if (!name) continue;
// 模型把思维链当标题写进来的那些不报(见 isUnusableName
if (isUnusableName(name)) continue;
const slug = slugFromTitle(name);
if (!slug) continue;
out.push({
platform_id: id,
// 老会话的 cwd 是空串pi 的 SessionInfo 注释里写明了),照实上报,
// 服务端按空 workspace 处理,不要拿桥自己的 cwd 冒充。
workspace: typeof e?.cwd === 'string' ? e.cwd : '',
slug,
title: name,
mail_driven: Boolean(isMailDriven(id)),
updated_at: toISO(e?.modified ?? e?.created),
});
}
return dedupeBySlug(sortAndCap(out));
}
/**
* 判断一个平台侧名字是否不适合当别名。
*
* 这条判废是 pi 特有的pi-web 的标题生成器(`sessionNameGenerator`)只做了
* 「取首行 + 去引号 + 截 60 字符」,没有防思维链泄漏。本机 81 条会话里实测捞到:
*
* "The user is asking me to generate a title for a coding-agent"
* "我们只需要生成标题不包含其他内容。标题应反映请求内容测试opencode的源。简短…"
*
* 这类字符串派生出的别名又长又没有指代作用,填进三维地址里更是灾难。
* 判废后调用方回退到「不上报」或「用邮件主题派生」,都比它强。
*
* 宁可漏判也不误判:错杀一个好名字会让那条会话失去可寻址的别名,
* 而漏掉一个坏名字只是别名难看。
*
* @param {string} name
* @returns {boolean}
*/
export function isUnusableName(name) {
const s = String(name ?? '').trim();
if (!s) return true;
// 自指标题生成任务 = 模型把系统提示词复述了出来
if (/生成标题|标题应|拟一个标题|generate a (short |concise )?title|session title|as a title/i.test(s)) {
return true;
}
// 以第三人称叙述用户意图开头 = 思维链的典型开场
if (/^(the user\b|用户(想|要|在|希望)|我们只需要|我需要先|首先(|,))/i.test(s)) return true;
// 又长又分句 = 一段话而不是一个标题pi-web 截断上限是 60
if (s.length >= 48 && /[。;;]|\.\s/.test(s)) return true;
return false;
}
/** 判断一条会话是否为 subagent 子会话。两个字段任一成立即算。 */
function isSubagent(e) {
if (e?.origin === 'subagent') return true;
@ -131,11 +208,17 @@ export function slugFromTitle(title) {
return slug;
}
/** 毫秒时间戳ISO 串 → ISO 串;无法解析时返回 undefined。 */
/** 毫秒时间戳ISO 串或 Date → ISO 串;无法解析时返回 undefined。 */
function toISO(v) {
if (typeof v === 'number' && Number.isFinite(v)) {
return new Date(v).toISOString();
}
// pi 的 SessionInfo 给的是 Date 实例created/modified不是时间戳。
// 少了这一支会让整份快照的 updated_at 全是 undefined于是服务端只能按
// 上报时间排序 —— 补全列表里「最近在谈的那条」不再排在前面。
if (v instanceof Date) {
return Number.isNaN(v.getTime()) ? undefined : v.toISOString();
}
if (typeof v === 'string' && v) {
const d = new Date(v);
if (!Number.isNaN(d.getTime())) return d.toISOString();

View File

@ -42,6 +42,14 @@ import {
DEFAULT_INBOX_STATUS,
DEFAULT_INBOX_LIMIT,
} from '../lib/inbox-format.js';
import {
renderNameSuggestions,
renderPathSuggestions,
renderSessionSuggestions,
renderParticipants,
renderContacts,
renderThread,
} from '../lib/discovery.js';
// ─── 凭证管理 ───
@ -294,11 +302,11 @@ export function apply(ctx: any, config: PluginConfig): void {
try {
await deliverMail(ev, 'mail');
} catch (e: any) {
ctx.logger.error(`[dsh-mail-bridge] 补投 ${ev.mail_id} 失败: ${e?.message || e}`);
console.error(`[dsh-mail-bridge] 补投 ${ev.mail_id} 失败: ${e?.message || e}`);
}
}
} catch (e: any) {
ctx.logger.error(`[dsh-mail-bridge] 补投失败: ${e?.message || e}`);
console.error(`[dsh-mail-bridge] 补投失败: ${e?.message || e}`);
}
}
@ -440,6 +448,82 @@ export function apply(ctx: any, config: PluginConfig): void {
});
}
// ─── 建会话(磁盘上已有则 resume───
/**
* 问持久化层:磁盘上是否已经有这条会话?
*
* `sessionMap` 是纯内存的,插件重启后为空,于是同一封邮件的续谈会走
* 「新开会话」那条路,用回同一个 `mail-<session_id>` —— 而那个 id 上一次
* 已经落过盘。只能问持久化层,因为这是重启后唯一还存在的事实来源。
*
* 读不到就当作不存在:`readSession` 在会话不存在、日志不可读、replay 校验
* 不过时都会抛。三种情形里只有第一种适合 create但后两种 resume 也一样
* 救不回来 —— 那就让 create 去报它自己的错。
*/
async function persistedCwd(sessionId: string): Promise<string | undefined> {
const q: any = (ctx as any).get?.('sessionQuery');
if (!q?.readSession) return undefined;
try {
const snap = await q.readSession(sessionId);
return snap?.header?.cwd ?? '';
} catch {
return undefined;
}
}
/**
* 启一个 agent磁盘上没有这个 id 就 create有就 resume。
*
* # 为何必须先探测,不能靠 try/catch
*
* id 冲突不是 `create` 报的:持久化是在**轮次进行中** flush 的,所以
* `create` 会正常返回,错误到 `turn/end` 才以 `reason.kind === 'error'`
* 冲出来(实测:`UNKNOWN: session "..." already has a persisted log on disk`)。
* 把修法写成 catch 里改 resume 完全不会生效 —— 这与「模型失败不是同步抛出的」
* 是同一类陷阱,只是上了一层。
*
* # 为何 resume 而不是换一个新 id
*
* 换 id 等于把之前的往来上下文丢掉,模型会重新问一遍已经问过的问题。
* resume 把磁盘上那条会话装回来接着谈,这同时修掉了一个已知取舍:
* 插件重启后续谈的邮件不再另开一条平台会话。
*
* resume 不接受 `meta`cwd 取自持久化的 header。这正是想要的 —— 上一次在哪个
* 目录,就继续在那儿;传一个不同的 cwd 只会得到
* `is already persisted at a different cwd` 而不是“改目录”。
*/
async function startAgent(
sessionId: string, cwd: string, route: any,
): Promise<{ handle: any; resumed: boolean }> {
// route 为 undefined 表示不指定模型,交给平台自己选
const agentOptions = route ? { provider: route.provider, model: route.model } : {};
const onDisk = await persistedCwd(sessionId);
if (onDisk !== undefined) {
// 用 console.error 而不是 ctx.logger.info后者不进 journalctl实测
// 而这条是排查「邮件投不进去」时唯一能看到的线索。
console.error(`[dsh-mail-bridge] 会话 ${sessionId} 已在磁盘上cwd=${onDisk || '未记录'}),改为 resume 续谈`);
const handle = await ctx.agents.resume({
resumeSessionId: sessionId as any,
agentOptions,
setup: undefined,
});
return { handle, resumed: true };
}
const handle = await ctx.agents.create({
sessionId,
meta: { cwd },
agentOptions,
// setup 留空DSH 的 base bundle 已经注册了 agent-loop、llm、tools 等服务。
// 模型路由通过 agentOptions 传入即可 —— 挂载 preset 或
// installModelSelection 反而会让 turn 崩溃(实测)。
setup: undefined,
});
return { handle, resumed: false };
}
// ─── 投递邮件到 DSH 会话 ───
async function deliverMail(data: any, kind: string): Promise<{ sessionID: string; reused: boolean }> {
@ -517,20 +601,13 @@ export function apply(ctx: any, config: PluginConfig): void {
let handle: any;
try {
handle = await ctx.agents.create({
sessionId: attemptSessionId,
meta: { cwd },
// route 为 undefined 表示不指定模型,交给平台自己选
agentOptions: route ? { provider: route.provider, model: route.model } : {},
// setup 留空DSH 的 base bundle 已经注册了 agent-loop、llm、tools 等服务。
// 模型路由通过 agentOptions 传入即可 —— 挂载 preset 或
// installModelSelection 反而会让 turn 崩溃(实测)。
setup: undefined,
});
const started = await startAgent(attemptSessionId, cwd, route);
handle = started.handle;
} catch (e: any) {
// create 本身很少失败(它不校验模型),但会话 id 冲突之类仍会抛
// create/resume 本身很少失败(create 不校验模型),
// 但 cwd 不符、日志 replay 不过之类仍会抛
failures.push({ ...(route ?? {}), error: e?.message || String(e) });
ctx.logger.error(`[dsh-mail-bridge] 建会话失败 ${label}: ${e?.message || e}`);
console.error(`[dsh-mail-bridge] 建会话失败 ${label}: ${e?.message || e}`);
continue;
}
@ -553,13 +630,13 @@ export function apply(ctx: any, config: PluginConfig): void {
if (outcome.ok) {
if (failures.length > 0) {
ctx.logger.info(`[dsh-mail-bridge] ${label} 成功(前 ${failures.length} 个失败)`);
console.error(`[dsh-mail-bridge] ${label} 成功(前 ${failures.length} 个失败)`);
}
return { sessionID: attemptSessionId, reused: false };
}
failures.push({ ...(route ?? {}), error: outcome.error });
ctx.logger.error(`[dsh-mail-bridge] 模型 ${label} 失败: ${outcome.error}`);
console.error(`[dsh-mail-bridge] 模型 ${label} 失败: ${outcome.error}`);
// 拆掉这一路的 agent 与映射,否则它会占着会话 id
// 而 agent/status 还会为这个死会话触发一次自动转发
reverseMap.delete(attemptSessionId);
@ -693,7 +770,9 @@ export function apply(ctx: any, config: PluginConfig): void {
// 渲染与已读策略放 lib/inbox-format.js它们与平台 SDK 无关,
// 各平台插件必须一致(见该文件里每条规则对应的错误行为)。
const listed = renderInbox(mails);
//
// 传 AGENT_NAME 才能判定「我是收件人还是抄送方」并给出可投递地址。
const listed = renderInbox(mails, 200, AGENT_NAME);
// 读过就标掉,否则每次拉收件箱都重复捞同一批,
// 处理过的和新来的混在一起,模型分不清哪封该回。
@ -722,10 +801,21 @@ export function apply(ctx: any, config: PluginConfig): void {
async execute(args: any): Promise<string> {
const data = await readFile(args.file_path);
const filename = args.file_path.split('/').pop() || 'file';
// **必须发真正的 multipart。**
//
// 早先这里发的是 `Content-Type: application/octet-stream` 加一个
// `X-Filename` 头,而服务端走 `ParseMultipartForm` + `FormFile("file")` ——
// 于是 **这个工具从来没成功过一次**,每次都回「解析 multipart 失败」。
// 模型甚至把它当成了文件存在性探针(存在→报 multipart 错、
// 不存在→ENOENT那是对症状的准确利用但不是它应该做的事。
//
// 不设 Content-Type交给 FormData 自己带 boundary手写的一定对不上。
const form = new FormData();
form.append('file', new Blob([data]), filename);
const res = await fetch(`${client.baseURL}/api/v1/attachments`, {
method: 'POST',
headers: { ...client.authHeaders(), 'Content-Type': 'application/octet-stream', 'X-Filename': filename },
body: data,
headers: client.authHeaders(),
body: form,
});
const json = await res.json() as any;
if (!res.ok) throw new Error(json?.error || `HTTP ${res.status}`);
@ -757,8 +847,185 @@ export function apply(ctx: any, config: PluginConfig): void {
},
}));
// ─── 寻址发现工具(读 Agent 侧只读端点)───
//
// 在这一组之前send_mail 的 to 是个只能靠记忆拼写的自由文本字段,
// 而拼错不报错:生产上本插件猜了 `opencode@/home`,投递成功,
// 但那不是 opencode 的工作目录,静默变成了新会话的 workspace。
ctx.tools.register(defineTool({
name: 'suggest_address',
description:
'查询可用的收件人地址,用于精准发信。不带参数给候选收件人名;带 name 给它可用的工作目录;' +
'name+path 都带则给该目录下可续谈的会话与现成地址。**发信前应先用它确认地址**' +
'不要凭记忆拼写 —— 拼错不会报错,只会投到别的会话。',
parameters: {
name: { type: 'string', description: '收件人名;留空则列出所有候选收件人' },
path: { type: 'string', description: '工作目录;与 name 同时给出才列会话' },
},
output: {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any): Promise<string> {
const name = String(args.name || '').trim();
const path = String(args.path || '').trim();
const qs = new URLSearchParams();
if (name) qs.set('name', name);
if (path) qs.set('path', path);
const data = await client.get(`/agent/contacts/suggest?${qs.toString()}`);
// 按服务端回的 kind 分派而不是按本地参数:省略与传空串在服务端
// 是同一个意思,但「哪一段该渲染成什么」只有服务端知道。
switch (data?.kind) {
case 'name': return renderNameSuggestions(data.suggestions);
case 'path': return renderPathSuggestions(data.suggestions, name);
default: return renderSessionSuggestions(data, name, path);
}
},
}));
ctx.tools.register(defineTool({
name: 'list_contacts',
description:
'列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。' +
'用于回答「我还有什么没处理」与「上次跟某人聊的那条线索地址是什么」。',
parameters: {
limit: { type: 'number', description: '最多列出多少条,默认 20' },
},
output: {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any): Promise<string> {
const data = await client.get('/agent/contacts');
return renderContacts(data, args.limit || 20);
},
}));
ctx.tools.register(defineTool({
name: 'session_participants',
description:
'列出某条会话的全部参与方(发件人/收件人/抄送方)及各自的可投递地址,' +
'并标出谁还没回应。**要回给抄收方或向第三方转达时先用它拿地址**。',
parameters: {
session_id: { type: 'string', required: true, description: '会话 ID' },
},
output: {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any): Promise<string> {
const data = await client.get(`/agent/sessions/${args.session_id}/participants`);
return renderParticipants(data);
},
}));
ctx.tools.register(defineTool({
name: 'read_thread',
description:
'查看一封邮件所在线索的完整往来(谁回了谁、谁还没回)。多方抄送协作时' +
'用它确认别人已经说了什么,避免重复提问或重复汇报。',
parameters: {
mail_id: { type: 'string', required: true, description: '线索中任一封邮件的 ID' },
offset: { type: 'number', description: '分页偏移,续取时传上次返回的 next_offset' },
},
output: {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any): Promise<string> {
const qs = args.offset ? `?offset=${args.offset}` : '';
const data = await client.get(`/agent/mail/${args.mail_id}/thread${qs}`);
return renderThread(data, AGENT_NAME);
},
}));
ctx.tools.register(defineTool({
name: 'read_mail',
description:
'读一封邮件的完整内容,含收件人、抄送清单、附件与每个参与方的可投递地址。' +
'收件箱只给摘要;要回给抄收方就得先看清这封信发给了谁。',
parameters: {
mail_id: { type: 'string', required: true, description: '邮件 ID' },
},
output: {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any): Promise<string> {
const data = await client.get(`/agent/mail/${args.mail_id}`);
const m = data?.mail || {};
const lines = [
`发件人: ${m.from_name || '?'}`,
`收件人: ${m.to_name || '?'}${m.to_workspace ? '@' + m.to_workspace : ''}`,
`主题: ${m.subject || '(无主题)'}`,
`会话: #${data.session_alias || '未命名'}session_id: ${m.session_id || '?'}`,
];
if (Array.isArray(m.cc_list) && m.cc_list.length) {
lines.push(`抄送: ${m.cc_list.map((c: any) => c?.raw || c?.name).join('、')}`);
}
if (Array.isArray(m.attachments) && m.attachments.length) {
lines.push(`附件: ${m.attachments
.map((a: any) => `${a.filename}${formatSize(a.size_bytes)}, id=${a.attachment_id}`)
.join('、')}`);
}
lines.push('', m.body || '(空正文)', '');
if (Array.isArray(data.participants) && data.participants.length) {
lines.push('可投递地址: ' + data.participants
.filter((p: any) => p.address && p.name !== AGENT_NAME)
.map((p: any) => `${p.address}${p.role}`)
.join('、'));
}
if (data.reply_address) {
lines.push(`回信给发件人用 ${data.reply_address},或传 reply_to=${m.mail_id}`);
}
return lines.join('\n');
},
}));
// forward_mail —— 转发给新收件人。
//
// 之前 DSH 侧缺这个工具opencode 侧一直有),于是本平台上「把这封信
// 转给某人」只能退化成 send_mail 重抄一遍正文 —— 丢掉附件、丢掉
// parent_mail_id对话树上也看不出这条新线索从何而来。
ctx.tools.register(defineTool({
name: 'forward_mail',
description:
'转发一封邮件给新的收件人(自动引用原文与附件)。与回复不同:回复落回原会话,' +
'转发按目标地址另行定位会话(它是一条新线索)。只能转发自己参与过的邮件。',
parameters: {
mail_id: { type: 'string', required: true, description: '要转发的邮件 ID' },
to: { type: 'string', required: true, description: '新收件人的三维地址(先用 suggest_address 确认)' },
comment: { type: 'string', description: '转发说明,置于引用原文之前' },
cc: { type: 'string', description: '抄送,逗号分隔多个三维地址' },
subject: { type: 'string', description: '自定义主题;留空则自动加 Fwd: 前缀' },
session_alias: { type: 'string', description: '仅当目标地址以 .new 结尾时生效:给新会话命名' },
},
output: {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any, toolCtx: any): Promise<string> {
const result = await client.post(`/mail/${args.mail_id}/forward`, {
to: args.to,
comment: args.comment || '',
cc: args.cc || '',
subject: args.subject || '',
session_alias: args.session_alias || '',
});
// 转发也是一次「模型亲手发信」,要计入 explicitSends
// 否则本轮结束时自动转发会再把同一段话发一遍。
noteExplicitSend(toolCtx?.sessionID, args.to, '');
return `已转发。新 Mail ID: ${result.mail_id}Session: ${result.session_id}`;
},
}));
return () => {
for (const n of ['send_mail', 'read_inbox', 'upload_attachment', 'download_attachment']) {
for (const n of [
'send_mail', 'read_inbox', 'read_mail', 'forward_mail',
'upload_attachment', 'download_attachment',
'suggest_address', 'list_contacts', 'session_participants', 'read_thread',
]) {
try { ctx.tools.unregister(n); } catch {}
}
};

View File

@ -0,0 +1,145 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
formatAddress,
roleOf,
replyAddressFor,
selfAddressFor,
participantsOfMail,
} from '../lib/addressing.js';
// 地址拼错不会报错,只会投到别处 —— 所以这一组测试全部落在
// 「拼出来的东西还能不能被正确解析回三段」上。
test('formatAddress: 空 path 仍保留 @ 与 .', () => {
// 生产事故:朴素拼接得到 admin.silent-harbor没有 @
// 整串被 ParseAddress 当成名字session 位静默丢失。
assert.equal(formatAddress('admin', '', 'silent-harbor'), 'admin@.silent-harbor');
});
test('formatAddress: 省略 session 位', () => {
assert.equal(formatAddress('dsh', '/home/program/agentmail', ''), 'dsh@/home/program/agentmail');
// 名字与 path 都有但都不带会话 → 默认会话语义
assert.equal(formatAddress('dsh', '', ''), 'dsh');
});
test('formatAddress: path 含 . 与 / 时仍按最后一个 . 切', () => {
// path 里允许 . 与 /,切分靠最后一个 . —— 拼出来的必须满足这个约定
const addr = formatAddress('bot', '/srv/app.v2', 'fix-leak');
assert.equal(addr, 'bot@/srv/app.v2.fix-leak');
assert.equal(addr.slice(addr.lastIndexOf('.') + 1), 'fix-leak');
});
test('formatAddress: 名字为空返回空串而不是残缺地址', () => {
// 返回 "@/path.alias" 会被投递端当成缺名字报错,
// 但那是在很后面才发现;这里直接给空串让调用方立刻看出没法拼。
assert.equal(formatAddress('', '/p', 'a'), '');
assert.equal(formatAddress(null, '/p', 'a'), '');
});
test('formatAddress: 去掉首尾空白', () => {
assert.equal(formatAddress(' dsh ', ' /home ', ' alias '), 'dsh@/home.alias');
});
const ccMail = {
from_name: 'admin',
to_name: 'dsh',
to_workspace: '/home/program/llmsproxy',
cc_list: [{ name: 'opencode', path: '/home', session: 'new', raw: 'opencode@/home.new' }],
session_alias: 'silent-harbor',
};
test('roleOf: 区分主收件人与抄送方', () => {
// 被抄送方与主收件人职责不同:线上那封联调邮件里 dsh 负责汇报、
// opencode 只提供信息。不区分身份两方都会以为自己是负责人。
assert.equal(roleOf(ccMail, 'dsh'), 'to');
assert.equal(roleOf(ccMail, 'opencode'), 'cc');
assert.equal(roleOf(ccMail, 'someone-else'), 'unknown');
});
test('roleOf: 名字为空时不猜', () => {
assert.equal(roleOf(ccMail, ''), 'unknown');
assert.equal(roleOf(ccMail, undefined), 'unknown');
});
test('replyAddressFor: 用会话别名而非原地址的 .new', () => {
// 关键回归:把 .new 原样当回信地址会再建一条平行会话。
const addr = replyAddressFor(ccMail);
assert.equal(addr, 'admin@.silent-harbor');
assert.ok(!addr.endsWith('.new'), '回信地址不得以 .new 结尾');
});
test('replyAddressFor: 发件人一侧不带 path', () => {
// Agent 回信时 from_workspace 存的是 Agent 名而不是路径,
// 拿它拼会得到 dsh@dsh.alias —— 投不出去。
const mail = { from_name: 'dsh', from_workspace: 'dsh', session_alias: 'x' };
assert.equal(replyAddressFor(mail), 'dsh@.x');
});
test('replyAddressFor: 无别名时退回默认会话形式', () => {
const mail = { from_name: 'admin', session_alias: '' };
const addr = replyAddressFor(mail);
assert.equal(addr, 'admin');
// 调用方靠有没有 . 判断这是不是「投回同一条会话」
assert.ok(!addr.includes('.'), '默认会话形式不含 session 位');
});
test('selfAddressFor: 抄送方取自己那个地址的 path', () => {
// to_workspace 是主收件人的工作目录。抄送方拿它当自己的 path
// 「我是谁」这句话就指向了别人的目录。
assert.equal(selfAddressFor(ccMail, 'opencode'), 'opencode@/home.silent-harbor');
assert.equal(selfAddressFor(ccMail, 'dsh'), 'dsh@/home/program/llmsproxy.silent-harbor');
});
test('participantsOfMail: 抄送方的 path 是自己那个', () => {
const parts = participantsOfMail(ccMail, 'dsh');
const byName = Object.fromEntries(parts.map(p => [p.name, p]));
assert.equal(byName.opencode.path, '/home');
assert.equal(byName.opencode.address, 'opencode@/home.silent-harbor');
assert.equal(byName.dsh.path, '/home/program/llmsproxy');
// 发件人 path 留空,理由同 replyAddressFor
assert.equal(byName.admin.address, 'admin@.silent-harbor');
});
test('participantsOfMail: 地址一律用会话别名,不带 .new', () => {
// cc_list 里原本记的是 opencode@/home.new。参与方地址必须换成别名
// 否则「回给抄收方」这个动作每次都会新开会话。
for (const p of participantsOfMail(ccMail, 'dsh')) {
assert.ok(!p.address.endsWith('.new'), `${p.name} 的地址仍是 .new: ${p.address}`);
}
});
test('participantsOfMail: 自己被标记而不是被剔除', () => {
// 剔掉的话模型无法确认这封信是不是也发给了自己,
// 也就无法判断自己该不该回。
const parts = participantsOfMail(ccMail, 'opencode');
const me = parts.find(p => p.name === 'opencode');
assert.ok(me, '自己应出现在参与方列表里');
assert.equal(me.is_self, true);
assert.equal(parts.filter(p => p.is_self).length, 1);
});
test('participantsOfMail: 角色齐全且顺序为 from → to → cc', () => {
// 主收件人稳定排在抄送方之前,模型据此判断谁是负责人、谁是配合方
const parts = participantsOfMail(ccMail, 'dsh');
assert.deepEqual(parts.map(p => p.role), ['from', 'to', 'cc']);
});
test('participantsOfMail: 无抄送时只有两方', () => {
const mail = { from_name: 'admin', to_name: 'dsh', to_workspace: '/w', session_alias: 'a' };
const parts = participantsOfMail(mail, 'dsh');
assert.equal(parts.length, 2);
});
test('participantsOfMail: 跳过空名字条目', () => {
// cc_list 里出现空对象(历史数据或解析残缺)不该产出一个 address 为空的参与方
const mail = {
from_name: 'admin', to_name: 'dsh', to_workspace: '/w',
cc_list: [{ name: '', path: '/x' }, {}],
session_alias: 'a',
};
const parts = participantsOfMail(mail, 'dsh');
assert.equal(parts.length, 2);
for (const p of parts) assert.notEqual(p.address, '');
});

View File

@ -0,0 +1,218 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
renderNameSuggestions,
renderPathSuggestions,
renderSessionSuggestions,
renderParticipants,
renderContacts,
renderThread,
} from '../lib/discovery.js';
// 这一组渲染的唯一目的是让模型**不要自己拼地址**。
// 所以断言集中在两点:给出的地址能原样使用;以及模型知道下一步该查什么。
test('renderNameSuggestions 只给名字并指向下一步', () => {
// 此时还不知道 path 与 session硬拼裸名字地址会投到「默认会话」——
// 那不一定是调用方想要的那条。
const got = renderNameSuggestions(['opencode', 'admin']);
assert.match(got, /opencode/);
assert.match(got, /admin/);
assert.match(got, /suggest_address/, '要告诉模型下一步查什么');
});
test('renderNameSuggestions 空列表给明确文案', () => {
assert.match(renderNameSuggestions([]), /没有可投递的收件人/);
assert.match(renderNameSuggestions(undefined), /没有可投递的收件人/);
});
test('renderPathSuggestions 空列表要说清「仍然能发」', () => {
// 不解释的话模型会卡在这一步,或者编一个路径出来。
const got = renderPathSuggestions([], 'admin');
assert.match(got, /可以留空/);
assert.match(got, /admin/);
});
test('renderPathSuggestions 列出目录并指向下一步', () => {
const got = renderPathSuggestions(['/home', '/home/program/agentmail'], 'opencode');
assert.match(got, /\/home\/program\/agentmail/);
assert.match(got, /最近使用/);
assert.match(got, /suggest_address\(name="opencode", path="/);
});
const sessionData = {
kind: 'session',
suggestions: ['silent-harbor', 'happy-tiger', 'new'],
addresses: [
'opencode@/home.silent-harbor',
'opencode@/home.happy-tiger',
'opencode@/home.new',
],
candidates: [
{ alias: 'silent-harbor', title: '联调 llmsproxy', source: 'mail', unread: 2 },
{ alias: 'happy-tiger', title: '补投验证', source: 'mail', unread: 0 },
{ alias: 'new', title: '新建会话', source: 'new' },
],
};
test('renderSessionSuggestions 用服务端拼好的完整地址', () => {
// 插件自己拼过一次,拼错了(空 path 时漏掉 @。addresses 与 suggestions
// 同序由服务端保证,直接用。
const got = renderSessionSuggestions(sessionData, 'opencode', '/home');
assert.match(got, /opencode@\/home\.silent-harbor/);
assert.match(got, /opencode@\/home\.happy-tiger/);
assert.match(got, /原样填进 send_mail 的 to/);
});
test('renderSessionSuggestions 带出标题与未读数', () => {
const got = renderSessionSuggestions(sessionData, 'opencode', '/home');
assert.match(got, /联调 llmsproxy/);
assert.match(got, /2 封未读/);
});
test('不变量new 不与已存在会话混列,且带警告', () => {
// new 排在前面会让模型在想续谈时顺手开出一条新线索 —— 生产上已经发生过。
const got = renderSessionSuggestions(sessionData, 'opencode', '/home');
const lines = got.split('\n');
const newLineIdx = lines.findIndex(l => l.includes('.new'));
const harborIdx = lines.findIndex(l => l.includes('silent-harbor'));
assert.ok(harborIdx >= 0 && newLineIdx > harborIdx, 'new 必须排在已存在会话之后');
assert.match(got, /新\*\*线索|新\*\*/, 'new 要带「这是开新线索」的提示');
});
test('renderSessionSuggestions 无已存在会话时引导命名', () => {
// 这是关键引导:开新会话时传 session_alias之后才能按名字续谈。
// 不传的话服务端会自动命名,但模型不知道那个名字。
const got = renderSessionSuggestions(
{ suggestions: ['new'], addresses: ['dsh@/tmp.new'], candidates: [{ alias: 'new', source: 'new' }] },
'dsh', '/tmp',
);
assert.match(got, /还没有可续谈的会话/);
assert.match(got, /session_alias/);
});
const participantData = {
session_id: 'f3d824ce',
session_alias: 'silent-harbor',
participants: [
{ name: 'admin', path: '', roles: ['from'], is_self: false, mail_count: 1, address: 'admin@.silent-harbor' },
{ name: 'dsh', path: '/home/program/llmsproxy', roles: ['to'], is_self: true, mail_count: 0, address: 'dsh@/home/program/llmsproxy.silent-harbor' },
{ name: 'opencode', path: '/home', roles: ['cc'], is_self: false, mail_count: 0, address: 'opencode@/home.silent-harbor' },
],
};
test('renderParticipants 给出每个参与方的地址', () => {
const got = renderParticipants(participantData);
assert.match(got, /opencode@\/home\.silent-harbor/);
assert.match(got, /admin@\.silent-harbor/);
assert.match(got, /原样填进 send_mail 的 to/);
});
test('不变量:标出「尚未回应」的人', () => {
// mail_count 为 0 就是还没开口的人。服务端只数「作为发件人」的邮件,
// 正是为了让这个判断成立。
const got = renderParticipants(participantData);
const line = got.split('\n').find(l => l.includes('opencode'));
assert.match(line, /尚未回应/);
// 自己不该被标「尚未回应」—— 自己正在处理这封
const selfLine = got.split('\n').find(l => l.includes('dsh'));
assert.ok(!selfLine.includes('尚未回应'));
assert.match(selfLine, /就是你/);
});
test('renderParticipants 用中文角色标签', () => {
// 模型读到「抄送方」比读到 cc 更容易判对分工。
const got = renderParticipants(participantData);
assert.match(got, /抄送方/);
assert.match(got, /发件人/);
});
test('renderParticipants 无地址时说明原因', () => {
const got = renderParticipants({
session_alias: '',
participants: [{ name: 'x', roles: ['to'], mail_count: 0, address: '' }],
});
assert.match(got, /尚未命名/);
});
test('renderParticipants 空会话不崩', () => {
assert.match(renderParticipants({ participants: [] }), /还没有参与方/);
assert.match(renderParticipants({}), /还没有参与方/);
});
test('renderContacts 未读优先排序', () => {
// 模型问「我还有什么没处理」时,有未读的那些才是答案。
const got = renderContacts({
contacts: [
{ address: 'a@.x', unread_count: 0, last_activity: '2026-09-03T02:00:00Z' },
{ address: 'b@.y', unread_count: 3, last_activity: '2026-09-01T00:00:00Z' },
],
});
const lines = got.split('\n').filter(l => l.startsWith('- '));
assert.match(lines[0], /b@\.y/, '有未读的应排在最前');
assert.match(lines[0], /3 封未读/);
});
test('renderContacts 带出剩余预算', () => {
const got = renderContacts({
contacts: [{ address: 'a@.x', unread_count: 0, max_rounds: 20, used_rounds: 17 }],
});
assert.match(got, /剩 3\/20 个来回/);
});
test('renderContacts 未命名会话说明只能 reply_to', () => {
const got = renderContacts({ contacts: [{ address: '', unread_count: 1 }] });
assert.match(got, /reply_to/);
});
test('renderContacts 空列表', () => {
assert.match(renderContacts({ contacts: [] }), /还没有任何往来会话/);
});
const threadData = {
anchor_mail_id: 'm-2',
total: 3,
hidden: 1,
nodes: [
{ mail_id: 'm-1', from_name: 'admin', to_name: 'dsh', subject: '抄收联调', depth: 0 },
{ mail_id: 'm-2', from_name: 'dsh', to_name: 'opencode', subject: '[联调] 请提供部署现状', depth: 1 },
{ mail_id: 'm-3', from_name: 'opencode', to_name: 'dsh', subject: 'Re: 联调', depth: 2, detached: true, parent_hidden: true },
],
};
test('renderThread 用缩进表示层级', () => {
const got = renderThread(threadData, 'dsh');
const lines = got.split('\n');
const l1 = lines.find(l => l.includes('m-1'));
const l2 = lines.find(l => l.includes('m-2'));
assert.ok(l2.indexOf('- ') > l1.indexOf('- '), '子节点应更深缩进');
});
test('不变量detached 必须标出来', () => {
// 不标的话模型会以为这是一条独立线索,而它其实挂在一封看不到的邮件下面。
const got = renderThread(threadData, 'dsh');
const line = got.split('\n').find(l => l.includes('m-3'));
assert.match(line, /父邮件无权查看/);
});
test('renderThread 标出自己发的与当前这封', () => {
const got = renderThread(threadData, 'dsh');
assert.match(got.split('\n').find(l => l.includes('m-2')), /你发的/);
assert.match(got.split('\n').find(l => l.includes('m-2')), /当前这封/);
});
test('renderThread 报告不可见数量', () => {
// 「共 3 封」与实际列出 3 条一致,但另有 1 封无权查看 ——
// 不说的话模型会以为自己看到了全貌。
assert.match(renderThread(threadData), /另有 1 封无权查看/);
});
test('renderThread 有更多时给出 offset', () => {
const got = renderThread({ ...threadData, has_more: true, next_offset: 60 });
assert.match(got, /offset=60/);
});
test('renderThread 空线索不崩', () => {
assert.match(renderThread({ nodes: [] }), /没有可见的邮件/);
assert.match(renderThread({}), /没有可见的邮件/);
});

View File

@ -117,6 +117,96 @@ test('附件字段不是数组时忽略', () => {
assert.ok(!got.includes('抄送'));
});
// ─── 收件人与身份(只有知道自己是谁才能判定)───
test('不变量:收件人要显示出来', () => {
// 不显示的后果:被抄送方不知道主收件人是谁,无法向对方转达或汇报。
// 线上那封联调邮件要求「由收件人汇报」,而抄送方看不到收件人叫什么。
const got = renderMail(mail({ to_name: 'dsh', to_workspace: '/home/program/llmsproxy' }));
assert.match(got, /收件人: dsh@\/home\/program\/llmsproxy/);
});
test('收件人无工作目录时只显名字', () => {
const got = renderMail(mail({ to_name: 'admin', to_workspace: '' }));
assert.match(got, /收件人: admin$/m);
});
test('不传 selfName 时不出现身份行(兼容旧调用)', () => {
const got = renderMail(mail({ to_name: 'dsh' }));
assert.ok(!got.includes('你的身份'));
});
test('不变量:区分收件人与抄送方身份', () => {
// 两者职责不同。不区分的话两方都会以为自己是负责人,
// 或者都以为自己只是旁观者。
const m = mail({
to_name: 'dsh',
to_workspace: '/home/program/llmsproxy',
cc_list: [{ name: 'opencode', path: '/home', raw: 'opencode@/home.new' }],
});
assert.match(renderMail(m, 200, 'dsh'), /你的身份: 收件人/);
assert.match(renderMail(m, 200, 'opencode'), /你的身份: 抄送方/);
// 不相关的名字不编造身份
assert.ok(!renderMail(m, 200, 'someone').includes('你的身份'));
});
// ─── 可投递地址(「精准发信」的关键)───
const joint = () => mail({
mail_id: 'm-7',
from_name: 'admin',
to_name: 'dsh',
to_workspace: '/home/program/llmsproxy',
cc_list: [{ name: 'opencode', path: '/home', session: 'new', raw: 'opencode@/home.new' }],
session_alias: 'silent-harbor',
});
test('不变量:给出每个参与方的可投递地址', () => {
// 之前模型只能从抄送行里拄一个 `opencode@/home.new`
// 而那个地址回过去只会再建一条平行会话。
const got = renderMail(joint(), 200, 'dsh');
assert.match(got, /可投递地址/);
assert.match(got, /opencode@\/home\.silent-harbor抄送方/);
assert.match(got, /admin@\.silent-harbor发件人/);
});
test('不变量:可投递地址里绝不出现 .new', () => {
// 这是本轮修的根因的直接回归:`.new` 是一次性动作,
// 把它当回信地址会让双方各说各话。
const got = renderMail(joint(), 200, 'dsh');
const line = got.split('\n').find(l => l.startsWith('可投递地址'));
assert.ok(line, '应有可投递地址行');
assert.ok(!line.includes('.new'), `地址行仍含 .new: ${line}`);
});
test('可投递地址不列自己', () => {
const got = renderMail(joint(), 200, 'dsh');
const line = got.split('\n').find(l => l.startsWith('可投递地址'));
assert.ok(!line.includes('dsh@'), `不该把自己当成收件人选项: ${line}`);
});
test('同时给出 reply_to 这条更稳的路', () => {
// 地址可能拼错reply_to 不会 —— 两条路都告诉模型。
const got = renderMail(joint(), 200, 'dsh');
assert.match(got, /reply_to=m-7/);
});
test('无会话别名时不给地址(宁可不给不可给错)', () => {
// 别名为空时拼不出「投回这条会话」的地址。给一个看着能用
// 实际指向默认会话的地址,比不给危险。
const got = renderMail(mail({
to_name: 'dsh', session_alias: '',
cc_list: [{ name: 'opencode', path: '/home' }],
}), 200, 'dsh');
assert.ok(!got.includes('可投递地址'));
});
test('renderInbox 透传 selfName', () => {
const got = renderInbox([joint()], 200, 'opencode');
assert.match(got, /你的身份: 抄送方/);
assert.match(got, /dsh@\/home\/program\/llmsproxy\.silent-harbor收件人/);
});
// ─── renderInbox ───
test('renderInbox 空收件箱给明确文案', () => {

View File

@ -12,6 +12,7 @@ import assert from 'node:assert/strict';
import {
snapshotOpencodeModels,
snapshotDshModels,
snapshotPiModels,
modelAttemptOrder,
renderFailureReport,
MAX_CATALOG,
@ -106,6 +107,46 @@ test('目录截断到 MAX_CATALOG', () => {
assert.equal(snapshotDshModels(many).length, MAX_CATALOG);
});
// ─── pi 目录 ───
test('pi 目录用 provider + id', () => {
const got = snapshotPiModels([
{ provider: 'llmsproxy', id: 'AUTO', name: 'AUTO' },
{ provider: 'anthropic', id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6' },
]);
assert.equal(got.length, 2);
assert.deepEqual(got[1], {
provider: 'anthropic',
model: 'claude-sonnet-4-6',
display_name: 'Claude Sonnet 4.6',
});
});
test('pi 目录跳过缺 provider 或 id 的条目', () => {
const got = snapshotPiModels([
{ provider: '', id: 'x' },
{ provider: 'p' },
{ provider: 'p', id: 'ok' },
]);
assert.equal(got.length, 1);
assert.equal(got[0].model, 'ok');
});
test('pi 目录容错:非数组不崩', () => {
assert.deepEqual(snapshotPiModels(undefined), []);
assert.deepEqual(snapshotPiModels(null), []);
assert.deepEqual(snapshotPiModels('oops'), []);
});
test('pi 目录同样受 MAX_CATALOG 截断', () => {
// 本机 pi 的完整目录有 1221 个模型getModels远超上限。
// 桥实际上报的是 getAvailable() 的结果(只有带凭证的),但截断仍要生效。
const many = Array.from({ length: MAX_CATALOG + 50 }, (_, i) => ({
provider: 'p', id: `m${i}`, name: `M${i}`,
}));
assert.equal(snapshotPiModels(many).length, MAX_CATALOG);
});
// ─── modelAttemptOrder ───
test('管理员划定范围时按 rank 顺序尝试', () => {

View File

@ -12,6 +12,8 @@ import assert from 'node:assert/strict';
import {
snapshotOpencodeSessions,
snapshotDshSessions,
snapshotPiSessions,
isUnusableName,
slugFromTitle,
MAX_REPORTED,
} from '../lib/session-snapshot.js';
@ -226,3 +228,99 @@ test('不同标题不受去重影响', () => {
]);
assert.equal(got.length, 2);
});
// ─── pi名字来自会话文件的 session_info ───
const piSession = (over = {}) => ({
id: '01a064cc-df57-7b2d-bebb-736776105485',
cwd: '/home/program/agentmail',
name: '重构导入路径',
messageCount: 6,
created: new Date(1788300000000),
modified: new Date(1788344476744),
...over,
});
test('pi 快照取 cwd 与 session_info 名字', () => {
const [got] = snapshotPiSessions([piSession()]);
assert.equal(got.workspace, '/home/program/agentmail');
assert.equal(got.title, '重构导入路径');
assert.equal(got.slug, '重构导入路径');
assert.equal(got.platform_id, '01a064cc-df57-7b2d-bebb-736776105485');
});
test('不变量pi 无名会话不上报', () => {
// pi 的列表在无名时显示首条消息,而邮件驱动会话的首条消息是桥自己拼的提示词
// 「你收到一封新邮件AgentMail…」—— 拿它当别名毫无区分度,且条条撞名。
const got = snapshotPiSessions([
piSession({ id: 'named', name: '有名字' }),
piSession({ id: 'anon', name: undefined }),
piSession({ id: 'blank', name: '' }),
]);
assert.deepEqual(got.map(s => s.platform_id), ['named']);
});
test('不变量pi 老会话的空 cwd 照实上报', () => {
// SessionInfo 的注释写明老会话 cwd 是空串。拿桥自己的 cwd 冒充会让
// 那条会话在补全里挂到一个它其实不属于的工作区下。
const [got] = snapshotPiSessions([piSession({ cwd: '' })]);
assert.equal(got.workspace, '');
});
test('不变量pi 的 updated_at 取 modified文件 mtime', () => {
const [got] = snapshotPiSessions([piSession()]);
assert.equal(got.updated_at, new Date(1788344476744).toISOString());
});
test('pi 快照按最近活跃排序并对撞名 slug 去重', () => {
const got = snapshotPiSessions([
piSession({ id: 'old', name: '同一个标题', modified: new Date(1000) }),
piSession({ id: 'new', name: '同一个标题', modified: new Date(9000) }),
]);
assert.equal(got.length, 1);
assert.equal(got[0].platform_id, 'new');
});
test('pi 快照标记邮件驱动的会话', () => {
const got = snapshotPiSessions(
[piSession({ id: 'mail-one' }), piSession({ id: 'human', name: '人开的' })],
(id) => id === 'mail-one'
);
assert.equal(got.find(s => s.platform_id === 'mail-one').mail_driven, true);
assert.equal(got.find(s => s.platform_id === 'human').mail_driven, false);
});
// ─── isUnusableNamepi-web 标题生成器的思维链泄漏 ───
test('不变量:思维链泄漏的标题判废', () => {
// 都是本机 ~/.pi/agent/sessions 里实测捞到的真实 session_info 名字。
// pi-web 的 cleanSessionName 只做「取首行 + 去引号 + 截 60 字符」,不防这个。
assert.equal(isUnusableName('The user is asking me to generate a title for a coding-agent'), true);
assert.equal(
isUnusableName('我们只需要生成标题不包含其他内容。标题应反映请求内容测试opencode的源。简短Opencode源测试。或者更简'),
true
);
});
test('isUnusableName 放过正常标题', () => {
// 宁可漏判也不误判:错杀一个好名字会让那条会话失去可寻址的别名。
assert.equal(isUnusableName('查看Agent接入群聊'), false);
assert.equal(isUnusableName('你应该知道内网拓扑结构吧'), false);
assert.equal(isUnusableName('homeagent-gateway'), false);
assert.equal(isUnusableName('重构导入路径'), false);
assert.equal(isUnusableName('Fix flaky auth test'), false);
});
test('isUnusableName 判废空名字', () => {
assert.equal(isUnusableName(''), true);
assert.equal(isUnusableName(' '), true);
assert.equal(isUnusableName(undefined), true);
});
test('判废的名字不进快照', () => {
const got = snapshotPiSessions([
piSession({ id: 'leaked', name: 'The user is asking me to generate a title for a coding-agent' }),
piSession({ id: 'clean', name: '正常标题' }),
]);
assert.deepEqual(got.map(s => s.platform_id), ['clean']);
});

View File

@ -20,6 +20,14 @@ import {
DEFAULT_INBOX_STATUS,
DEFAULT_INBOX_LIMIT,
} from "./lib/inbox-format.js";
import {
renderNameSuggestions,
renderPathSuggestions,
renderSessionSuggestions,
renderParticipants,
renderContacts,
renderThread,
} from "./lib/discovery.js";
import {
explicitSends,
noteExplicitSend,
@ -242,7 +250,11 @@ const readInboxTool = {
// 渲染与已读策略放 lib/inbox-format.js它们与平台 SDK 无关,
// 各平台插件必须一致(见该文件里每条规则对应的错误行为)。
const listed = renderInbox(data.mails);
//
// 传 AGENT_NAME 是为了让渲染能判定「我是收件人还是抄送方」并给出
// 可投递地址 —— 不传的话模型只能从抄送行里抄一个 `.new`,而那是
// 一次性的,回过去只会再建一条平行会话。
const listed = renderInbox(data.mails, 200, AGENT_NAME);
const ids = idsToMarkRead(args.filter, data.mails);
if (ids.length) {
@ -317,6 +329,130 @@ const downloadAttachmentTool = {
},
};
// ─── 寻址发现工具(读 Agent 侧只读端点)───
//
// 在这一组之前send_mail 的 to 是个只能靠记忆拼写的自由文本字段。人类侧
// 从来不是这样三段式输入框逐段查候选。Agent 只能猜,而猜错不报错 ——
// 生产上 dsh 猜了 `opencode@/home`,投递成功,但那不是 opencode 的工作目录,
// 那个错误路径静默变成了新会话的 workspace。
//
// 渲染逻辑在 lib/discovery.js与平台 SDK 无关,三平台共用)。
const suggestAddressTool = {
description:
"查询可用的收件人地址,用于精准发信。分三段逐步查:不带参数给候选收件人名;" +
"带 name 给它可用的工作目录name+path 都带则给该目录下可续谈的会话别名与现成地址。" +
"**发信前应先用它确认地址**,不要凭记忆拼写 —— 拼错不会报错,只会投到别的会话。",
args: {
name: z.string().optional().describe("收件人名;留空则列出所有候选收件人"),
path: z.string().optional().describe("工作目录;与 name 同时给出才列会话"),
},
async execute(args) {
const name = (args.name || "").trim();
const path = (args.path || "").trim();
const qs = new URLSearchParams();
if (name) qs.set("name", name);
if (path) qs.set("path", path);
const data = await apiGet(`/agent/contacts/suggest?${qs.toString()}`);
// 按服务端回的 kind 分派,而不是按本地参数判断:省略 path 与传空串在
// 服务端是同一个意思,但「哪一段该渲染成什么」只有服务端知道。
switch (data?.kind) {
case "name":
return renderNameSuggestions(data.suggestions);
case "path":
return renderPathSuggestions(data.suggestions, name);
default:
return renderSessionSuggestions(data, name, path);
}
},
};
const listContactsTool = {
description:
"列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。" +
"用于回答「我还有什么没处理」以及「上次跟某人聊的那条线索地址是什么」。",
args: {
limit: z.number().optional().describe("最多列出多少条,默认 20"),
},
async execute(args) {
const data = await apiGet("/agent/contacts");
return renderContacts(data, args.limit || 20);
},
};
const sessionParticipantsTool = {
description:
"列出某条会话的全部参与方(发件人/收件人/抄送方)及各自的可投递地址," +
"并标出谁还没回应。**要回给抄收方或向第三方转达时先用它拿地址**。",
args: {
session_id: z.string().describe("会话 IDread_inbox 未直接给出时可从 read_thread 或新邮件通知取得)"),
},
async execute(args) {
const data = await apiGet(`/agent/sessions/${args.session_id}/participants`);
return renderParticipants(data);
},
};
const readThreadTool = {
description:
"查看一封邮件所在线索的完整往来(谁回了谁、谁还没回)。多方抄送协作时" +
"用它确认别人已经说了什么,避免重复提问或重复汇报。",
args: {
mail_id: z.string().describe("线索中任一封邮件的 ID"),
offset: z.number().optional().describe("分页偏移,续取时传上次返回的 next_offset"),
},
async execute(args) {
const qs = args.offset ? `?offset=${args.offset}` : "";
const data = await apiGet(`/agent/mail/${args.mail_id}/thread${qs}`);
return renderThread(data, AGENT_NAME);
},
};
const readMailTool = {
description:
"读一封邮件的完整内容,含收件人、抄送清单、附件与每个参与方的可投递地址。" +
"收件箱只给摘要;要回给抄收方就得先看清这封信发给了谁。",
args: {
mail_id: z.string().describe("邮件 ID"),
},
async execute(args) {
const data = await apiGet(`/agent/mail/${args.mail_id}`);
const m = data?.mail || {};
const lines = [
`发件人: ${m.from_name || "?"}`,
`收件人: ${m.to_name || "?"}${m.to_workspace ? "@" + m.to_workspace : ""}`,
`主题: ${m.subject || "(无主题)"}`,
`会话: #${data.session_alias || "未命名"}session_id: ${m.session_id || "?"}`,
];
if (Array.isArray(m.cc_list) && m.cc_list.length) {
lines.push(`抄送: ${m.cc_list.map(c => c?.raw || c?.name).join("、")}`);
}
if (Array.isArray(m.attachments) && m.attachments.length) {
lines.push(
`附件: ${m.attachments
.map(a => `${a.filename}${formatSize(a.size_bytes)}, id=${a.attachment_id}`)
.join("、")}`
);
}
lines.push("", m.body || "(空正文)", "");
// 参与方地址由服务端拼好session 位已是真实别名,不是 .new
if (Array.isArray(data.participants) && data.participants.length) {
lines.push(
"可投递地址: " +
data.participants
.filter(p => p.address && p.name !== AGENT_NAME)
.map(p => `${p.address}${p.role}`)
.join("、")
);
}
if (data.reply_address) {
lines.push(`回信给发件人用 ${data.reply_address},或传 reply_to=${m.mail_id}`);
}
return lines.join("\n");
},
};
// 平台原生权限询问 → 邮件。
//
// **不作为工具暴露给模型**opencode 自己就有权限机制permission.ask 钩子 /
@ -1052,10 +1188,16 @@ export default async function mailBridge(input) {
tool: {
send_mail: sendMailTool,
read_inbox: readInboxTool,
read_mail: readMailTool,
forward_mail: forwardMailTool,
upload_attachment: uploadAttachmentTool,
download_attachment: downloadAttachmentTool,
connect_to_server: connectToServerTool,
// 寻址发现:让模型选地址而不是拼地址
suggest_address: suggestAddressTool,
list_contacts: listContactsTool,
session_participants: sessionParticipantsTool,
read_thread: readThreadTool,
},
};
}

View File

@ -0,0 +1,141 @@
/**
* 三维寻址的构造与判读 —— 所有平台插件共用。
*
* 为什么这些函数必须共用、且必须是纯函数:
*
* 地址拼错不会报错。`name@path.session` 的每一段都可以省略,任何组合都能被
* `ParseAddress` 解析出**某个**结果,于是拼错的代价不是失败而是**投到别处**。
* 生产上真实发生过两次:
*
* 1. 插件把 `.new` 原样当作回信地址 —— `.new` 是一次性动作,回过去只会
* 再建一条平行会话,双方从此各说各话。
* 2. path 为空时朴素拼接得到 `admin.silent-harbor` —— 没有 `@`
* 整串被当成名字session 位静默丢失。
*
* 两次都是「拼字符串」造成的,所以拼地址这件事收进这里,各平台不再自己拼。
*/
/**
* 拼一个可寻址的 `name@path.session`。
*
* **空 path 也必须留下 `@` 与 `.`**`admin@.silent-harbor` 才解析成
* name=admin path="" session=silent-harbor。省掉 `@` 得到的
* `admin.silent-harbor` 会被整串当作名字。
*
* session 省略时不写那一位(默认会话语义)。
*
* @param {string} name 收件方名Agent 名或人类用户名)
* @param {string} [path] 工作目录,可为空
* @param {string} [session] 会话别名;空则省略该位
* @returns {string} 地址name 为空时返回空串
*/
export function formatAddress(name, path, session) {
const n = String(name ?? '').trim();
const p = String(path ?? '').trim();
const s = String(session ?? '').trim();
if (!n) return '';
if (!s) return p ? `${n}@${p}` : n;
return `${n}@${p}.${s}`;
}
/**
* 判断自己在这封邮件里是收件人还是抄送方。
*
* 为什么需要它:被抄送方与主收件人的**职责不同**。线上那封联调邮件里,
* admin 主发 dsh、抄送 opencode分工是「dsh 提供源码解读、opencode 提供部署
* 现状、最后由 dsh 汇报」。收件箱若不区分身份,两方都会以为自己是负责人,
* 或者都以为自己只是旁观者。
*
* @param {any} mail `/mail/inbox` 返回的一封邮件
* @param {string} selfName 自己的 Agent 名
* @returns {'to'|'cc'|'unknown'}
*/
export function roleOf(mail, selfName) {
const self = String(selfName ?? '').trim();
if (!self) return 'unknown';
if (mail?.to_name === self) return 'to';
if (Array.isArray(mail?.cc_list) && mail.cc_list.some(c => c?.name === self)) {
return 'cc';
}
return 'unknown';
}
/**
* 给出「把回信发回这条会话」的地址。
*
* 发件人一侧**不带 path**Agent 回信时 `from_workspace` 存的是 Agent 名而不是
* 路径(历史遗留),拿它拼会得到 `dsh@dsh.alias` 这种投不出去的东西。
* 人类发件人本来就没有工作目录。
*
* 别名为空时退回 `name`(默认会话)而不是编一个 —— 但注意这与「投回同一条会话」
* 不等价,默认会话是该 name 当前最活跃的那条。调用方要区分时看返回值有没有 `.`。
*
* @param {any} mail 一封邮件
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
* @returns {string}
*/
export function replyAddressFor(mail, alias) {
const a = alias ?? mail?.session_alias ?? '';
return formatAddress(mail?.from_name, '', a);
}
/**
* 给出自己在这条会话里的地址,供转发说明或向第三方引用时使用。
*
* 用 `to_workspace`(自己那个地址的 path 位)而不是发件人的:
* 抄送给 `opencode@/a` 与主发给 `dsh@/b` 是两个不同的工作区。
*
* @param {any} mail 一封邮件
* @param {string} selfName 自己的 Agent 名
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
* @returns {string}
*/
export function selfAddressFor(mail, selfName, alias) {
const a = alias ?? mail?.session_alias ?? '';
// 抄送方拿到的 to_workspace 是主收件人的,自己的 path 在 cc_list 里。
// 不取对的那个会让「我是谁」这句话指向别人的工作目录。
let path = mail?.to_workspace ?? '';
if (mail?.to_name !== selfName && Array.isArray(mail?.cc_list)) {
const mine = mail.cc_list.find(c => c?.name === selfName);
if (mine) path = mine.path ?? '';
}
return formatAddress(selfName, path, a);
}
/**
* 列出这封邮件的全部参与方及各自可投递的地址。
*
* 这是「回给抄收方」缺的那块信息:知道有谁,**以及用什么地址找到他**。
* 抄送方的 path 取它自己那个地址的 path 位。
*
* 自己会被标 `is_self`,而不是从列表里剔掉 —— 剔掉的话模型无法确认
* 「这封信是不是也发给了我」,也就无法判断自己是不是该回。
*
* @param {any} mail 一封邮件
* @param {string} [selfName] 自己的名字,用于标记 is_self
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
* @returns {{role: string, name: string, path: string, address: string, is_self: boolean}[]}
*/
export function participantsOfMail(mail, selfName, alias) {
const a = alias ?? mail?.session_alias ?? '';
const self = String(selfName ?? '').trim();
const out = [];
const add = (role, name, path) => {
const n = String(name ?? '').trim();
if (!n) return;
out.push({
role,
name: n,
path: String(path ?? ''),
address: formatAddress(n, path, a),
is_self: !!self && n === self,
});
};
// 发件人一侧 path 留空,理由同 replyAddressFor
add('from', mail?.from_name, '');
add('to', mail?.to_name, mail?.to_workspace);
if (Array.isArray(mail?.cc_list)) {
for (const c of mail.cc_list) add('cc', c?.name, c?.path);
}
return out;
}

View File

@ -0,0 +1,237 @@
/**
* 寻址发现工具 —— 所有平台插件共用的**纯逻辑**部分。
*
* 三个 Agent 侧只读端点(`/agent/contacts`、`/agent/contacts/suggest`、
* `/agent/sessions/{id}/participants`)的返回值怎么渲染给模型看,与平台 SDK 无关,
* 所以收进这里。各平台只负责把自己的工具定义壳套上去。
*
* # 这一组端点解决的问题
*
* 在它们存在之前,`send_mail` 的 `to` 是一个**只能靠记忆拼写的自由文本字段**。
* 人类侧从来不是这样三段式输入框逐段查候选name / path / session 每一段都从
* 活数据里选。Agent 只能猜,而猜错不会报错 —— 生产上 dsh 猜了
* `opencode@/home`,地址解析通过、投递成功,但那不是 opencode 的工作目录,
* 那个错误路径静默变成了新会话的 workspace。
*
* # 渲染的取舍
*
* 一律输出**可直接粘进 `to` 的完整地址**,而不是把三段分开列。模型看到
* `opencode@/home.silent-harbor` 会整串复制;看到 `name=opencode path=/home
* session=silent-harbor` 则要自己拼,而自己拼就是问题的来源。
*/
/**
* 渲染候选收件人清单(`kind: "name"`)。
*
* 只给名字,不给地址:此时还不知道 path 与 session硬拼出来的
* 裸名字地址会投到「默认会话」—— 那不一定是调用方想要的那条。
* 明确提示下一步该查什么,模型才会继续往下走而不是就地拼一个。
*
* @param {string[]} names
* @returns {string}
*/
export function renderNameSuggestions(names) {
const list = Array.isArray(names) ? names.filter(Boolean) : [];
if (list.length === 0) return '当前没有可投递的收件人。';
return [
`可投递的收件人(${list.length} 个):`,
list.map(n => `- ${n}`).join('\n'),
'',
'下一步:用 suggest_address 带上 name 查它可用的工作目录path 位)。',
].join('\n');
}
/**
* 渲染工作目录候选(`kind: "path"`)。
*
* 空列表要说清「这不代表不能发」path 位允许为空(人类用户没有工作目录),
* 不解释的话模型会卡在这一步,或者编一个路径出来。
*
* @param {string[]} paths
* @param {string} name 正在查的收件人名,用于拼下一步的提示
* @returns {string}
*/
export function renderPathSuggestions(paths, name) {
const list = Array.isArray(paths) ? paths.filter(Boolean) : [];
if (list.length === 0) {
return [
`${name} 没有记录在案的工作目录。`,
'这不代表不能给它发信 —— path 位可以留空(人类用户就没有工作目录)。',
`直接用 suggest_address(name="${name}", path="") 查它的会话,或直接发给 ${name}`,
].join('\n');
}
return [
`${name} 用过的工作目录(按最近使用排序):`,
list.map(p => `- ${p}`).join('\n'),
'',
`下一步:用 suggest_address(name="${name}", path="<上面某一个>") 查该目录下可续谈的会话。`,
].join('\n');
}
/**
* 渲染会话候选(`kind: "session"`)。
*
* **`addresses` 与 `suggestions` 同序**,服务端保证。这里优先用 `addresses`
* 那是服务端拼好的完整地址,插件不必自己拼(自己拼过一次,拼错了)。
*
* `new` 永远在最后且带一句警告:它不是一条已存在的会话。排在前面会让模型
* 在想续谈时顺手开出一条新线索 —— 生产上已经发生过。
*
* @param {object} data `/agent/contacts/suggest` 的返回体
* @param {string} name
* @param {string} path
* @returns {string}
*/
export function renderSessionSuggestions(data, name, path) {
const aliases = Array.isArray(data?.suggestions) ? data.suggestions : [];
const addresses = Array.isArray(data?.addresses) ? data.addresses : [];
const candidates = Array.isArray(data?.candidates) ? data.candidates : [];
// 只有 new 一项 = 这个 name@path 下还没有任何可续谈的会话
const existing = aliases.filter(a => a !== 'new');
if (existing.length === 0) {
return [
`${name}${path ? '@' + path : ''} 下还没有可续谈的会话。`,
`要开一条新线索用 ${addressAt(addresses, aliases, 'new') || `${name}@${path}.new`}`,
'并在 send_mail 里传 session_alias 给它命名,之后就能按名字续谈。',
].join('\n');
}
const lines = [`${name}${path ? '@' + path : ''} 下可续谈的会话:`];
for (let i = 0; i < aliases.length; i++) {
const alias = aliases[i];
const addr = addresses[i] || '';
const c = candidates[i] || {};
if (alias === 'new') continue; // new 单独放最后
const bits = [];
if (c.title) bits.push(c.title);
if (typeof c.unread === 'number' && c.unread > 0) bits.push(`${c.unread} 封未读`);
if (c.source === 'platform') bits.push('平台侧会话');
lines.push(`- ${addr || alias}${bits.length ? ` ${bits.join('')}` : ''}`);
}
lines.push('');
lines.push('把上面某个地址原样填进 send_mail 的 to 即可投进那条会话。');
const newAddr = addressAt(addresses, aliases, 'new');
if (newAddr) {
lines.push(`若确实要开一条**新**线索(而不是接着上面某条谈)才用 ${newAddr}`);
}
return lines.join('\n');
}
/** 按别名在同序的 addresses 里取地址。 */
function addressAt(addresses, aliases, alias) {
const i = aliases.indexOf(alias);
return i >= 0 ? addresses[i] || '' : '';
}
/**
* 渲染会话参与方清单。
*
* 这是「发送给抄收方 / 转发方」缺的最后一块:知道有谁、**用什么地址找到他**、
* 以及谁还没开口。`mail_count` 为 0 的那个就是还没回应的人 —— 服务端只数
* 「作为发件人」的邮件,正是为了让这个判断成立。
*
* @param {object} data `/agent/sessions/{id}/participants` 的返回体
* @returns {string}
*/
export function renderParticipants(data) {
const parts = Array.isArray(data?.participants) ? data.participants : [];
if (parts.length === 0) return '该会话还没有参与方(可能是一条刚建立的空会话)。';
const alias = data?.session_alias || '';
const lines = [`会话 #${alias || '未命名'} 的参与方:`];
for (const p of parts) {
const tags = [];
if (p.is_self) tags.push('就是你');
if (Array.isArray(p.roles) && p.roles.length) {
tags.push(p.roles.map(roleLabel).join('/'));
}
if (p.mail_count === 0 && !p.is_self) tags.push('尚未回应');
const addr = p.address ? p.address : '(无可投递地址:该会话尚未命名)';
lines.push(`- ${p.name} ${addr}${tags.length ? ` [${tags.join('')}]` : ''}`);
}
lines.push('');
lines.push('要联系其中某一方,把它的地址原样填进 send_mail 的 to。');
return lines.join('\n');
}
/**
* 渲染联系人清单(本 Agent 参与过的全部会话)。
*
* 按未读优先、其次最近活跃排序:模型问「我还有什么没处理」时,
* 有未读的那些才是答案。
*
* @param {object} data `/agent/contacts` 的返回体
* @param {number} limit 最多列出多少条
* @returns {string}
*/
export function renderContacts(data, limit = 20) {
const list = Array.isArray(data?.contacts) ? data.contacts.slice() : [];
if (list.length === 0) return '还没有任何往来会话。';
list.sort((a, b) => {
const ua = a?.unread_count || 0;
const ub = b?.unread_count || 0;
if (ua !== ub) return ub - ua;
return String(b?.last_activity || '').localeCompare(String(a?.last_activity || ''));
});
const shown = list.slice(0, limit);
const lines = [`往来会话(共 ${list.length}${list.length > shown.length ? `,列出前 ${shown.length}` : ''}`];
for (const c of shown) {
const bits = [];
if (c.unread_count > 0) bits.push(`${c.unread_count} 封未读`);
if (c.subject) bits.push(c.subject);
if (c.max_rounds > 0) {
const left = Math.max(0, c.max_rounds - (c.used_rounds || 0));
bits.push(`${left}/${c.max_rounds} 个来回`);
}
const addr = c.address || '(未命名会话,只能用 reply_to 续谈)';
lines.push(`- ${addr}${bits.length ? ` ${bits.join('')}` : ''}`);
}
return lines.join('\n');
}
/** 角色的中文标签。模型读到「抄送方」比读到 cc 更容易判对分工。 */
function roleLabel(role) {
switch (role) {
case 'from': return '发件人';
case 'to': return '收件人';
case 'cc': return '抄送方';
default: return String(role);
}
}
/**
* 渲染对话树,回答「谁已经回了、谁还没回」。
*
* 缩进表示层级。**detached 必须标出来**:那表示父邮件不在本次结果里
* (无权查看或尚未加载),不标的话模型会以为这是一条独立线索。
*
* @param {object} data `/agent/mail/{id}/thread` 的返回体
* @param {string} [selfName] 自己的名字,用于标出哪几封是自己发的
* @returns {string}
*/
export function renderThread(data, selfName = '') {
const nodes = Array.isArray(data?.nodes) ? data.nodes : [];
if (nodes.length === 0) return '这条线索上没有可见的邮件。';
const lines = [`线索共 ${data?.total ?? nodes.length}${data?.hidden ? `(另有 ${data.hidden} 封无权查看)` : ''}`];
for (const n of nodes) {
const depth = typeof n?.depth === 'number' ? Math.max(0, n.depth) : 0;
const indent = ' '.repeat(Math.min(depth, 8));
const marks = [];
if (selfName && n?.from_name === selfName) marks.push('你发的');
if (n?.mail_id === data?.anchor_mail_id) marks.push('当前这封');
if (n?.detached) marks.push(n.parent_hidden ? '父邮件无权查看' : '父邮件尚未加载');
lines.push(
`${indent}- ${n?.from_name ?? '?'}${n?.to_name ?? '?'}: ${n?.subject ?? '(无主题)'}` +
` [${n?.mail_id ?? '?'}]${marks.length ? ` (${marks.join('')})` : ''}`
);
}
if (data?.has_more) {
lines.push('');
lines.push(`还有更多,用 offset=${data.next_offset} 继续取。`);
}
return lines.join('\n');
}

View File

@ -6,6 +6,8 @@
* 渲染出的文本与标记已读的时机都该一致。新接一个平台时直接复用这里。
*/
import { roleOf, replyAddressFor, participantsOfMail } from './addressing.js';
/** 人类可读的字节数,用于附件清单展示。 */
export function formatSize(n) {
if (typeof n !== 'number' || !Number.isFinite(n)) return '?';
@ -19,19 +21,41 @@ export function formatSize(n) {
*
* @param {any} m `/mail/inbox` 返回的一封邮件
* @param {number} bodyLimit 正文截断长度
* @param {string} [selfName] 自己的 Agent 名。给了就能判定「我是收件人还是抄送方」
* 并给出参与方地址;不给则退化成旧行为(兼容未传该参数的调用方)。
* @returns {string}
*/
export function renderMail(m, bodyLimit = 200) {
export function renderMail(m, bodyLimit = 200, selfName = '') {
const alias = m?.session_alias || '';
const lines = [
`[${m?.status ?? 'unknown'}] ${m?.from_name ?? 'unknown'}: ${m?.subject ?? '(无主题)'}`,
`邮件 ID: ${m?.mail_id ?? 'unknown'}`,
`会话: #${m?.session_alias || '未命名'}`,
`会话: #${alias || '未命名'}`,
];
// 收件人必须显示。不显示的后果:被抄送方既不知道主收件人是谁,
// 也无法向对方转达或汇报 —— 线上那封联调邮件要求「由收件人汇报」,
// 抄送方却看不到收件人叫什么。
if (m?.to_name) {
let toLine = `收件人: ${m.to_name}`;
if (m?.to_workspace) toLine += `@${m.to_workspace}`;
lines.push(toLine);
}
// 抄送要显示:一封邮件为什么同时到了几个人手上,只有抄送能解释。
// 不显示的话模型会以为这是私下发给它一个人的,回信时漏掉其他参与方。
if (Array.isArray(m?.cc_list) && m.cc_list.length > 0) {
lines.push('抄送: ' + m.cc_list.map(c => c?.raw || c?.name || '?').join('、'));
}
// 自己的身份。抄送方与主收件人的职责不同,不区分的话两方都会
// 以为自己是负责人,或者都以为自己只是旁观者。
if (selfName) {
const role = roleOf(m, selfName);
if (role === 'to') lines.push('你的身份: 收件人(主办)');
else if (role === 'cc') lines.push('你的身份: 抄送方(配合)');
}
// **必须给出 attachment_id**:只说「有附件」模型就无从下载。
if (Array.isArray(m?.attachments) && m.attachments.length > 0) {
lines.push(
@ -42,22 +66,52 @@ export function renderMail(m, bodyLimit = 200) {
);
lines.push('下载附件请用 download_attachment 工具。');
}
// 列表接口只给 body_preview省带宽单封接口才有 body。两者都兜住。
const body = m?.body_preview || m?.body || '';
lines.push(`内容: ${String(body).slice(0, bodyLimit)}`);
// 可投递地址放在最后,紧贴正文 —— 模型读完内容紧接着就要决定发给谁。
//
// 这一段是「精准发信」的关键:之前模型只能从抄送行里拄一个
// `opencode@/home.new` 拄过去,而 `.new` 是一次性的,回过去只会再建一条
// 平行会话。这里给的地址全部已经把 session 位换成真实别名。
if (selfName && alias) {
const parts = participantsOfMail(m, selfName, alias);
const others = parts.filter(p => !p.is_self && p.address);
if (others.length > 0) {
lines.push(
'可投递地址: ' +
others.map(p => `${p.address}${roleLabel(p.role)}`).join('、')
);
lines.push(`直接回信给发件人用 ${replyAddressFor(m, alias)},或传 reply_to=${m?.mail_id ?? ''}`);
}
}
return lines.join('\n');
}
/** 角色的中文标签。模型读到「抄送方」比读到 cc 更容易判对分工。 */
function roleLabel(role) {
switch (role) {
case 'from': return '发件人';
case 'to': return '收件人';
case 'cc': return '抄送方';
default: return role;
}
}
/**
* 渲染整个收件箱。
* @param {any[]} mails
* @param {number} bodyLimit
* @param {string} [selfName] 自己的 Agent 名,透传给 renderMail
* @returns {string}
*/
export function renderInbox(mails, bodyLimit = 200) {
export function renderInbox(mails, bodyLimit = 200, selfName = '') {
const list = Array.isArray(mails) ? mails : [];
if (list.length === 0) return '收件箱为空。';
return list.map(m => renderMail(m, bodyLimit)).join('\n\n');
return list.map(m => renderMail(m, bodyLimit, selfName)).join('\n\n');
}
/**

View File

@ -65,6 +65,37 @@ export function snapshotDshModels(entries) {
return dedupeAndCap(out);
}
/**
* 把 pi 的模型列表整理成上报格式。
*
* pi 侧的取法是 `await modelRuntime.getAvailable()` —— **不是** `getModels()`。
* 两者差别很大:本机实测目录里有 1221 个模型,而带凭证、真能调起来的只有 1 个。
* 上报 `getModels()` 的结果会让管理员在配置页选中一个注定失败的路由,
* 而失败要到真发邮件时才暴露(模型目录上报的全部意义就是避免这件事)。
*
* pi 的 Model 对象上provider 在 `provider` 字段、模型 id 在 `id` 字段,
* 展示名在 `name`。形状与 DSH 侧一致,但语义来源不同,因此单独一个函数
* ——照抄 snapshotDshModels 会让「必须用 getAvailable」这条约束无处记录。
*
* @param {any[]} models `await modelRuntime.getAvailable()` 的结果
* @returns {object[]}
*/
export function snapshotPiModels(models) {
const list = Array.isArray(models) ? models : [];
const out = [];
for (const m of list) {
const provider = typeof m?.provider === 'string' ? m.provider : '';
const model = typeof m?.id === 'string' ? m.id : '';
if (!provider || !model) continue;
out.push({
provider,
model,
display_name: typeof m?.name === 'string' ? m.name : '',
});
}
return dedupeAndCap(out);
}
/**
* 决定这一轮按什么顺序尝试模型。
*

View File

@ -83,6 +83,83 @@ export function snapshotDshSessions(entries, isMailDriven = () => false) {
return dedupeBySlug(sortAndCap(out));
}
/**
* 把 pi 的 `SessionManager.list()/listAll()` 结果整理成上报格式。
*
* pi 的会话名字来自会话文件里最后一条 `session_info` 条目:
* - pi-web 在一条会话的首次 prompt 时用模型生成一个 2-6 词的标题
* - TUI 的 `/name`、启动参数 `--name`、`/resume` 里的改名也写同一处
* - **pi 内核SDK自己不生成**:桥用 createAgentSession 起的会话没有名字,
* 要由桥按「Gateway 定稿的别名」回写(见 index 的 syncNaming
*
* 与另两个平台的差异pi 的 SessionInfo 里**没有 subagent 标记**。
* pi-subagents 把子会话写在自定义 sessionDirrun 根目录)下,默认会话目录
* 列不到它们,因此这里不需要 S-2 那样的显式过滤。
*
* @param {any[]} entries SessionInfo 列表 `[{ id, cwd, name, modified }]`
* @param {(id: string) => boolean} isMailDriven
* @returns {object[]}
*/
export function snapshotPiSessions(entries, isMailDriven = () => false) {
const list = Array.isArray(entries) ? entries : [];
const out = [];
for (const e of list) {
const id = typeof e?.id === 'string' ? e.id : '';
if (!id) continue;
const name = typeof e?.name === 'string' ? e.name : '';
// 没有名字的会话不报S-1pi 的列表在无名时显示首条消息,
// 而首条消息对邮件驱动的会话就是桥自己拼的提示词 —— 拿它当别名毫无区分度。
if (!name) continue;
// 模型把思维链当标题写进来的那些不报(见 isUnusableName
if (isUnusableName(name)) continue;
const slug = slugFromTitle(name);
if (!slug) continue;
out.push({
platform_id: id,
// 老会话的 cwd 是空串pi 的 SessionInfo 注释里写明了),照实上报,
// 服务端按空 workspace 处理,不要拿桥自己的 cwd 冒充。
workspace: typeof e?.cwd === 'string' ? e.cwd : '',
slug,
title: name,
mail_driven: Boolean(isMailDriven(id)),
updated_at: toISO(e?.modified ?? e?.created),
});
}
return dedupeBySlug(sortAndCap(out));
}
/**
* 判断一个平台侧名字是否不适合当别名。
*
* 这条判废是 pi 特有的pi-web 的标题生成器(`sessionNameGenerator`)只做了
* 「取首行 + 去引号 + 截 60 字符」,没有防思维链泄漏。本机 81 条会话里实测捞到:
*
* "The user is asking me to generate a title for a coding-agent"
* "我们只需要生成标题不包含其他内容。标题应反映请求内容测试opencode的源。简短…"
*
* 这类字符串派生出的别名又长又没有指代作用,填进三维地址里更是灾难。
* 判废后调用方回退到「不上报」或「用邮件主题派生」,都比它强。
*
* 宁可漏判也不误判:错杀一个好名字会让那条会话失去可寻址的别名,
* 而漏掉一个坏名字只是别名难看。
*
* @param {string} name
* @returns {boolean}
*/
export function isUnusableName(name) {
const s = String(name ?? '').trim();
if (!s) return true;
// 自指标题生成任务 = 模型把系统提示词复述了出来
if (/生成标题|标题应|拟一个标题|generate a (short |concise )?title|session title|as a title/i.test(s)) {
return true;
}
// 以第三人称叙述用户意图开头 = 思维链的典型开场
if (/^(the user\b|用户(想|要|在|希望)|我们只需要|我需要先|首先(|,))/i.test(s)) return true;
// 又长又分句 = 一段话而不是一个标题pi-web 截断上限是 60
if (s.length >= 48 && /[。;;]|\.\s/.test(s)) return true;
return false;
}
/** 判断一条会话是否为 subagent 子会话。两个字段任一成立即算。 */
function isSubagent(e) {
if (e?.origin === 'subagent') return true;
@ -131,11 +208,17 @@ export function slugFromTitle(title) {
return slug;
}
/** 毫秒时间戳ISO 串 → ISO 串;无法解析时返回 undefined。 */
/** 毫秒时间戳ISO 串或 Date → ISO 串;无法解析时返回 undefined。 */
function toISO(v) {
if (typeof v === 'number' && Number.isFinite(v)) {
return new Date(v).toISOString();
}
// pi 的 SessionInfo 给的是 Date 实例created/modified不是时间戳。
// 少了这一支会让整份快照的 updated_at 全是 undefined于是服务端只能按
// 上报时间排序 —— 补全列表里「最近在谈的那条」不再排在前面。
if (v instanceof Date) {
return Number.isNaN(v.getTime()) ? undefined : v.toISOString();
}
if (typeof v === 'string' && v) {
const d = new Date(v);
if (!Number.isNaN(d.getTime())) return d.toISOString();

View File

@ -0,0 +1,145 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
formatAddress,
roleOf,
replyAddressFor,
selfAddressFor,
participantsOfMail,
} from '../lib/addressing.js';
// 地址拼错不会报错,只会投到别处 —— 所以这一组测试全部落在
// 「拼出来的东西还能不能被正确解析回三段」上。
test('formatAddress: 空 path 仍保留 @ 与 .', () => {
// 生产事故:朴素拼接得到 admin.silent-harbor没有 @
// 整串被 ParseAddress 当成名字session 位静默丢失。
assert.equal(formatAddress('admin', '', 'silent-harbor'), 'admin@.silent-harbor');
});
test('formatAddress: 省略 session 位', () => {
assert.equal(formatAddress('dsh', '/home/program/agentmail', ''), 'dsh@/home/program/agentmail');
// 名字与 path 都有但都不带会话 → 默认会话语义
assert.equal(formatAddress('dsh', '', ''), 'dsh');
});
test('formatAddress: path 含 . 与 / 时仍按最后一个 . 切', () => {
// path 里允许 . 与 /,切分靠最后一个 . —— 拼出来的必须满足这个约定
const addr = formatAddress('bot', '/srv/app.v2', 'fix-leak');
assert.equal(addr, 'bot@/srv/app.v2.fix-leak');
assert.equal(addr.slice(addr.lastIndexOf('.') + 1), 'fix-leak');
});
test('formatAddress: 名字为空返回空串而不是残缺地址', () => {
// 返回 "@/path.alias" 会被投递端当成缺名字报错,
// 但那是在很后面才发现;这里直接给空串让调用方立刻看出没法拼。
assert.equal(formatAddress('', '/p', 'a'), '');
assert.equal(formatAddress(null, '/p', 'a'), '');
});
test('formatAddress: 去掉首尾空白', () => {
assert.equal(formatAddress(' dsh ', ' /home ', ' alias '), 'dsh@/home.alias');
});
const ccMail = {
from_name: 'admin',
to_name: 'dsh',
to_workspace: '/home/program/llmsproxy',
cc_list: [{ name: 'opencode', path: '/home', session: 'new', raw: 'opencode@/home.new' }],
session_alias: 'silent-harbor',
};
test('roleOf: 区分主收件人与抄送方', () => {
// 被抄送方与主收件人职责不同:线上那封联调邮件里 dsh 负责汇报、
// opencode 只提供信息。不区分身份两方都会以为自己是负责人。
assert.equal(roleOf(ccMail, 'dsh'), 'to');
assert.equal(roleOf(ccMail, 'opencode'), 'cc');
assert.equal(roleOf(ccMail, 'someone-else'), 'unknown');
});
test('roleOf: 名字为空时不猜', () => {
assert.equal(roleOf(ccMail, ''), 'unknown');
assert.equal(roleOf(ccMail, undefined), 'unknown');
});
test('replyAddressFor: 用会话别名而非原地址的 .new', () => {
// 关键回归:把 .new 原样当回信地址会再建一条平行会话。
const addr = replyAddressFor(ccMail);
assert.equal(addr, 'admin@.silent-harbor');
assert.ok(!addr.endsWith('.new'), '回信地址不得以 .new 结尾');
});
test('replyAddressFor: 发件人一侧不带 path', () => {
// Agent 回信时 from_workspace 存的是 Agent 名而不是路径,
// 拿它拼会得到 dsh@dsh.alias —— 投不出去。
const mail = { from_name: 'dsh', from_workspace: 'dsh', session_alias: 'x' };
assert.equal(replyAddressFor(mail), 'dsh@.x');
});
test('replyAddressFor: 无别名时退回默认会话形式', () => {
const mail = { from_name: 'admin', session_alias: '' };
const addr = replyAddressFor(mail);
assert.equal(addr, 'admin');
// 调用方靠有没有 . 判断这是不是「投回同一条会话」
assert.ok(!addr.includes('.'), '默认会话形式不含 session 位');
});
test('selfAddressFor: 抄送方取自己那个地址的 path', () => {
// to_workspace 是主收件人的工作目录。抄送方拿它当自己的 path
// 「我是谁」这句话就指向了别人的目录。
assert.equal(selfAddressFor(ccMail, 'opencode'), 'opencode@/home.silent-harbor');
assert.equal(selfAddressFor(ccMail, 'dsh'), 'dsh@/home/program/llmsproxy.silent-harbor');
});
test('participantsOfMail: 抄送方的 path 是自己那个', () => {
const parts = participantsOfMail(ccMail, 'dsh');
const byName = Object.fromEntries(parts.map(p => [p.name, p]));
assert.equal(byName.opencode.path, '/home');
assert.equal(byName.opencode.address, 'opencode@/home.silent-harbor');
assert.equal(byName.dsh.path, '/home/program/llmsproxy');
// 发件人 path 留空,理由同 replyAddressFor
assert.equal(byName.admin.address, 'admin@.silent-harbor');
});
test('participantsOfMail: 地址一律用会话别名,不带 .new', () => {
// cc_list 里原本记的是 opencode@/home.new。参与方地址必须换成别名
// 否则「回给抄收方」这个动作每次都会新开会话。
for (const p of participantsOfMail(ccMail, 'dsh')) {
assert.ok(!p.address.endsWith('.new'), `${p.name} 的地址仍是 .new: ${p.address}`);
}
});
test('participantsOfMail: 自己被标记而不是被剔除', () => {
// 剔掉的话模型无法确认这封信是不是也发给了自己,
// 也就无法判断自己该不该回。
const parts = participantsOfMail(ccMail, 'opencode');
const me = parts.find(p => p.name === 'opencode');
assert.ok(me, '自己应出现在参与方列表里');
assert.equal(me.is_self, true);
assert.equal(parts.filter(p => p.is_self).length, 1);
});
test('participantsOfMail: 角色齐全且顺序为 from → to → cc', () => {
// 主收件人稳定排在抄送方之前,模型据此判断谁是负责人、谁是配合方
const parts = participantsOfMail(ccMail, 'dsh');
assert.deepEqual(parts.map(p => p.role), ['from', 'to', 'cc']);
});
test('participantsOfMail: 无抄送时只有两方', () => {
const mail = { from_name: 'admin', to_name: 'dsh', to_workspace: '/w', session_alias: 'a' };
const parts = participantsOfMail(mail, 'dsh');
assert.equal(parts.length, 2);
});
test('participantsOfMail: 跳过空名字条目', () => {
// cc_list 里出现空对象(历史数据或解析残缺)不该产出一个 address 为空的参与方
const mail = {
from_name: 'admin', to_name: 'dsh', to_workspace: '/w',
cc_list: [{ name: '', path: '/x' }, {}],
session_alias: 'a',
};
const parts = participantsOfMail(mail, 'dsh');
assert.equal(parts.length, 2);
for (const p of parts) assert.notEqual(p.address, '');
});

View File

@ -0,0 +1,218 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
renderNameSuggestions,
renderPathSuggestions,
renderSessionSuggestions,
renderParticipants,
renderContacts,
renderThread,
} from '../lib/discovery.js';
// 这一组渲染的唯一目的是让模型**不要自己拼地址**。
// 所以断言集中在两点:给出的地址能原样使用;以及模型知道下一步该查什么。
test('renderNameSuggestions 只给名字并指向下一步', () => {
// 此时还不知道 path 与 session硬拼裸名字地址会投到「默认会话」——
// 那不一定是调用方想要的那条。
const got = renderNameSuggestions(['opencode', 'admin']);
assert.match(got, /opencode/);
assert.match(got, /admin/);
assert.match(got, /suggest_address/, '要告诉模型下一步查什么');
});
test('renderNameSuggestions 空列表给明确文案', () => {
assert.match(renderNameSuggestions([]), /没有可投递的收件人/);
assert.match(renderNameSuggestions(undefined), /没有可投递的收件人/);
});
test('renderPathSuggestions 空列表要说清「仍然能发」', () => {
// 不解释的话模型会卡在这一步,或者编一个路径出来。
const got = renderPathSuggestions([], 'admin');
assert.match(got, /可以留空/);
assert.match(got, /admin/);
});
test('renderPathSuggestions 列出目录并指向下一步', () => {
const got = renderPathSuggestions(['/home', '/home/program/agentmail'], 'opencode');
assert.match(got, /\/home\/program\/agentmail/);
assert.match(got, /最近使用/);
assert.match(got, /suggest_address\(name="opencode", path="/);
});
const sessionData = {
kind: 'session',
suggestions: ['silent-harbor', 'happy-tiger', 'new'],
addresses: [
'opencode@/home.silent-harbor',
'opencode@/home.happy-tiger',
'opencode@/home.new',
],
candidates: [
{ alias: 'silent-harbor', title: '联调 llmsproxy', source: 'mail', unread: 2 },
{ alias: 'happy-tiger', title: '补投验证', source: 'mail', unread: 0 },
{ alias: 'new', title: '新建会话', source: 'new' },
],
};
test('renderSessionSuggestions 用服务端拼好的完整地址', () => {
// 插件自己拼过一次,拼错了(空 path 时漏掉 @。addresses 与 suggestions
// 同序由服务端保证,直接用。
const got = renderSessionSuggestions(sessionData, 'opencode', '/home');
assert.match(got, /opencode@\/home\.silent-harbor/);
assert.match(got, /opencode@\/home\.happy-tiger/);
assert.match(got, /原样填进 send_mail 的 to/);
});
test('renderSessionSuggestions 带出标题与未读数', () => {
const got = renderSessionSuggestions(sessionData, 'opencode', '/home');
assert.match(got, /联调 llmsproxy/);
assert.match(got, /2 封未读/);
});
test('不变量new 不与已存在会话混列,且带警告', () => {
// new 排在前面会让模型在想续谈时顺手开出一条新线索 —— 生产上已经发生过。
const got = renderSessionSuggestions(sessionData, 'opencode', '/home');
const lines = got.split('\n');
const newLineIdx = lines.findIndex(l => l.includes('.new'));
const harborIdx = lines.findIndex(l => l.includes('silent-harbor'));
assert.ok(harborIdx >= 0 && newLineIdx > harborIdx, 'new 必须排在已存在会话之后');
assert.match(got, /新\*\*线索|新\*\*/, 'new 要带「这是开新线索」的提示');
});
test('renderSessionSuggestions 无已存在会话时引导命名', () => {
// 这是关键引导:开新会话时传 session_alias之后才能按名字续谈。
// 不传的话服务端会自动命名,但模型不知道那个名字。
const got = renderSessionSuggestions(
{ suggestions: ['new'], addresses: ['dsh@/tmp.new'], candidates: [{ alias: 'new', source: 'new' }] },
'dsh', '/tmp',
);
assert.match(got, /还没有可续谈的会话/);
assert.match(got, /session_alias/);
});
const participantData = {
session_id: 'f3d824ce',
session_alias: 'silent-harbor',
participants: [
{ name: 'admin', path: '', roles: ['from'], is_self: false, mail_count: 1, address: 'admin@.silent-harbor' },
{ name: 'dsh', path: '/home/program/llmsproxy', roles: ['to'], is_self: true, mail_count: 0, address: 'dsh@/home/program/llmsproxy.silent-harbor' },
{ name: 'opencode', path: '/home', roles: ['cc'], is_self: false, mail_count: 0, address: 'opencode@/home.silent-harbor' },
],
};
test('renderParticipants 给出每个参与方的地址', () => {
const got = renderParticipants(participantData);
assert.match(got, /opencode@\/home\.silent-harbor/);
assert.match(got, /admin@\.silent-harbor/);
assert.match(got, /原样填进 send_mail 的 to/);
});
test('不变量:标出「尚未回应」的人', () => {
// mail_count 为 0 就是还没开口的人。服务端只数「作为发件人」的邮件,
// 正是为了让这个判断成立。
const got = renderParticipants(participantData);
const line = got.split('\n').find(l => l.includes('opencode'));
assert.match(line, /尚未回应/);
// 自己不该被标「尚未回应」—— 自己正在处理这封
const selfLine = got.split('\n').find(l => l.includes('dsh'));
assert.ok(!selfLine.includes('尚未回应'));
assert.match(selfLine, /就是你/);
});
test('renderParticipants 用中文角色标签', () => {
// 模型读到「抄送方」比读到 cc 更容易判对分工。
const got = renderParticipants(participantData);
assert.match(got, /抄送方/);
assert.match(got, /发件人/);
});
test('renderParticipants 无地址时说明原因', () => {
const got = renderParticipants({
session_alias: '',
participants: [{ name: 'x', roles: ['to'], mail_count: 0, address: '' }],
});
assert.match(got, /尚未命名/);
});
test('renderParticipants 空会话不崩', () => {
assert.match(renderParticipants({ participants: [] }), /还没有参与方/);
assert.match(renderParticipants({}), /还没有参与方/);
});
test('renderContacts 未读优先排序', () => {
// 模型问「我还有什么没处理」时,有未读的那些才是答案。
const got = renderContacts({
contacts: [
{ address: 'a@.x', unread_count: 0, last_activity: '2026-09-03T02:00:00Z' },
{ address: 'b@.y', unread_count: 3, last_activity: '2026-09-01T00:00:00Z' },
],
});
const lines = got.split('\n').filter(l => l.startsWith('- '));
assert.match(lines[0], /b@\.y/, '有未读的应排在最前');
assert.match(lines[0], /3 封未读/);
});
test('renderContacts 带出剩余预算', () => {
const got = renderContacts({
contacts: [{ address: 'a@.x', unread_count: 0, max_rounds: 20, used_rounds: 17 }],
});
assert.match(got, /剩 3\/20 个来回/);
});
test('renderContacts 未命名会话说明只能 reply_to', () => {
const got = renderContacts({ contacts: [{ address: '', unread_count: 1 }] });
assert.match(got, /reply_to/);
});
test('renderContacts 空列表', () => {
assert.match(renderContacts({ contacts: [] }), /还没有任何往来会话/);
});
const threadData = {
anchor_mail_id: 'm-2',
total: 3,
hidden: 1,
nodes: [
{ mail_id: 'm-1', from_name: 'admin', to_name: 'dsh', subject: '抄收联调', depth: 0 },
{ mail_id: 'm-2', from_name: 'dsh', to_name: 'opencode', subject: '[联调] 请提供部署现状', depth: 1 },
{ mail_id: 'm-3', from_name: 'opencode', to_name: 'dsh', subject: 'Re: 联调', depth: 2, detached: true, parent_hidden: true },
],
};
test('renderThread 用缩进表示层级', () => {
const got = renderThread(threadData, 'dsh');
const lines = got.split('\n');
const l1 = lines.find(l => l.includes('m-1'));
const l2 = lines.find(l => l.includes('m-2'));
assert.ok(l2.indexOf('- ') > l1.indexOf('- '), '子节点应更深缩进');
});
test('不变量detached 必须标出来', () => {
// 不标的话模型会以为这是一条独立线索,而它其实挂在一封看不到的邮件下面。
const got = renderThread(threadData, 'dsh');
const line = got.split('\n').find(l => l.includes('m-3'));
assert.match(line, /父邮件无权查看/);
});
test('renderThread 标出自己发的与当前这封', () => {
const got = renderThread(threadData, 'dsh');
assert.match(got.split('\n').find(l => l.includes('m-2')), /你发的/);
assert.match(got.split('\n').find(l => l.includes('m-2')), /当前这封/);
});
test('renderThread 报告不可见数量', () => {
// 「共 3 封」与实际列出 3 条一致,但另有 1 封无权查看 ——
// 不说的话模型会以为自己看到了全貌。
assert.match(renderThread(threadData), /另有 1 封无权查看/);
});
test('renderThread 有更多时给出 offset', () => {
const got = renderThread({ ...threadData, has_more: true, next_offset: 60 });
assert.match(got, /offset=60/);
});
test('renderThread 空线索不崩', () => {
assert.match(renderThread({ nodes: [] }), /没有可见的邮件/);
assert.match(renderThread({}), /没有可见的邮件/);
});

View File

@ -117,6 +117,96 @@ test('附件字段不是数组时忽略', () => {
assert.ok(!got.includes('抄送'));
});
// ─── 收件人与身份(只有知道自己是谁才能判定)───
test('不变量:收件人要显示出来', () => {
// 不显示的后果:被抄送方不知道主收件人是谁,无法向对方转达或汇报。
// 线上那封联调邮件要求「由收件人汇报」,而抄送方看不到收件人叫什么。
const got = renderMail(mail({ to_name: 'dsh', to_workspace: '/home/program/llmsproxy' }));
assert.match(got, /收件人: dsh@\/home\/program\/llmsproxy/);
});
test('收件人无工作目录时只显名字', () => {
const got = renderMail(mail({ to_name: 'admin', to_workspace: '' }));
assert.match(got, /收件人: admin$/m);
});
test('不传 selfName 时不出现身份行(兼容旧调用)', () => {
const got = renderMail(mail({ to_name: 'dsh' }));
assert.ok(!got.includes('你的身份'));
});
test('不变量:区分收件人与抄送方身份', () => {
// 两者职责不同。不区分的话两方都会以为自己是负责人,
// 或者都以为自己只是旁观者。
const m = mail({
to_name: 'dsh',
to_workspace: '/home/program/llmsproxy',
cc_list: [{ name: 'opencode', path: '/home', raw: 'opencode@/home.new' }],
});
assert.match(renderMail(m, 200, 'dsh'), /你的身份: 收件人/);
assert.match(renderMail(m, 200, 'opencode'), /你的身份: 抄送方/);
// 不相关的名字不编造身份
assert.ok(!renderMail(m, 200, 'someone').includes('你的身份'));
});
// ─── 可投递地址(「精准发信」的关键)───
const joint = () => mail({
mail_id: 'm-7',
from_name: 'admin',
to_name: 'dsh',
to_workspace: '/home/program/llmsproxy',
cc_list: [{ name: 'opencode', path: '/home', session: 'new', raw: 'opencode@/home.new' }],
session_alias: 'silent-harbor',
});
test('不变量:给出每个参与方的可投递地址', () => {
// 之前模型只能从抄送行里拄一个 `opencode@/home.new`
// 而那个地址回过去只会再建一条平行会话。
const got = renderMail(joint(), 200, 'dsh');
assert.match(got, /可投递地址/);
assert.match(got, /opencode@\/home\.silent-harbor抄送方/);
assert.match(got, /admin@\.silent-harbor发件人/);
});
test('不变量:可投递地址里绝不出现 .new', () => {
// 这是本轮修的根因的直接回归:`.new` 是一次性动作,
// 把它当回信地址会让双方各说各话。
const got = renderMail(joint(), 200, 'dsh');
const line = got.split('\n').find(l => l.startsWith('可投递地址'));
assert.ok(line, '应有可投递地址行');
assert.ok(!line.includes('.new'), `地址行仍含 .new: ${line}`);
});
test('可投递地址不列自己', () => {
const got = renderMail(joint(), 200, 'dsh');
const line = got.split('\n').find(l => l.startsWith('可投递地址'));
assert.ok(!line.includes('dsh@'), `不该把自己当成收件人选项: ${line}`);
});
test('同时给出 reply_to 这条更稳的路', () => {
// 地址可能拼错reply_to 不会 —— 两条路都告诉模型。
const got = renderMail(joint(), 200, 'dsh');
assert.match(got, /reply_to=m-7/);
});
test('无会话别名时不给地址(宁可不给不可给错)', () => {
// 别名为空时拼不出「投回这条会话」的地址。给一个看着能用
// 实际指向默认会话的地址,比不给危险。
const got = renderMail(mail({
to_name: 'dsh', session_alias: '',
cc_list: [{ name: 'opencode', path: '/home' }],
}), 200, 'dsh');
assert.ok(!got.includes('可投递地址'));
});
test('renderInbox 透传 selfName', () => {
const got = renderInbox([joint()], 200, 'opencode');
assert.match(got, /你的身份: 抄送方/);
assert.match(got, /dsh@\/home\/program\/llmsproxy\.silent-harbor收件人/);
});
// ─── renderInbox ───
test('renderInbox 空收件箱给明确文案', () => {

View File

@ -12,6 +12,7 @@ import assert from 'node:assert/strict';
import {
snapshotOpencodeModels,
snapshotDshModels,
snapshotPiModels,
modelAttemptOrder,
renderFailureReport,
MAX_CATALOG,
@ -106,6 +107,46 @@ test('目录截断到 MAX_CATALOG', () => {
assert.equal(snapshotDshModels(many).length, MAX_CATALOG);
});
// ─── pi 目录 ───
test('pi 目录用 provider + id', () => {
const got = snapshotPiModels([
{ provider: 'llmsproxy', id: 'AUTO', name: 'AUTO' },
{ provider: 'anthropic', id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6' },
]);
assert.equal(got.length, 2);
assert.deepEqual(got[1], {
provider: 'anthropic',
model: 'claude-sonnet-4-6',
display_name: 'Claude Sonnet 4.6',
});
});
test('pi 目录跳过缺 provider 或 id 的条目', () => {
const got = snapshotPiModels([
{ provider: '', id: 'x' },
{ provider: 'p' },
{ provider: 'p', id: 'ok' },
]);
assert.equal(got.length, 1);
assert.equal(got[0].model, 'ok');
});
test('pi 目录容错:非数组不崩', () => {
assert.deepEqual(snapshotPiModels(undefined), []);
assert.deepEqual(snapshotPiModels(null), []);
assert.deepEqual(snapshotPiModels('oops'), []);
});
test('pi 目录同样受 MAX_CATALOG 截断', () => {
// 本机 pi 的完整目录有 1221 个模型getModels远超上限。
// 桥实际上报的是 getAvailable() 的结果(只有带凭证的),但截断仍要生效。
const many = Array.from({ length: MAX_CATALOG + 50 }, (_, i) => ({
provider: 'p', id: `m${i}`, name: `M${i}`,
}));
assert.equal(snapshotPiModels(many).length, MAX_CATALOG);
});
// ─── modelAttemptOrder ───
test('管理员划定范围时按 rank 顺序尝试', () => {

View File

@ -12,6 +12,8 @@ import assert from 'node:assert/strict';
import {
snapshotOpencodeSessions,
snapshotDshSessions,
snapshotPiSessions,
isUnusableName,
slugFromTitle,
MAX_REPORTED,
} from '../lib/session-snapshot.js';
@ -226,3 +228,99 @@ test('不同标题不受去重影响', () => {
]);
assert.equal(got.length, 2);
});
// ─── pi名字来自会话文件的 session_info ───
const piSession = (over = {}) => ({
id: '01a064cc-df57-7b2d-bebb-736776105485',
cwd: '/home/program/agentmail',
name: '重构导入路径',
messageCount: 6,
created: new Date(1788300000000),
modified: new Date(1788344476744),
...over,
});
test('pi 快照取 cwd 与 session_info 名字', () => {
const [got] = snapshotPiSessions([piSession()]);
assert.equal(got.workspace, '/home/program/agentmail');
assert.equal(got.title, '重构导入路径');
assert.equal(got.slug, '重构导入路径');
assert.equal(got.platform_id, '01a064cc-df57-7b2d-bebb-736776105485');
});
test('不变量pi 无名会话不上报', () => {
// pi 的列表在无名时显示首条消息,而邮件驱动会话的首条消息是桥自己拼的提示词
// 「你收到一封新邮件AgentMail…」—— 拿它当别名毫无区分度,且条条撞名。
const got = snapshotPiSessions([
piSession({ id: 'named', name: '有名字' }),
piSession({ id: 'anon', name: undefined }),
piSession({ id: 'blank', name: '' }),
]);
assert.deepEqual(got.map(s => s.platform_id), ['named']);
});
test('不变量pi 老会话的空 cwd 照实上报', () => {
// SessionInfo 的注释写明老会话 cwd 是空串。拿桥自己的 cwd 冒充会让
// 那条会话在补全里挂到一个它其实不属于的工作区下。
const [got] = snapshotPiSessions([piSession({ cwd: '' })]);
assert.equal(got.workspace, '');
});
test('不变量pi 的 updated_at 取 modified文件 mtime', () => {
const [got] = snapshotPiSessions([piSession()]);
assert.equal(got.updated_at, new Date(1788344476744).toISOString());
});
test('pi 快照按最近活跃排序并对撞名 slug 去重', () => {
const got = snapshotPiSessions([
piSession({ id: 'old', name: '同一个标题', modified: new Date(1000) }),
piSession({ id: 'new', name: '同一个标题', modified: new Date(9000) }),
]);
assert.equal(got.length, 1);
assert.equal(got[0].platform_id, 'new');
});
test('pi 快照标记邮件驱动的会话', () => {
const got = snapshotPiSessions(
[piSession({ id: 'mail-one' }), piSession({ id: 'human', name: '人开的' })],
(id) => id === 'mail-one'
);
assert.equal(got.find(s => s.platform_id === 'mail-one').mail_driven, true);
assert.equal(got.find(s => s.platform_id === 'human').mail_driven, false);
});
// ─── isUnusableNamepi-web 标题生成器的思维链泄漏 ───
test('不变量:思维链泄漏的标题判废', () => {
// 都是本机 ~/.pi/agent/sessions 里实测捞到的真实 session_info 名字。
// pi-web 的 cleanSessionName 只做「取首行 + 去引号 + 截 60 字符」,不防这个。
assert.equal(isUnusableName('The user is asking me to generate a title for a coding-agent'), true);
assert.equal(
isUnusableName('我们只需要生成标题不包含其他内容。标题应反映请求内容测试opencode的源。简短Opencode源测试。或者更简'),
true
);
});
test('isUnusableName 放过正常标题', () => {
// 宁可漏判也不误判:错杀一个好名字会让那条会话失去可寻址的别名。
assert.equal(isUnusableName('查看Agent接入群聊'), false);
assert.equal(isUnusableName('你应该知道内网拓扑结构吧'), false);
assert.equal(isUnusableName('homeagent-gateway'), false);
assert.equal(isUnusableName('重构导入路径'), false);
assert.equal(isUnusableName('Fix flaky auth test'), false);
});
test('isUnusableName 判废空名字', () => {
assert.equal(isUnusableName(''), true);
assert.equal(isUnusableName(' '), true);
assert.equal(isUnusableName(undefined), true);
});
test('判废的名字不进快照', () => {
const got = snapshotPiSessions([
piSession({ id: 'leaked', name: 'The user is asking me to generate a title for a coding-agent' }),
piSession({ id: 'clean', name: '正常标题' }),
]);
assert.deepEqual(got.map(s => s.platform_id), ['clean']);
});

View File

@ -0,0 +1,141 @@
/**
* 三维寻址的构造与判读 —— 所有平台插件共用。
*
* 为什么这些函数必须共用、且必须是纯函数:
*
* 地址拼错不会报错。`name@path.session` 的每一段都可以省略,任何组合都能被
* `ParseAddress` 解析出**某个**结果,于是拼错的代价不是失败而是**投到别处**。
* 生产上真实发生过两次:
*
* 1. 插件把 `.new` 原样当作回信地址 —— `.new` 是一次性动作,回过去只会
* 再建一条平行会话,双方从此各说各话。
* 2. path 为空时朴素拼接得到 `admin.silent-harbor` —— 没有 `@`
* 整串被当成名字session 位静默丢失。
*
* 两次都是「拼字符串」造成的,所以拼地址这件事收进这里,各平台不再自己拼。
*/
/**
* 拼一个可寻址的 `name@path.session`。
*
* **空 path 也必须留下 `@` 与 `.`**`admin@.silent-harbor` 才解析成
* name=admin path="" session=silent-harbor。省掉 `@` 得到的
* `admin.silent-harbor` 会被整串当作名字。
*
* session 省略时不写那一位(默认会话语义)。
*
* @param {string} name 收件方名Agent 名或人类用户名)
* @param {string} [path] 工作目录,可为空
* @param {string} [session] 会话别名;空则省略该位
* @returns {string} 地址name 为空时返回空串
*/
export function formatAddress(name, path, session) {
const n = String(name ?? '').trim();
const p = String(path ?? '').trim();
const s = String(session ?? '').trim();
if (!n) return '';
if (!s) return p ? `${n}@${p}` : n;
return `${n}@${p}.${s}`;
}
/**
* 判断自己在这封邮件里是收件人还是抄送方。
*
* 为什么需要它:被抄送方与主收件人的**职责不同**。线上那封联调邮件里,
* admin 主发 dsh、抄送 opencode分工是「dsh 提供源码解读、opencode 提供部署
* 现状、最后由 dsh 汇报」。收件箱若不区分身份,两方都会以为自己是负责人,
* 或者都以为自己只是旁观者。
*
* @param {any} mail `/mail/inbox` 返回的一封邮件
* @param {string} selfName 自己的 Agent 名
* @returns {'to'|'cc'|'unknown'}
*/
export function roleOf(mail, selfName) {
const self = String(selfName ?? '').trim();
if (!self) return 'unknown';
if (mail?.to_name === self) return 'to';
if (Array.isArray(mail?.cc_list) && mail.cc_list.some(c => c?.name === self)) {
return 'cc';
}
return 'unknown';
}
/**
* 给出「把回信发回这条会话」的地址。
*
* 发件人一侧**不带 path**Agent 回信时 `from_workspace` 存的是 Agent 名而不是
* 路径(历史遗留),拿它拼会得到 `dsh@dsh.alias` 这种投不出去的东西。
* 人类发件人本来就没有工作目录。
*
* 别名为空时退回 `name`(默认会话)而不是编一个 —— 但注意这与「投回同一条会话」
* 不等价,默认会话是该 name 当前最活跃的那条。调用方要区分时看返回值有没有 `.`。
*
* @param {any} mail 一封邮件
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
* @returns {string}
*/
export function replyAddressFor(mail, alias) {
const a = alias ?? mail?.session_alias ?? '';
return formatAddress(mail?.from_name, '', a);
}
/**
* 给出自己在这条会话里的地址,供转发说明或向第三方引用时使用。
*
* 用 `to_workspace`(自己那个地址的 path 位)而不是发件人的:
* 抄送给 `opencode@/a` 与主发给 `dsh@/b` 是两个不同的工作区。
*
* @param {any} mail 一封邮件
* @param {string} selfName 自己的 Agent 名
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
* @returns {string}
*/
export function selfAddressFor(mail, selfName, alias) {
const a = alias ?? mail?.session_alias ?? '';
// 抄送方拿到的 to_workspace 是主收件人的,自己的 path 在 cc_list 里。
// 不取对的那个会让「我是谁」这句话指向别人的工作目录。
let path = mail?.to_workspace ?? '';
if (mail?.to_name !== selfName && Array.isArray(mail?.cc_list)) {
const mine = mail.cc_list.find(c => c?.name === selfName);
if (mine) path = mine.path ?? '';
}
return formatAddress(selfName, path, a);
}
/**
* 列出这封邮件的全部参与方及各自可投递的地址。
*
* 这是「回给抄收方」缺的那块信息:知道有谁,**以及用什么地址找到他**。
* 抄送方的 path 取它自己那个地址的 path 位。
*
* 自己会被标 `is_self`,而不是从列表里剔掉 —— 剔掉的话模型无法确认
* 「这封信是不是也发给了我」,也就无法判断自己是不是该回。
*
* @param {any} mail 一封邮件
* @param {string} [selfName] 自己的名字,用于标记 is_self
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
* @returns {{role: string, name: string, path: string, address: string, is_self: boolean}[]}
*/
export function participantsOfMail(mail, selfName, alias) {
const a = alias ?? mail?.session_alias ?? '';
const self = String(selfName ?? '').trim();
const out = [];
const add = (role, name, path) => {
const n = String(name ?? '').trim();
if (!n) return;
out.push({
role,
name: n,
path: String(path ?? ''),
address: formatAddress(n, path, a),
is_self: !!self && n === self,
});
};
// 发件人一侧 path 留空,理由同 replyAddressFor
add('from', mail?.from_name, '');
add('to', mail?.to_name, mail?.to_workspace);
if (Array.isArray(mail?.cc_list)) {
for (const c of mail.cc_list) add('cc', c?.name, c?.path);
}
return out;
}

View File

@ -0,0 +1,74 @@
/**
* 启动补拉:把插件离线期间到的邮件变成与 SSE 事件同形的投递任务。
*
* 为什么需要它:**SSE 只推连上之后的事件**。插件重启前发来的邮件不会再推一次,
* 心跳响应的 `pending_mails` 是唯一线索。不补拉的后果是那封邮件永远躺在
* 收件箱里,而发件人以为 Agent 收到了 —— 这比明确的失败更难排查。
*
* 两个平台共用必须逐字节相同deploy/check-shared-libs.sh 校验)。
*/
/**
* 一次补拉最多处理几封。
*
* 上限存在的理由:每封都要起一轮模型。攒了 80 封的时候一次性全放出去,
* 等于对上游打 80 个并发请求,且最后那几封要等前面全部跑完。
* 超出的部分留在收件箱里,下次重启或人工触发时再处理。
*/
export const MAX_CATCHUP = 5;
/**
* 把收件箱里的一封邮件转成 SSE `new_mail` 那个形状。
*
* 补拉与 SSE 走同一条投递路径deliverMail因此形状必须一致 ——
* 两条路径各写一遍投递逻辑的话,某一条上的修复会漏掉另一条。
*
* @param {any} mail `/mail/inbox` 返回的一行
* @returns {{mail_id: string, session_id: string, from_name: string,
* subject: string, mail_type: string, role: string,
* to_workspace: string, catchup: true}}
*/
export function mailToEvent(mail) {
return {
mail_id: mail?.mail_id || '',
session_id: mail?.session_id || '',
from_name: mail?.from_name || '',
subject: mail?.subject || '',
mail_type: mail?.mail_type || 'normal',
role: 'to',
to_workspace: mail?.to_workspace || '',
// 标记来源,投递侧可据此决定是否在提示词里说明「这是积压的邮件」
catchup: true,
};
}
/**
* 从收件箱挑出该补投的邮件。
*
* @param {any[]} mails `/mail/inbox?status=unread` 的结果
* @param {Set<string>} seen 已经通过 SSE 投过的 mail_id避免重复投递
* @param {number} [max] 上限,默认 MAX_CATCHUP
* @returns {any[]} 与 SSE 事件同形的投递任务,按时间正序(老的先处理)
*/
export function selectCatchup(mails, seen, max = MAX_CATCHUP) {
if (!Array.isArray(mails) || mails.length === 0) return [];
const picked = [];
for (const m of mails) {
const id = m?.mail_id;
if (!id) continue;
// 心跳与 SSE 建连之间有个窗口:那期间到的邮件既在 pending_mails 里、
// 也会被 SSE 推一次。不去重就会投两遍,模型回两封信。
if (seen && seen.has(id)) continue;
// permission 类邮件不补投它是给人看的询问Agent 侧没有可恢复的上下文
// (原来的工具调用早随进程一起没了),投过去只会让模型困惑。
if (m?.mail_type && m.mail_type !== 'normal') continue;
picked.push(m);
}
// 收件箱按时间倒序返回,补投要按正序 —— 先来的先处理,
// 否则同一会话里的多封邮件会被倒着塞进去,上下文顺序是乱的。
picked.reverse();
return picked.slice(0, Math.max(0, max)).map(mailToEvent);
}

View File

@ -0,0 +1,237 @@
/**
* 寻址发现工具 —— 所有平台插件共用的**纯逻辑**部分。
*
* 三个 Agent 侧只读端点(`/agent/contacts`、`/agent/contacts/suggest`、
* `/agent/sessions/{id}/participants`)的返回值怎么渲染给模型看,与平台 SDK 无关,
* 所以收进这里。各平台只负责把自己的工具定义壳套上去。
*
* # 这一组端点解决的问题
*
* 在它们存在之前,`send_mail` 的 `to` 是一个**只能靠记忆拼写的自由文本字段**。
* 人类侧从来不是这样三段式输入框逐段查候选name / path / session 每一段都从
* 活数据里选。Agent 只能猜,而猜错不会报错 —— 生产上 dsh 猜了
* `opencode@/home`,地址解析通过、投递成功,但那不是 opencode 的工作目录,
* 那个错误路径静默变成了新会话的 workspace。
*
* # 渲染的取舍
*
* 一律输出**可直接粘进 `to` 的完整地址**,而不是把三段分开列。模型看到
* `opencode@/home.silent-harbor` 会整串复制;看到 `name=opencode path=/home
* session=silent-harbor` 则要自己拼,而自己拼就是问题的来源。
*/
/**
* 渲染候选收件人清单(`kind: "name"`)。
*
* 只给名字,不给地址:此时还不知道 path 与 session硬拼出来的
* 裸名字地址会投到「默认会话」—— 那不一定是调用方想要的那条。
* 明确提示下一步该查什么,模型才会继续往下走而不是就地拼一个。
*
* @param {string[]} names
* @returns {string}
*/
export function renderNameSuggestions(names) {
const list = Array.isArray(names) ? names.filter(Boolean) : [];
if (list.length === 0) return '当前没有可投递的收件人。';
return [
`可投递的收件人(${list.length} 个):`,
list.map(n => `- ${n}`).join('\n'),
'',
'下一步:用 suggest_address 带上 name 查它可用的工作目录path 位)。',
].join('\n');
}
/**
* 渲染工作目录候选(`kind: "path"`)。
*
* 空列表要说清「这不代表不能发」path 位允许为空(人类用户没有工作目录),
* 不解释的话模型会卡在这一步,或者编一个路径出来。
*
* @param {string[]} paths
* @param {string} name 正在查的收件人名,用于拼下一步的提示
* @returns {string}
*/
export function renderPathSuggestions(paths, name) {
const list = Array.isArray(paths) ? paths.filter(Boolean) : [];
if (list.length === 0) {
return [
`${name} 没有记录在案的工作目录。`,
'这不代表不能给它发信 —— path 位可以留空(人类用户就没有工作目录)。',
`直接用 suggest_address(name="${name}", path="") 查它的会话,或直接发给 ${name}`,
].join('\n');
}
return [
`${name} 用过的工作目录(按最近使用排序):`,
list.map(p => `- ${p}`).join('\n'),
'',
`下一步:用 suggest_address(name="${name}", path="<上面某一个>") 查该目录下可续谈的会话。`,
].join('\n');
}
/**
* 渲染会话候选(`kind: "session"`)。
*
* **`addresses` 与 `suggestions` 同序**,服务端保证。这里优先用 `addresses`
* 那是服务端拼好的完整地址,插件不必自己拼(自己拼过一次,拼错了)。
*
* `new` 永远在最后且带一句警告:它不是一条已存在的会话。排在前面会让模型
* 在想续谈时顺手开出一条新线索 —— 生产上已经发生过。
*
* @param {object} data `/agent/contacts/suggest` 的返回体
* @param {string} name
* @param {string} path
* @returns {string}
*/
export function renderSessionSuggestions(data, name, path) {
const aliases = Array.isArray(data?.suggestions) ? data.suggestions : [];
const addresses = Array.isArray(data?.addresses) ? data.addresses : [];
const candidates = Array.isArray(data?.candidates) ? data.candidates : [];
// 只有 new 一项 = 这个 name@path 下还没有任何可续谈的会话
const existing = aliases.filter(a => a !== 'new');
if (existing.length === 0) {
return [
`${name}${path ? '@' + path : ''} 下还没有可续谈的会话。`,
`要开一条新线索用 ${addressAt(addresses, aliases, 'new') || `${name}@${path}.new`}`,
'并在 send_mail 里传 session_alias 给它命名,之后就能按名字续谈。',
].join('\n');
}
const lines = [`${name}${path ? '@' + path : ''} 下可续谈的会话:`];
for (let i = 0; i < aliases.length; i++) {
const alias = aliases[i];
const addr = addresses[i] || '';
const c = candidates[i] || {};
if (alias === 'new') continue; // new 单独放最后
const bits = [];
if (c.title) bits.push(c.title);
if (typeof c.unread === 'number' && c.unread > 0) bits.push(`${c.unread} 封未读`);
if (c.source === 'platform') bits.push('平台侧会话');
lines.push(`- ${addr || alias}${bits.length ? ` ${bits.join('')}` : ''}`);
}
lines.push('');
lines.push('把上面某个地址原样填进 send_mail 的 to 即可投进那条会话。');
const newAddr = addressAt(addresses, aliases, 'new');
if (newAddr) {
lines.push(`若确实要开一条**新**线索(而不是接着上面某条谈)才用 ${newAddr}`);
}
return lines.join('\n');
}
/** 按别名在同序的 addresses 里取地址。 */
function addressAt(addresses, aliases, alias) {
const i = aliases.indexOf(alias);
return i >= 0 ? addresses[i] || '' : '';
}
/**
* 渲染会话参与方清单。
*
* 这是「发送给抄收方 / 转发方」缺的最后一块:知道有谁、**用什么地址找到他**、
* 以及谁还没开口。`mail_count` 为 0 的那个就是还没回应的人 —— 服务端只数
* 「作为发件人」的邮件,正是为了让这个判断成立。
*
* @param {object} data `/agent/sessions/{id}/participants` 的返回体
* @returns {string}
*/
export function renderParticipants(data) {
const parts = Array.isArray(data?.participants) ? data.participants : [];
if (parts.length === 0) return '该会话还没有参与方(可能是一条刚建立的空会话)。';
const alias = data?.session_alias || '';
const lines = [`会话 #${alias || '未命名'} 的参与方:`];
for (const p of parts) {
const tags = [];
if (p.is_self) tags.push('就是你');
if (Array.isArray(p.roles) && p.roles.length) {
tags.push(p.roles.map(roleLabel).join('/'));
}
if (p.mail_count === 0 && !p.is_self) tags.push('尚未回应');
const addr = p.address ? p.address : '(无可投递地址:该会话尚未命名)';
lines.push(`- ${p.name} ${addr}${tags.length ? ` [${tags.join('')}]` : ''}`);
}
lines.push('');
lines.push('要联系其中某一方,把它的地址原样填进 send_mail 的 to。');
return lines.join('\n');
}
/**
* 渲染联系人清单(本 Agent 参与过的全部会话)。
*
* 按未读优先、其次最近活跃排序:模型问「我还有什么没处理」时,
* 有未读的那些才是答案。
*
* @param {object} data `/agent/contacts` 的返回体
* @param {number} limit 最多列出多少条
* @returns {string}
*/
export function renderContacts(data, limit = 20) {
const list = Array.isArray(data?.contacts) ? data.contacts.slice() : [];
if (list.length === 0) return '还没有任何往来会话。';
list.sort((a, b) => {
const ua = a?.unread_count || 0;
const ub = b?.unread_count || 0;
if (ua !== ub) return ub - ua;
return String(b?.last_activity || '').localeCompare(String(a?.last_activity || ''));
});
const shown = list.slice(0, limit);
const lines = [`往来会话(共 ${list.length}${list.length > shown.length ? `,列出前 ${shown.length}` : ''}`];
for (const c of shown) {
const bits = [];
if (c.unread_count > 0) bits.push(`${c.unread_count} 封未读`);
if (c.subject) bits.push(c.subject);
if (c.max_rounds > 0) {
const left = Math.max(0, c.max_rounds - (c.used_rounds || 0));
bits.push(`${left}/${c.max_rounds} 个来回`);
}
const addr = c.address || '(未命名会话,只能用 reply_to 续谈)';
lines.push(`- ${addr}${bits.length ? ` ${bits.join('')}` : ''}`);
}
return lines.join('\n');
}
/** 角色的中文标签。模型读到「抄送方」比读到 cc 更容易判对分工。 */
function roleLabel(role) {
switch (role) {
case 'from': return '发件人';
case 'to': return '收件人';
case 'cc': return '抄送方';
default: return String(role);
}
}
/**
* 渲染对话树,回答「谁已经回了、谁还没回」。
*
* 缩进表示层级。**detached 必须标出来**:那表示父邮件不在本次结果里
* (无权查看或尚未加载),不标的话模型会以为这是一条独立线索。
*
* @param {object} data `/agent/mail/{id}/thread` 的返回体
* @param {string} [selfName] 自己的名字,用于标出哪几封是自己发的
* @returns {string}
*/
export function renderThread(data, selfName = '') {
const nodes = Array.isArray(data?.nodes) ? data.nodes : [];
if (nodes.length === 0) return '这条线索上没有可见的邮件。';
const lines = [`线索共 ${data?.total ?? nodes.length}${data?.hidden ? `(另有 ${data.hidden} 封无权查看)` : ''}`];
for (const n of nodes) {
const depth = typeof n?.depth === 'number' ? Math.max(0, n.depth) : 0;
const indent = ' '.repeat(Math.min(depth, 8));
const marks = [];
if (selfName && n?.from_name === selfName) marks.push('你发的');
if (n?.mail_id === data?.anchor_mail_id) marks.push('当前这封');
if (n?.detached) marks.push(n.parent_hidden ? '父邮件无权查看' : '父邮件尚未加载');
lines.push(
`${indent}- ${n?.from_name ?? '?'}${n?.to_name ?? '?'}: ${n?.subject ?? '(无主题)'}` +
` [${n?.mail_id ?? '?'}]${marks.length ? ` (${marks.join('')})` : ''}`
);
}
if (data?.has_more) {
lines.push('');
lines.push(`还有更多,用 offset=${data.next_offset} 继续取。`);
}
return lines.join('\n');
}

View File

@ -0,0 +1,144 @@
/**
* 收件箱渲染与已读策略 —— 所有平台插件共用。
*
* 提到 lib/ 是因为这几条规则每一条都对应过一次真实的错误行为,而它们与
* 平台 SDK 无关:无论 opencode 的 zod 工具还是 DSH 的 defineTool
* 渲染出的文本与标记已读的时机都该一致。新接一个平台时直接复用这里。
*/
import { roleOf, replyAddressFor, participantsOfMail } from './addressing.js';
/** 人类可读的字节数,用于附件清单展示。 */
export function formatSize(n) {
if (typeof n !== 'number' || !Number.isFinite(n)) return '?';
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
/**
* 把一封邮件渲染成模型可读的文本块。
*
* @param {any} m `/mail/inbox` 返回的一封邮件
* @param {number} bodyLimit 正文截断长度
* @param {string} [selfName] 自己的 Agent 名。给了就能判定「我是收件人还是抄送方」
* 并给出参与方地址;不给则退化成旧行为(兼容未传该参数的调用方)。
* @returns {string}
*/
export function renderMail(m, bodyLimit = 200, selfName = '') {
const alias = m?.session_alias || '';
const lines = [
`[${m?.status ?? 'unknown'}] ${m?.from_name ?? 'unknown'}: ${m?.subject ?? '(无主题)'}`,
`邮件 ID: ${m?.mail_id ?? 'unknown'}`,
`会话: #${alias || '未命名'}`,
];
// 收件人必须显示。不显示的后果:被抄送方既不知道主收件人是谁,
// 也无法向对方转达或汇报 —— 线上那封联调邮件要求「由收件人汇报」,
// 抄送方却看不到收件人叫什么。
if (m?.to_name) {
let toLine = `收件人: ${m.to_name}`;
if (m?.to_workspace) toLine += `@${m.to_workspace}`;
lines.push(toLine);
}
// 抄送要显示:一封邮件为什么同时到了几个人手上,只有抄送能解释。
// 不显示的话模型会以为这是私下发给它一个人的,回信时漏掉其他参与方。
if (Array.isArray(m?.cc_list) && m.cc_list.length > 0) {
lines.push('抄送: ' + m.cc_list.map(c => c?.raw || c?.name || '?').join('、'));
}
// 自己的身份。抄送方与主收件人的职责不同,不区分的话两方都会
// 以为自己是负责人,或者都以为自己只是旁观者。
if (selfName) {
const role = roleOf(m, selfName);
if (role === 'to') lines.push('你的身份: 收件人(主办)');
else if (role === 'cc') lines.push('你的身份: 抄送方(配合)');
}
// **必须给出 attachment_id**:只说「有附件」模型就无从下载。
if (Array.isArray(m?.attachments) && m.attachments.length > 0) {
lines.push(
'附件: ' +
m.attachments
.map(a => `${a?.filename ?? '?'}${formatSize(a?.size_bytes)}, id=${a?.attachment_id ?? '?'}`)
.join('、')
);
lines.push('下载附件请用 download_attachment 工具。');
}
// 列表接口只给 body_preview省带宽单封接口才有 body。两者都兜住。
const body = m?.body_preview || m?.body || '';
lines.push(`内容: ${String(body).slice(0, bodyLimit)}`);
// 可投递地址放在最后,紧贴正文 —— 模型读完内容紧接着就要决定发给谁。
//
// 这一段是「精准发信」的关键:之前模型只能从抄送行里拄一个
// `opencode@/home.new` 拄过去,而 `.new` 是一次性的,回过去只会再建一条
// 平行会话。这里给的地址全部已经把 session 位换成真实别名。
if (selfName && alias) {
const parts = participantsOfMail(m, selfName, alias);
const others = parts.filter(p => !p.is_self && p.address);
if (others.length > 0) {
lines.push(
'可投递地址: ' +
others.map(p => `${p.address}${roleLabel(p.role)}`).join('、')
);
lines.push(`直接回信给发件人用 ${replyAddressFor(m, alias)},或传 reply_to=${m?.mail_id ?? ''}`);
}
}
return lines.join('\n');
}
/** 角色的中文标签。模型读到「抄送方」比读到 cc 更容易判对分工。 */
function roleLabel(role) {
switch (role) {
case 'from': return '发件人';
case 'to': return '收件人';
case 'cc': return '抄送方';
default: return role;
}
}
/**
* 渲染整个收件箱。
* @param {any[]} mails
* @param {number} bodyLimit
* @param {string} [selfName] 自己的 Agent 名,透传给 renderMail
* @returns {string}
*/
export function renderInbox(mails, bodyLimit = 200, selfName = '') {
const list = Array.isArray(mails) ? mails : [];
if (list.length === 0) return '收件箱为空。';
return list.map(m => renderMail(m, bodyLimit, selfName)).join('\n\n');
}
/**
* 判断本次读取该标记哪些邮件为已读。
*
* 两条规则:
*
* 1. **只标本次真正列出来的**,不是全部未读。`limit` 之外的还没看过,
* 一并标掉等于让它们凭空消失。
* 2. **`status=all` 时不标**。那是「回顾历史」的读法,把历史邮件标成已读
* 会让下一轮真正的新邮件混在里面认不出来。
*
* 不标的后果是每次拉收件箱都重复捞同一批,处理过的和新来的混在一起,
* 模型分不清哪封该回。
*
* @param {string|undefined} status 本次查询用的过滤条件
* @param {any[]} mails 本次返回的邮件
* @returns {string[]} 待标记的 mail_id空数组表示不需要标记
*/
export function idsToMarkRead(status, mails) {
if (status === 'all') return [];
const list = Array.isArray(mails) ? mails : [];
return list.map(m => m?.mail_id).filter(id => typeof id === 'string' && id);
}
/** 收件箱默认过滤条件。默认只看未读 —— 默认 all 会让模型每轮重读旧邮件。 */
export const DEFAULT_INBOX_STATUS = 'unread';
/** 收件箱默认返回条数。 */
export const DEFAULT_INBOX_LIMIT = 5;

View File

@ -0,0 +1,169 @@
/**
* 平台模型目录的整理与降级选择 —— 所有平台插件共用。
*
* 两个职责:
* 1. 把各平台的 provider/model 结构整理成统一的上报格式(随心跳发给 Gateway
* 2. 按管理员划定的范围决定「先试哪个、再试哪个」
*
* 为什么随心跳上报而不是只在注册时报一次:模型清单会在运行中变(换 provider
* 配置、上游上下线、换 API key。只在注册时报的话目录会静静变陈而管理员
* 在配置页上看到的是上次重启时的快照 —— 选中一个平台已经调不到的模型,
* 失败要到真发邮件时才暴露。
*/
/** 单次上报的模型数上限。与服务端的 maxCatalogModels 一致。 */
export const MAX_CATALOG = 300;
/**
* 把 opencode 的 `/config/providers` 响应整理成上报格式。
*
* @param {any} config `client.config.providers()` 的结果
* @returns {object[]} `[{ provider, model, display_name }]`
*/
export function snapshotOpencodeModels(config) {
const providers = Array.isArray(config?.providers) ? config.providers : [];
const out = [];
for (const p of providers) {
const provider = typeof p?.id === 'string' ? p.id : '';
if (!provider) continue;
// models 是对象而非数组:键是 model id值是元数据
const models = p?.models && typeof p.models === 'object' ? p.models : {};
for (const [id, meta] of Object.entries(models)) {
if (!id) continue;
out.push({
provider,
model: id,
display_name: typeof meta?.name === 'string' ? meta.name : '',
});
}
}
return dedupeAndCap(out);
}
/**
* 把 DSH 的 provider/model 列表整理成上报格式。
*
* DSH 侧要先 `ctx.llm.listProviders()` 再对每个 provider `listModels()`
* 因此这里收的是已经拍平的结果。
*
* @param {any[]} entries `[{ provider, id, name }]`
* @returns {object[]}
*/
export function snapshotDshModels(entries) {
const list = Array.isArray(entries) ? entries : [];
const out = [];
for (const m of list) {
const provider = typeof m?.provider === 'string' ? m.provider : '';
const model = typeof m?.id === 'string' ? m.id : '';
if (!provider || !model) continue;
out.push({
provider,
model,
display_name: typeof m?.name === 'string' ? m.name : '',
});
}
return dedupeAndCap(out);
}
/**
* 把 pi 的模型列表整理成上报格式。
*
* pi 侧的取法是 `await modelRuntime.getAvailable()` —— **不是** `getModels()`。
* 两者差别很大:本机实测目录里有 1221 个模型,而带凭证、真能调起来的只有 1 个。
* 上报 `getModels()` 的结果会让管理员在配置页选中一个注定失败的路由,
* 而失败要到真发邮件时才暴露(模型目录上报的全部意义就是避免这件事)。
*
* pi 的 Model 对象上provider 在 `provider` 字段、模型 id 在 `id` 字段,
* 展示名在 `name`。形状与 DSH 侧一致,但语义来源不同,因此单独一个函数
* ——照抄 snapshotDshModels 会让「必须用 getAvailable」这条约束无处记录。
*
* @param {any[]} models `await modelRuntime.getAvailable()` 的结果
* @returns {object[]}
*/
export function snapshotPiModels(models) {
const list = Array.isArray(models) ? models : [];
const out = [];
for (const m of list) {
const provider = typeof m?.provider === 'string' ? m.provider : '';
const model = typeof m?.id === 'string' ? m.id : '';
if (!provider || !model) continue;
out.push({
provider,
model,
display_name: typeof m?.name === 'string' ? m.name : '',
});
}
return dedupeAndCap(out);
}
/**
* 决定这一轮按什么顺序尝试模型。
*
* 三种情形:
*
* 1. **管理员划定了范围** → 按 rank 顺序(服务端已排好),逐个降级
* 2. **没划定范围**`allowed` 为空)→ 返回 `[undefined]`
* 表示「用平台自己的默认模型试一次」。**不是**空数组:
* 空数组会让调用方一次都不试,等于让 Agent 彻底哑掉,
* 而「管理员没配」的正确含义是不限定。
* 3. **插件配了 `AGENTMAIL_REPLY_PROVIDER`/`MODEL`** → 那是部署方的显式指定,
* 优先于「平台默认」,但**不优先于管理员划定的范围**
* 范围是运行时可改的策略,环境变量是部署时的兜底。
*
* @param {readonly {provider: string, model: string}[]} allowed 管理员划定的范围(按 rank
* @param {{provider?: string, model?: string}|undefined} envDefault 环境变量指定的模型
* @returns {(({provider: string, model: string})|undefined)[]} 依次尝试的候选;
* `undefined` 表示这一次不指定模型、交给平台
*/
export function modelAttemptOrder(allowed, envDefault) {
const list = Array.isArray(allowed) ? allowed.filter(m => m?.provider && m?.model) : [];
if (list.length > 0) return list.map(m => ({ provider: m.provider, model: m.model }));
if (envDefault?.provider && envDefault?.model) {
return [{ provider: envDefault.provider, model: envDefault.model }];
}
return [undefined];
}
/**
* 把多次尝试的失败原因整理成一封邮件正文。
*
* 全部失败时必须发这封信:模型一次都没跑起来,会话里没有任何 assistant 消息,
* 自动转发因此什么也不会发 —— 发件人只会看到邮件发出去后再无音讯。
*
* @param {{provider?: string, model?: string, error: string}[]} failures 每次尝试的失败
* @param {string} subject 原邮件主题
* @returns {string} Markdown 正文
*/
export function renderFailureReport(failures, subject) {
const list = Array.isArray(failures) ? failures : [];
const lines = [
`本次未能处理「${subject || '(无主题)'}」:划定范围内的模型全部调用失败。`,
'',
`已尝试 ${list.length} 个:`,
'',
];
list.forEach((f, i) => {
const route = f?.provider && f?.model ? `${f.provider}/${f.model}` : '(平台默认模型)';
lines.push(`${i + 1}. **${route}**`);
// 缩进四格让报错原文成为代码块,避免其中的 Markdown 字符影响排版
lines.push(` ${String(f?.error ?? '未知错误').replace(/\n/g, '\n ')}`);
});
lines.push('');
lines.push('可能的原因模型已下线、API key 失效、上游限流,或该 provider 未在平台侧配置。');
lines.push('调整可用模型范围:配置页 → Agent 模型范围。');
return lines.join('\n');
}
/** 去重provider/model 组合)并截断。 */
function dedupeAndCap(list) {
const seen = new Set();
const out = [];
for (const m of list) {
const key = `${m.provider}/${m.model}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(m);
if (out.length >= MAX_CATALOG) break;
}
return out;
}

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,234 @@
/**
* 平台会话快照:把 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));
}
/**
* 把 pi 的 `SessionManager.list()/listAll()` 结果整理成上报格式。
*
* pi 的会话名字来自会话文件里最后一条 `session_info` 条目:
* - pi-web 在一条会话的首次 prompt 时用模型生成一个 2-6 词的标题
* - TUI 的 `/name`、启动参数 `--name`、`/resume` 里的改名也写同一处
* - **pi 内核SDK自己不生成**:桥用 createAgentSession 起的会话没有名字,
* 要由桥按「Gateway 定稿的别名」回写(见 index 的 syncNaming
*
* 与另两个平台的差异pi 的 SessionInfo 里**没有 subagent 标记**。
* pi-subagents 把子会话写在自定义 sessionDirrun 根目录)下,默认会话目录
* 列不到它们,因此这里不需要 S-2 那样的显式过滤。
*
* @param {any[]} entries SessionInfo 列表 `[{ id, cwd, name, modified }]`
* @param {(id: string) => boolean} isMailDriven
* @returns {object[]}
*/
export function snapshotPiSessions(entries, isMailDriven = () => false) {
const list = Array.isArray(entries) ? entries : [];
const out = [];
for (const e of list) {
const id = typeof e?.id === 'string' ? e.id : '';
if (!id) continue;
const name = typeof e?.name === 'string' ? e.name : '';
// 没有名字的会话不报S-1pi 的列表在无名时显示首条消息,
// 而首条消息对邮件驱动的会话就是桥自己拼的提示词 —— 拿它当别名毫无区分度。
if (!name) continue;
// 模型把思维链当标题写进来的那些不报(见 isUnusableName
if (isUnusableName(name)) continue;
const slug = slugFromTitle(name);
if (!slug) continue;
out.push({
platform_id: id,
// 老会话的 cwd 是空串pi 的 SessionInfo 注释里写明了),照实上报,
// 服务端按空 workspace 处理,不要拿桥自己的 cwd 冒充。
workspace: typeof e?.cwd === 'string' ? e.cwd : '',
slug,
title: name,
mail_driven: Boolean(isMailDriven(id)),
updated_at: toISO(e?.modified ?? e?.created),
});
}
return dedupeBySlug(sortAndCap(out));
}
/**
* 判断一个平台侧名字是否不适合当别名。
*
* 这条判废是 pi 特有的pi-web 的标题生成器(`sessionNameGenerator`)只做了
* 「取首行 + 去引号 + 截 60 字符」,没有防思维链泄漏。本机 81 条会话里实测捞到:
*
* "The user is asking me to generate a title for a coding-agent"
* "我们只需要生成标题不包含其他内容。标题应反映请求内容测试opencode的源。简短…"
*
* 这类字符串派生出的别名又长又没有指代作用,填进三维地址里更是灾难。
* 判废后调用方回退到「不上报」或「用邮件主题派生」,都比它强。
*
* 宁可漏判也不误判:错杀一个好名字会让那条会话失去可寻址的别名,
* 而漏掉一个坏名字只是别名难看。
*
* @param {string} name
* @returns {boolean}
*/
export function isUnusableName(name) {
const s = String(name ?? '').trim();
if (!s) return true;
// 自指标题生成任务 = 模型把系统提示词复述了出来
if (/生成标题|标题应|拟一个标题|generate a (short |concise )?title|session title|as a title/i.test(s)) {
return true;
}
// 以第三人称叙述用户意图开头 = 思维链的典型开场
if (/^(the user\b|用户(想|要|在|希望)|我们只需要|我需要先|首先(|,))/i.test(s)) return true;
// 又长又分句 = 一段话而不是一个标题pi-web 截断上限是 60
if (s.length >= 48 && /[。;;]|\.\s/.test(s)) return true;
return false;
}
/** 判断一条会话是否为 subagent 子会话。两个字段任一成立即算。 */
function isSubagent(e) {
if (e?.origin === 'subagent') return true;
const depth = e?.delegationDepth;
return typeof depth === 'number' && depth > 0;
}
/** 同 slug 只保留第一条(调用前已按最近活跃排序)。 */
function dedupeBySlug(list) {
const seen = new Set();
const out = [];
for (const item of list) {
if (seen.has(item.slug)) continue;
seen.add(item.slug);
out.push(item);
}
return out;
}
/**
* 把模型生成的会话标题转成可寻址的 slug。
*
* 保留中文而不转拼音:标题「缓存层选型评估」转成 huancunceng-xuanxing 之后
* 既不好读也不好打,而 AgentMail 的别名校验本来就允许中文(三维地址按最后一个
* `.` 切分,中文不影响解析)。
*
* 处理:空白 → `-`,去掉会干扰寻址的字符(`.` 是 session 位的分隔符,
* `@` 是 path 位的分隔符,`/` 会被当成路径),压缩连续 `-`,截断到 48 字符。
*
* @param {string} title
* @returns {string} slug无法派生时为空串
*/
export function slugFromTitle(title) {
const raw = String(title ?? '').trim();
if (!raw) return '';
const slug = raw
.replace(/[\s\u3000]+/g, '-')
// 寻址相关的分隔符必须去掉,否则别名本身会被解析器切开
.replace(/[.@/\\:,;'"`?#[\]{}()<>|*!$&=+%^~]/g, '')
.replace(/-{2,}/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48)
// 截断可能又切出尾部的 -
.replace(/-+$/g, '');
// 纯符号标题清干净后会剩空串
return slug;
}
/** 毫秒时间戳、ISO 串或 Date → ISO 串;无法解析时返回 undefined。 */
function toISO(v) {
if (typeof v === 'number' && Number.isFinite(v)) {
return new Date(v).toISOString();
}
// pi 的 SessionInfo 给的是 Date 实例created/modified不是时间戳。
// 少了这一支会让整份快照的 updated_at 全是 undefined于是服务端只能按
// 上报时间排序 —— 补全列表里「最近在谈的那条」不再排在前面。
if (v instanceof Date) {
return Number.isNaN(v.getTime()) ? undefined : v.toISOString();
}
if (typeof v === 'string' && v) {
const d = new Date(v);
if (!Number.isNaN(d.getTime())) return d.toISOString();
}
return undefined;
}
/** 按最近活跃降序排列并截断。上千条会话对补全列表毫无用处。 */
function sortAndCap(list) {
return list
.sort((a, b) => String(b.updated_at ?? '').localeCompare(String(a.updated_at ?? '')))
.slice(0, MAX_REPORTED);
}

View File

@ -0,0 +1,77 @@
/**
* 邮件寻址里的工作目录(三维地址 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';
/**
* 校验寻址里的工作目录,不可用时返回调用方给的兜底。
*
* 决策顺序:
* 1. path 位是一个已存在的目录 → 直接用它(同 path 的多封邮件天然同组)
* 2. path 位非空但目录不存在 → **不创建**,返回兜底
* 3. path 位为空(地址写成 `dsh` 而不带 `@/path`)→ 兜底
*
* 为什么不给不存在的 path 建目录:那等于让一个笔误(`/home/porgram/x`
* 在磁盘上落下一个真目录,而 Agent 会在里面一无所获地干活 ——
* 用户看到会话建起来了却什么都做不了,比明确落到兜底目录更难排查。
*
* 为什么拒绝相对路径cwd 的相对基准是 harness 进程的启动目录,
* 那是个与邮件语义无关的量systemd 下通常是 `/`)。
*
* 兜底由调用方给因为各平台的兜底不同opencode 有插件启动时的 directory
* 可用DSH 没有、只能落到 `~/.dsh/mail-sessions/<会话>`(见 mailSessionFallback
*
* @param {string} workspace 事件里的 to_workspace
* @param {string} fallback 不可用时的兜底目录(可为空串 = 交给平台自己决定)
* @returns {{cwd: string, grouped: boolean}} grouped 为真表示落在了寻址指定的目录里
*/
export function resolveWorkspaceCwd(workspace, fallback) {
const raw = typeof workspace === 'string' ? workspace.trim() : '';
const fb = typeof fallback === 'string' ? fallback : '';
if (!raw || !isAbsolute(raw)) return { cwd: fb, grouped: false };
const abs = resolve(raw);
try {
if (existsSync(abs) && statSync(abs).isDirectory()) {
return { cwd: abs, grouped: true };
}
} catch {
// 权限不足等:当作不可用
}
return { cwd: fb, grouped: false };
}
/**
* 没有天然兜底的平台DSH用这个`~/.dsh/mail-sessions/<会话 id>`。
* @param {string} sessionKey 会话标识
* @returns {string}
*/
export function mailSessionFallback(sessionKey) {
return join(homedir(), '.dsh', 'mail-sessions', String(sessionKey || 'default'));
}
/**
* 确保兜底目录存在。寻址指定的目录本来就存在(否则不会被选中),
* 只有兜底目录需要现建。
* @param {string} cwd resolveWorkspaceCwd 的结果
* @param {boolean} grouped 是否落在寻址指定的目录里
*/
export function ensureCwd(cwd, grouped) {
if (grouped || !cwd) return;
try {
mkdirSync(cwd, { recursive: true });
} catch {
// 建不出来就让 harness 自己报错,这里不该吞掉真实原因
}
}

View File

@ -0,0 +1,17 @@
{
"name": "pi-mail-bridge",
"version": "0.1.0",
"description": "pi (@earendil-works/pi-coding-agent) 桥:邮件驱动多智能体协作平台接入",
"type": "module",
"main": "src/index.mjs",
"bin": {
"pi-mail-bridge": "src/index.mjs"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": ">=0.84.0"
},
"scripts": {
"start": "node src/index.mjs",
"test": "node --test 'test/*.test.mjs'"
}
}

View File

@ -0,0 +1,235 @@
/**
* AgentMail Gateway 客户端 —— HTTP + SSE。
*
* 与另两个插件同构(同样的认证头、同样的手写 SSE 解析),区别只在这里是
* 独立守护进程,所以密钥解析与 Last-Event-ID 的状态都归它自己管。
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { randomBytes } from 'node:crypto';
import { homedir } from 'node:os';
import { join } from 'node:path';
const CONFIG_DIR = process.env.AGENTMAIL_CONFIG_DIR || join(homedir(), '.agentmail');
const KEY_FILE = join(CONFIG_DIR, 'agent.key');
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
/** 读取本地密钥文件;不存在或损坏时返回 null。 */
export function readLocalKey() {
try {
if (!existsSync(KEY_FILE)) return null;
const raw = JSON.parse(readFileSync(KEY_FILE, 'utf8'));
return typeof raw?.key_token === 'string' && raw.key_token ? raw.key_token : null;
} catch {
return null;
}
}
/**
* 首次安装时本地生成密钥并落盘0600**并把全文打印到日志**B-1.1)。
*
* 打印是必须的:密钥要管理员在后台登记之后才能接入,不打印就没人知道登记什么。
* 走 console.error 而不是任何结构化日志 —— 它一定进 journalctl契约 9.8)。
*/
export function generateLocalKey(log = console.error) {
const token = randomBytes(32).toString('hex');
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
writeFileSync(
KEY_FILE,
JSON.stringify({ key_token: token, created_at: new Date().toISOString() }, null, 2),
{ mode: 0o600 },
);
// 调用方传进来的 log 已经带 [pi-mail-bridge] 前缀,这里不再自己加
log(`已在 ${KEY_FILE} 生成本地密钥。`);
log(`该密钥需管理员在 AgentMail 后台登记后才能接入:`);
log(` ${token}`);
return token;
}
/** 把 gateway 地址与身份记到 config.json便于换机时人工核对。 */
export function saveConfig(extra) {
try {
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
let cur = {};
if (existsSync(CONFIG_FILE)) {
try { cur = JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); } catch { /* 损坏就重写 */ }
}
writeFileSync(CONFIG_FILE, JSON.stringify({ ...cur, ...extra }, null, 2), { mode: 0o600 });
} catch (e) {
console.error('[pi-mail-bridge] 写 config.json 失败:', e?.message || e);
}
}
export class GatewayClient {
/**
* @param {{url: string, agentName: string, agentKey: string, agentSecret: string}} opts
*/
constructor({ url, agentName, agentKey, agentSecret }) {
this.baseURL = String(url || 'http://127.0.0.1:8180').replace(/\/+$/, '');
this.agentName = agentName;
this.agentKey = agentKey || '';
this.agentSecret = agentSecret || '';
this.sseAbort = null;
// SSE 重连时带上,首次连接**不带**B-1.4 / N-11
// 带上会收到一批已处理过的旧事件,插件重启一次就把历史邮件重投一遍。
this.lastEventID = '';
}
/** 认证头:有密钥走 Bearer否则退回 name/secret。 */
authHeaders() {
if (this.agentKey) {
return { Authorization: `Bearer ${this.agentKey}`, 'X-Agent-Name': this.agentName };
}
return { 'X-Agent-Name': this.agentName, 'X-Agent-Secret': this.agentSecret };
}
async get(path) {
const res = await fetch(`${this.baseURL}/api/v1${path}`, { headers: this.authHeaders() });
if (!res.ok) throw new Error(`GET ${path} 失败: HTTP ${res.status}`);
return res.json();
}
async post(path, body) {
const res = await fetch(`${this.baseURL}/api/v1${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error(data?.error || `POST ${path} 失败: HTTP ${res.status}`);
err.status = res.status;
throw err;
}
return data;
}
/** 注册。workspaces 传 []B-1.2)—— 工作目录由每封邮件的 to_workspace 决定。 */
async register() {
return this.post('/agent/register', {
name: this.agentName,
secret: this.agentSecret || '',
workspaces: [],
platform: 'pi',
});
}
/**
* 上传附件。
*
* 必须走 multipart 的 `file` 字段:服务端是 `r.FormFile("file")`
* 且**不认 `X-Filename` 头**grep 过 handler/attachments.go没有这个分支
* 直接 POST 二进制体会得到 400「缺少 file 字段」。
*
* 不手动设 Content-Type让 undici 按 FormData 自己生成 boundary。
*/
async uploadFile(buf, filename) {
const form = new FormData();
form.append('file', new Blob([buf]), filename);
const res = await fetch(`${this.baseURL}/api/v1/attachments`, {
method: 'POST',
headers: this.authHeaders(),
body: form,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error || `上传失败: HTTP ${res.status}`);
return data.attachment;
}
async downloadFile(attachmentID) {
const res = await fetch(`${this.baseURL}/api/v1/attachments/${attachmentID}`, {
headers: this.authHeaders(),
});
if (!res.ok) throw new Error(`下载失败: HTTP ${res.status}`);
return Buffer.from(await res.arrayBuffer());
}
/**
* 建立 SSE 长连并自动重连。
*
* 手写解析而不用 EventSourceNode 内建的那个不支持自定义请求头,
* 而认证头是必须的。协议这一小块(`id:` / `event:` / `data:` + 空行分隔)
* 比引一个依赖划算。
*
* 断线重连带 `Last-Event-ID`D-7.2):服务端有 per-agent 环形缓冲,
* 能把断连期间的事件回放出来 —— 否则那段时间的邮件只能等下次重启补拉。
*/
startSSE(onEvent, log = console.error) {
this.sseAbort?.abort();
this.sseAbort = new AbortController();
const signal = this.sseAbort.signal;
const reconnect = (delay) => {
if (signal.aborted) return;
setTimeout(() => this.#connect(onEvent, reconnect, log), delay);
};
this.#connect(onEvent, reconnect, log);
}
#connect(onEvent, reconnect, log) {
const signal = this.sseAbort?.signal;
if (!signal || signal.aborted) return;
const headers = { ...this.authHeaders(), Accept: 'text/event-stream' };
// 重连时带上断点D-7.2)。**首次连接必须不带**N-11那会让服务端
// 把缓冲区里的旧事件全回放一遍,插件重启后重复处理一批已处理的邮件。
// 只有 lastEventID 非空(= 已经收过事件)时才是重连。
if (this.lastEventID) {
headers['Last-Event-ID'] = this.lastEventID;
log(`SSE 重连,从事件 ${this.lastEventID} 之后续传`);
}
fetch(`${this.baseURL}/api/v1/events/stream`, { headers, signal })
.then((res) => {
if (!res.ok || !res.body) {
log(`SSE 建连失败: HTTP ${res.status}`);
return reconnect(5000);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
let id = '';
let evt = '';
let data = '';
const read = () => {
reader.read().then(({ done, value }) => {
if (done) return reconnect(3000);
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('id: ')) id = line.slice(4).trim();
else if (line.startsWith('event: ')) evt = line.slice(7).trim();
else if (line.startsWith('data: ')) data = line.slice(6);
else if (line === '' && evt) {
// 事件 id 要在**分发之前**记下:分发里抛异常也不该让它丢,
// 否则重连会从更早的位置回放,已处理的邮件再来一遍。
if (id) this.lastEventID = id;
try { onEvent(evt, JSON.parse(data)); } catch (e) {
log(`SSE 事件处理失败: ${e?.message || e}`);
}
id = ''; evt = ''; data = '';
}
}
read();
}).catch((e) => {
if (signal.aborted) return;
log(`SSE 读取中断: ${e?.message || e}`);
reconnect(5000);
});
};
read();
})
.catch((e) => {
if (signal.aborted) return;
log(`SSE 连接错误: ${e?.message || e}`);
reconnect(5000);
});
}
stopSSE() {
this.sseAbort?.abort();
this.sseAbort = null;
}
}

View File

@ -0,0 +1,693 @@
#!/usr/bin/env node
/**
* AgentMail ↔ pi 桥pi-mail-bridge
*
* 形态是**常驻守护进程**,不是 pi 扩展。原因见 src/session-pool.mjs 顶部:
* 扩展被加载进一条已存在的会话cwd 由启动 pi 的人决定;而 B-3.1 要求每封邮件的
* to_workspace 成为会话 cwd。桥用 SDK 的 createAgentSession 按邮件起会话,
* 一个进程里并存多条不同 cwd 的会话(实测可行)。
*
* 契约实现对照docs/PLUGIN-CONTRACT.md
* B-1 启动 → main()
* B-2 心跳 → beat()30 秒
* B-3 new_mail → deliverMail()
* B-4 决策 → handlePermissionDecision()
* B-5 转发 → relaySummary(),挂在 agent_end 上
* B-6 失败回信 → deliverMail() 末尾的 renderFailureReport
* B-7 补拉 → catchUp()
* B-8 权限 → permissionExtension() 的 tool_call 钩子
* B-9 关停 → shutdown()
*/
import { mkdirSync, openSync, closeSync, unlinkSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { ModelRuntime } from '@earendil-works/pi-coding-agent';
import { GatewayClient, readLocalKey, generateLocalKey, saveConfig } from './gateway.mjs';
import { createMailTools } from './tools.mjs';
import { openSession, runTurn } from './session-pool.mjs';
import { buildMailPrompt, lastAssistantText, replySubject, relayKeyFor, describeError } from './turn.mjs';
import { planNamingSync, planWriteBack } from './naming.mjs';
import { resolveWorkspaceCwd, ensureCwd } from '../lib/workspace.js';
import { modelAttemptOrder, renderFailureReport, snapshotPiModels } from '../lib/model-scope.js';
import { snapshotPiSessions } from '../lib/session-snapshot.js';
import { selectCatchup } from '../lib/catchup.js';
import { explicitSends, shouldSkipAutoRelay } from '../lib/relay-dedup.js';
// ─── 配置 ───
const GATEWAY_URL = process.env.AGENTMAIL_GATEWAY_URL || 'http://127.0.0.1:8180';
const AGENT_NAME = process.env.AGENTMAIL_AGENT_NAME || 'pi';
const AGENT_SECRET = process.env.AGENTMAIL_AGENT_SECRET || '';
const REPLY_PROVIDER = process.env.AGENTMAIL_REPLY_PROVIDER || '';
const REPLY_MODEL = process.env.AGENTMAIL_REPLY_MODEL || '';
const TURN_TIMEOUT_MS = Number(process.env.AGENTMAIL_TURN_TIMEOUT_MS || 60_000);
const LOCK_FILE = join(process.env.AGENTMAIL_CONFIG_DIR || join(homedir(), '.agentmail'), 'pi-bridge.lock');
/** 日志一律 console.error它一定进 journalctl契约 9.8)。 */
const log = (...args) => console.error('[pi-mail-bridge]', ...args);
// ─── 进程内状态 ───
//
// 全部只在内存,重启即丢 —— 这是契约第六节列明的已知取舍。
// 要持久化的话该落在 pi 的会话元数据里,而不是桥自己的文件。
const sessions = new Map(); // agentmail session_id -> { session, sessionManager, cwd }
const reverseMap = new Map(); // pi session id -> agentmail session_id
const mailDriven = new Set(); // pi session id
const mailContexts = new Map(); // agentmail session_id -> { replyTo, subject, mailID }
const relayedSummaries = new Map(); // pi session id -> 已转发过的 relay_key
const syncedNames = new Map(); // pi session id -> 上次提交给 Gateway 的名字
const pendingPermissions = new Map(); // relay_key -> { resolve, piSessionId }
const deliveredMails = new Set(); // 已投过的 mail_idSSE 与补拉共用B-7.3
let allowedModels = [];
let modelRuntime = null;
let client = null;
let heartbeatTimer = null;
let shuttingDown = false;
// ─── 单实例锁 ───
//
// 两个桥同时跑的后果不是「慢一点」而是错的:两条 SSE 各收到同一封邮件,
// 各起一条 pi 会话,发件人收到两封回信;而 deliveredMails 在各自内存里,去重不了。
function acquireLock() {
mkdirSync(join(LOCK_FILE, '..'), { recursive: true, mode: 0o700 });
try {
// O_EXCL 原子创建。存在则说明有别的实例(或上次崩溃留下的陈锁)。
const fd = openSync(LOCK_FILE, 'wx');
writeFileSync(fd, String(process.pid));
closeSync(fd);
return true;
} catch (e) {
if (e?.code !== 'EEXIST') throw e;
}
// 陈锁判定:文件里的 pid 还活着吗
let pid = 0;
try { pid = Number(readFileSync(LOCK_FILE, 'utf8').trim()); } catch { /* 读不到当陈锁 */ }
if (pid > 0) {
try {
// signal 0 只探测存在性,不真的发信号
process.kill(pid, 0);
log(`已有实例在运行pid ${pid}),本进程退出。`);
return false;
} catch {
// ESRCH进程没了是陈锁
}
}
log(`清理陈锁 ${LOCK_FILE}(原 pid ${pid || '未知'} 已不存在)`);
try { unlinkSync(LOCK_FILE); } catch { /* 竞态下别人清掉了也行 */ }
return acquireLock();
}
function releaseLock() {
try {
// 只删自己的锁pid 不符说明这把锁已被别的实例接管
if (Number(readFileSync(LOCK_FILE, 'utf8').trim()) === process.pid) unlinkSync(LOCK_FILE);
} catch { /* 已经没了 */ }
}
// ─── 权限钩子B-8───
/**
* 内联 pi 扩展:把 pi 拦下的危险工具调用转成一封邮件问人。
*
* 这是 `I-1` 最直接的体现 —— 被平台真正拦下的那一次才是事实,
* 不依赖模型「记得」调 request_permission它会忘也会在不需要时乱调
*
* pi 的 `tool_call` 钩子**可以 await**C-9 实测成立:处理器里 await 300ms
* 再返回 {block:true}pi 会等),所以这里能真的等人做决定,
* 不必走「先拒一次再重试」的退化路径。
*
* @param {string} piSessionIdRef 用一个 getter 拿会话 id扩展工厂在
* createAgentSession **内部**被调用,那时 session 对象还没返回给桥。
*/
function permissionExtension(getMailContext) {
// pi 默认放行内建工具;桥只拦真正有副作用的那几个。
// read/grep/ls 之类不拦:每一步都问人会让 Agent 什么也做不成,
// 而人也会很快开始无脑点同意(那比不问更危险)。
const GUARDED = new Set(['bash', 'write', 'edit']);
return (pi) => {
pi.on('tool_call', async (event, ctx) => {
if (!GUARDED.has(event.toolName)) return;
const piSessionId = ctx?.sessionManager?.getSessionId?.() || '';
const mailSessionId = reverseMap.get(piSessionId);
// 不是邮件驱动的会话 → 让位给 pi 自己的本地 UIB-8.2)。
// 占着钩子不放会让人在 TUI 里干活时每一步都卡住等邮件。
if (!mailSessionId) return;
// relay_key 用 pi 给的 toolCallIdB-8.1):服务端会随决策事件回传它,
// 桥重启丢了 pendingPermissions 也能对上B-4.2)。自造随机 id 做不到。
const relayKey = `${piSessionId}:${event.toolCallId}`;
const ctxInfo = getMailContext(mailSessionId);
try {
await client.post('/permission/request', {
question: `是否允许执行 ${event.toolName}`,
options: ['同意', '一直同意', '拒绝'],
context: describeToolCall(event),
session_id: mailSessionId,
to: ctxInfo?.replyTo || '',
relay_key: relayKey,
});
} catch (e) {
// 转发失败 → 让位给 pi 本地 UIB-8.2)。返回 undefined 表示
// 「这个钩子不表态」pi 会走它自己的批准流程。
log(`权限转发失败,让位给本地决策: ${describeError(e)}`);
return;
}
log(`权限询问已发出(${event.toolName}key=${relayKey}),等待决策…`);
const decision = await new Promise((resolve) => {
pendingPermissions.set(relayKey, { resolve, piSessionId });
});
// fail closedB-9.2 / N-9只有明确的同意才放行。
// 关停时 shutdown() 会用 'shutdown' 唤醒所有等待者,落到这里的 else。
if (/^(同意|一直同意|allow|approve|always|yes)/i.test(decision)) {
log(`权限 ${relayKey} 获批(${decision}),放行 ${event.toolName}`);
return;
}
return { block: true, reason: `用户${decision === 'shutdown' ? '未及决策(桥已关停)' : `拒绝了这次 ${event.toolName} 调用`}` };
});
};
}
/** 把一次工具调用摘要成人能判断的文本B-8.4)。 */
function describeToolCall(event) {
const input = event?.input ?? {};
if (event.toolName === 'bash') {
return `命令:\n${String(input.command ?? '').slice(0, 800)}`;
}
if (event.toolName === 'write' || event.toolName === 'edit') {
return `文件:${input.file_path ?? input.path ?? '(未给出)'}`;
}
return JSON.stringify(input).slice(0, 800);
}
// ─── 会话解析B-3───
/**
* 没有可用 `to_workspace` 时的兜底目录。
*
* 与 DSH 的 `mailSessionFallback` 同构,但目录名是 `.pi`:那个函数在
* lib/ 下(三平台逐字节相同),写死了 `.dsh`,不能为 pi 改。
* 让 pi 的会话落进 `~/.dsh/` 会让人以为是 DSH 在干活。
*/
function piMailFallback(sessionKey) {
return join(homedir(), '.pi', 'mail-sessions', String(sessionKey || 'default'));
}
/**
* 找到(或建立)这封邮件该落进的 pi 会话。
*
* Gateway 已经按三维地址的 session 位做完了「复用默认 / 新建 / 具名必须存在」
* 的判定,推来的 session_id 就是判定结果 —— 桥只负责忠实映射,
* 不自己决定开不开新会话N-8404 后自动改用 .new 是禁止的)。
*/
async function resolveSession(data, mailTools) {
const mailSessionID = data.session_id;
const bound = mailSessionID ? sessions.get(mailSessionID) : undefined;
if (bound) return { ...bound, reused: true };
// cwd 取寻址里的 path 位B-3.1)。校验走共用模块:目录不存在时**不创建**
// N-2笔误会在磁盘上落下真目录而 Agent 在里面一无所获拒绝相对路径N-3
//
// 兜底用 `~/.pi/mail-sessions/<会话>` 而不是共用模块里的 mailSessionFallback ——
// 后者写死了 `.dsh` 目录名(那是 DSH 的家pi 的会话落进去会让人以为
// DSH 在干活。lib/ 里的函数三平台逐字节相同,不能为 pi 改它。
const { cwd, grouped } = resolveWorkspaceCwd(data.to_workspace, piMailFallback(mailSessionID));
if (!grouped && data.to_workspace) {
log(`工作目录 ${data.to_workspace} 不可用,回退到 ${cwd}`);
}
ensureCwd(cwd, grouped);
const opened = await openSession({
cwd,
modelRuntime,
customTools: mailTools,
extension: permissionExtension((id) => mailContexts.get(id)),
});
for (const d of opened.diagnostics) {
log(`扩展诊断: ${d?.message ?? JSON.stringify(d)}`);
}
const piSessionId = opened.session.sessionId;
const entry = { session: opened.session, sessionManager: opened.sessionManager, cwd };
if (mailSessionID) {
sessions.set(mailSessionID, entry);
reverseMap.set(piSessionId, mailSessionID);
mailDriven.add(piSessionId);
}
// 一轮结束就转发总结B-5。挂 agent_end 而不是 message_end
// 后者在流式生成中反复触发,转出去的是半截话。
// subscribe 收的是一个普通函数AgentSessionEventListener不是 {onEvent}。
opened.session.subscribe((event) => {
if (event?.type === 'agent_end') {
// willRetry 为真表示 pi 自己要重试auto_retry这一轮还没定论 —— 不转。
if (event.willRetry) return;
relaySummary(piSessionId).catch((e) => log(`自动转发失败: ${describeError(e)}`));
}
// pi 侧改名pi-web 生成标题、人在 TUI 里 /name→ 同步给 Gateway
if (event?.type === 'session_info_changed') {
syncNaming(piSessionId, event.name).catch((e) => log(`命名同步失败: ${describeError(e)}`));
}
});
log(`新建 pi 会话 ${piSessionId}cwd=${cwd}`);
return { ...entry, reused: false };
}
// ─── 命名一致C-11 / W-7───
/**
* pi 的名字 → Gateway → 定稿别名回写进 pi。
*
* 完整推理见 src/naming.mjs 顶部。这里只是把那套决策接上 I/O。
*/
async function syncNaming(piSessionId, platformName) {
const mailSessionID = reverseMap.get(piSessionId);
if (!mailSessionID) return; // 不是邮件驱动的会话,不碰
const plan = planNamingSync({
platformName,
mailSubject: mailContexts.get(mailSessionID)?.subject,
lastSynced: syncedNames.get(piSessionId),
});
if (plan.skip) return;
// 先记下指纹再发请求:响应回来时 setSessionName 会再次触发
// session_info_changed这一步是防自激循环的关键。
syncedNames.set(piSessionId, plan.signature);
const res = await client.post(`/sessions/${mailSessionID}/sync`, {
alias: plan.alias,
title: plan.title,
});
const entry = sessions.get(mailSessionID);
const back = planWriteBack({
finalAlias: res?.alias,
currentPiName: entry?.session?.sessionName,
});
log(`命名同步 ${piSessionId}: alias=${res?.alias || '(未变)'} 来源=${plan.source}`);
if (back.write && entry?.session) {
// 顺序要紧:先更新指纹,再改名。
//
// setSessionName **同步**触发 session_info_changed实测于是本函数会在
// 这一行里被重入。指纹在改名之后才更新的话,重入那次看到的还是旧指纹,
// 于是又打一次 sync —— 每条会话两次请求,内容完全相同。
//
// 记的是「把定稿别名当作平台名字」会算出的指纹:重入那次的 platformName
// 正是 back.name来源判定成 platform算出来的就是这个值。
syncedNames.set(piSessionId, `platform:${back.name}|${back.name}`);
// 只用 setSessionName走 pi 自己的写入路径)。绝不自己拼路径写会话文件:
// 首条 assistant 消息落盘前文件还不存在pi 首次落盘用 openSync(file,"wx")
// 抢先创建会让它抛 EEXIST实测
entry.session.setSessionName(back.name);
log(`别名回写 pi${back.name}${back.reason}`);
}
}
// ─── 自动转发B-5───
async function relaySummary(piSessionId) {
const mailSessionID = reverseMap.get(piSessionId);
if (!mailSessionID) return;
// 只对邮件驱动的会话转发B-5.5):人在 pi 里正常干活时不该往邮箱灌总结
if (!mailDriven.has(piSessionId)) return;
const entry = sessions.get(mailSessionID);
if (!entry) return;
// 一轮结束是命名的自然时机C-11 / D-5
//
// 这一步不能只挂在 session_info_changed 上:桥用 SDK 起的会话**永远不会**
// 触发那个事件 —— pi 的标题生成器在 pi-web 里不在内核里SDK 路径上没有它。
// 只等事件的话别名永远是空的,于是 `name@path.<别名>` 续谈无从下手
// 实测过第一封邮件跑通了sessions.session_alias 仍是空串)。
//
// 放在转发**之前**:回信里会带上会话别名,收件人看到的第一封回信就能用它续谈。
await syncNaming(piSessionId, entry.session.sessionName)
.catch((e) => log(`命名同步失败: ${describeError(e)}`));
// 只取 type==='text' 的块B-5.1 / N-6thinking 是思考过程,不是结论
const text = lastAssistantText(entry.session.messages);
if (!text) return; // 空文本不发空邮件B-5.4
const ctx = mailContexts.get(mailSessionID);
if (!ctx?.replyTo) return; // 不知道回给谁
// 幂等键用 pi 的会话 id + 会话树叶子 id两者都落盘重启重放也是同一个键。
const relayKey = relayKeyFor(piSessionId, entry.sessionManager.getLeafId?.());
if (relayedSummaries.get(piSessionId) === relayKey) return;
// 模型这一轮已亲手回过这条线索 → 让位B-5.3)。
// 否则收件箱里是两封说同一件事的邮件(生产实测过)。
if (shouldSkipAutoRelay(explicitSends.get(piSessionId), ctx.replyTo, ctx.mailID)) {
explicitSends.delete(piSessionId);
relayedSummaries.set(piSessionId, relayKey);
log(`本轮模型已主动回信 ${ctx.replyTo},跳过自动转发`);
return;
}
await client.post('/mail/send', {
to: ctx.replyTo,
subject: replySubject(ctx.subject),
body: text,
reply_to: ctx.mailID || '',
// relay + relay_key 走免配额通道I-2模型已经把话说完了
// 桥只是把它搬到邮件里。对搬运收费会让配额用尽时 Agent 连交代都做不了。
relay: 'summary',
relay_key: relayKey,
});
relayedSummaries.set(piSessionId, relayKey);
explicitSends.delete(piSessionId); // 一轮结束,窗口关闭
log(`已转发本轮总结给 ${ctx.replyTo}${text.length} 字)`);
}
// ─── 投递B-3 / B-6───
async function deliverMail(data, kind, mailTools) {
const { session, reused } = await resolveSession(data, mailTools);
const piSessionId = session.sessionId;
// 新一轮开始:清掉上一轮「模型主动发过信」的记录。不清的话,
// 上一轮亲手回过信会永久压掉这个会话之后所有的自动转发。
explicitSends.delete(piSessionId);
if (kind === 'mail' && data.session_id) {
// 一个会话里可能来过多封信,只留最近那封 —— 回信要落回最新的线索
mailContexts.set(data.session_id, {
replyTo: data.from_name || '',
subject: data.subject || '',
mailID: data.mail_id || '',
});
}
const prompt = buildMailPrompt({ agentName: AGENT_NAME, data, kind, reused });
// 续谈会话已经存在模型也已经定了pi 的模型在 createAgentSession 时绑定),
// 所以这一支不做模型降级。runTurn 内部按 isStreaming 分流:
// 空闲就直接起一轮,正在跑就排到当轮之后(不打断上一封邮件的工作)。
if (reused) {
const outcome = await runTurn(session, prompt, TURN_TIMEOUT_MS);
log(`续谈 ${piSessionId}mail ${data.mail_id}${outcome.queued ? ',已排队' : ''}`);
// 续谈失败不换模型重试(换模型要换会话,会丢掉整条上下文 ——
// 而上下文正是发件人指定这条会话的原因),但要让失败可见。
if (!outcome.ok) throw new Error(`续谈失败: ${outcome.error}`);
return;
}
// 按管理员划定的范围逐个尝试D-3
// 关键点:`prompt()` resolve **不代表模型跑成功了** —— 无凭证的 provider
// 会让它 reject实测 `No API key found for amazon-bedrock.`
// 而上游报错走 stopReason==='error'。判定交给 classifyTurnOutcome。
const attempts = modelAttemptOrder(allowedModels, {
provider: REPLY_PROVIDER,
model: REPLY_MODEL,
});
const failures = [];
for (const route of attempts) {
const label = route ? `${route.provider}/${route.model}` : '(平台默认)';
if (route) {
const model = modelRuntime.getModel(route.provider, route.model);
if (!model) {
// 目录里根本没有这个路由:同步就能判定,不必起一轮
failures.push({ ...route, error: `平台目录里没有 ${label}` });
log(`模型 ${label} 不存在,跳过`);
continue;
}
// 换模型要换会话pi 的模型在 createAgentSession 时绑定。
// 上一次尝试失败的会话没有任何 assistant 消息,丢掉不损失内容。
const cwd = sessions.get(data.session_id)?.cwd;
const current = sessions.get(data.session_id)?.session;
current?.dispose?.();
const retried = await openSession({
cwd,
modelRuntime,
model,
customTools: mailTools,
extension: permissionExtension((id) => mailContexts.get(id)),
});
rebind(data.session_id, current?.sessionId ?? piSessionId, retried, cwd);
const outcome = await runTurn(retried.session, prompt, TURN_TIMEOUT_MS);
if (outcome.ok) {
if (failures.length) log(`${label} 成功(前 ${failures.length} 个失败)`);
return;
}
failures.push({ ...route, error: outcome.error });
log(`模型 ${label} 失败: ${outcome.error}`);
continue;
}
const outcome = await runTurn(session, prompt, TURN_TIMEOUT_MS);
if (outcome.ok) {
if (failures.length) log(`${label} 成功(前 ${failures.length} 个失败)`);
return;
}
failures.push({ error: outcome.error });
log(`模型 ${label} 失败: ${outcome.error}`);
}
// 全部失败 → 必须回信B-6模型一次都没跑起来会话里没有任何
// assistant 消息,自动转发因此什么也不会发 —— 发件人只会看到再无音讯。
if (kind === 'mail' && data.from_name) {
try {
await client.post('/mail/send', {
to: data.from_name,
subject: `处理失败: ${data.subject || '(无主题)'}`,
body: renderFailureReport(failures, data.subject),
reply_to: data.mail_id || '',
relay: 'summary',
relay_key: `model-failure:${data.mail_id || piSessionId}`,
});
log(`已回报模型调用失败给 ${data.from_name}`);
} catch (e) {
log(`失败回报也发不出去: ${describeError(e)}`);
}
}
// 发完仍要 throwB-6.4):静默会让这次失败只存在于邮件里,日志上看不出来
throw new Error(`范围内 ${failures.length} 个模型全部失败:${failures.map(f => f.error).join(' | ')}`);
}
/** 换模型重开会话后,把三张映射表指向新会话。 */
function rebind(mailSessionID, oldPiId, opened, cwd) {
reverseMap.delete(oldPiId);
mailDriven.delete(oldPiId);
const piSessionId = opened.session.sessionId;
// cwd 由调用方传AgentSession 上没有 cwd getter只有 sessionId /
// sessionFile / sessionName从 sessionManager.getCwd() 也行,
// 但这里本来就有那个值,多绕一层没有意义。
const entry = { session: opened.session, sessionManager: opened.sessionManager, cwd };
if (mailSessionID) {
sessions.set(mailSessionID, entry);
reverseMap.set(piSessionId, mailSessionID);
mailDriven.add(piSessionId);
}
opened.session.subscribe((event) => {
if (event?.type === 'agent_end' && !event.willRetry) {
relaySummary(piSessionId).catch((e) => log(`自动转发失败: ${describeError(e)}`));
}
if (event?.type === 'session_info_changed') {
syncNaming(piSessionId, event.name).catch((e) => log(`命名同步失败: ${describeError(e)}`));
}
});
}
// ─── 权限决策回来B-4───
async function handlePermissionDecision(data, mailTools) {
const relayKey = data.relay_key || '';
const pending = relayKey ? pendingPermissions.get(relayKey) : undefined;
if (pending) {
pendingPermissions.delete(relayKey);
pending.resolve(String(data.decision || '拒绝'));
log(`权限 ${relayKey} 决策 ${data.decision}(决策人 ${data.decided_by || '?'}`);
return;
}
// 找不到挂起项(桥重启丢了内存映射)→ 退化为把决策当一封通知投进原会话B-4.2)。
// 此时 pi 侧那次工具调用早已随进程消失,但人刚刚点了「同意」——
// 什么都不做的话人以为自己批准了、Agent 却毫无反应。
if (!data.session_id || !sessions.has(data.session_id)) {
// **不得凭空新开会话**B-4.3
log(`权限决策 ${relayKey} 无对应会话,忽略`);
return;
}
log(`权限 ${relayKey} 无挂起项,退化为通知投递`);
await deliverMail(data, 'permission', mailTools);
}
// ─── 心跳B-2───
async function reportSessions() {
try {
const { SessionManager } = await import('@earendil-works/pi-coding-agent');
// 不传参数:`listAll(dir)` 把字符串当**自定义会话目录**,传 getAgentDir()
// 会去 ~/.pi/agent 下直接找 .jsonl那里没有得到空列表。
// 不传时它用默认的 ~/.pi/agent/sessions逐个 cwd 子目录扫。
//
// 用 listAll 而不是 list(cwd):桥的进程 cwd 与会话 cwd 无关,
// 按前者过滤会漏掉所有真正在干活的会话。
const all = await SessionManager.listAll();
return snapshotPiSessions(all, (id) => mailDriven.has(id));
} catch (e) {
// 拉不到就**省略字段**而不是传 []N-7 / W-3
// 空数组的语义是「平台确实一条会话都没有」,会把服务端镜像抹掉。
log(`会话列表读取失败: ${describeError(e)}`);
return undefined;
}
}
async function reportModels() {
try {
// getAvailable 而不是 getModels后者本机有 1221 条,其中真能调起来的只有 1 条。
// 上报目录的全部意义就是让管理员别选中一个注定失败的路由。
const available = await modelRuntime.getAvailable();
return snapshotPiModels(available);
} catch (e) {
log(`模型目录读取失败: ${describeError(e)}`);
return undefined;
}
}
async function catchUp(pending, mailTools) {
if (!pending) return;
try {
const box = await client.get('/mail/inbox?status=unread&limit=20');
const tasks = selectCatchup(box?.mails ?? box, deliveredMails);
if (!tasks.length) return;
log(`补投 ${tasks.length} 封离线期间的邮件(共 ${pending} 封未读)`);
// 串行B-7.2):每封都要起一轮模型,并发放出去等于对上游打 N 个并发请求
for (const ev of tasks) {
if (deliveredMails.has(ev.mail_id)) continue; // 逐封再查B-7.6
deliveredMails.add(ev.mail_id);
try {
await deliverMail(ev, 'mail', mailTools);
} catch (e) {
log(`补投 ${ev.mail_id} 失败: ${describeError(e)}`);
}
}
} catch (e) {
log(`补投失败: ${describeError(e)}`);
}
}
// ─── 启动 / 关停 ───
async function main() {
if (!acquireLock()) process.exit(0);
// B-1.1:环境变量 → ~/.agentmail/agent.key → 本地生成并打印全文
let agentKey = process.env.AGENTMAIL_AGENT_KEY || readLocalKey();
if (!agentKey && !AGENT_SECRET) agentKey = generateLocalKey(log);
client = new GatewayClient({
url: GATEWAY_URL,
agentName: AGENT_NAME,
agentKey,
agentSecret: AGENT_SECRET,
});
// ModelRuntime 建一次全进程共用:它要读 auth.json / models.json 并做
// 可用性探测,每条会话建一个既慢又会重复打 provider 的探测请求。
//
// allowModelNetwork 保持默认的 false桥启动时不去网上拉模型目录。
// 拉了也没用 —— 上报给 Gateway 的是 getAvailable()(有凭证、真能调起来的),
// 而那取决于本机 auth.json不取决于目录里有多少条。开着只会让
// 启动多等一个网络往返,而且断网时启动路径上多一个可失败点。
modelRuntime = await ModelRuntime.create();
const runtimeErr = modelRuntime.getError?.();
if (runtimeErr) log(`模型运行时告警: ${runtimeErr}`);
const mailTools = createMailTools({ client, log, agentName: AGENT_NAME });
try {
await client.register(); // B-1.2
saveConfig({ gateway_url: GATEWAY_URL, agent_name: AGENT_NAME, registered_at: new Date().toISOString() });
log(`已接入 ${GATEWAY_URL},身份 ${AGENT_NAME}${agentKey ? '密钥认证' : 'name/secret 认证'})。`);
} catch (e) {
// 密钥未登记时这里报「密钥无效」—— 必须说清该做什么,
// 否则用户只看到一句 401不知道要拿密钥去后台登记。
log(`注册失败: ${describeError(e)}`);
if (agentKey) log(`若提示密钥无效,请让管理员在 AgentMail 后台登记这把密钥。`);
}
let caughtUp = false;
const beat = async () => {
const [platform_sessions, models] = await Promise.all([reportSessions(), reportModels()]);
const body = {};
if (platform_sessions) body.platform_sessions = platform_sessions;
if (models) body.models = models;
try {
const res = await client.post('/agent/heartbeat', body);
if (Array.isArray(res?.allowed_models)) allowedModels = res.allowed_models; // B-2.2
if (!caughtUp) { // B-7.1:只在首个成功心跳后补一次
caughtUp = true;
await catchUp(res?.pending_mails, mailTools);
}
} catch {
// B-2.1:心跳失败不重试不报错。真连不上时 Gateway 会把它判成离线,
// 那才是可见的信号;桥自己打一串错误日志只会淹掉真正的问题。
}
};
await beat(); // B-1.3:不等第一个 30 秒周期
heartbeatTimer = setInterval(beat, 30_000); // B-1.5
client.startSSE((type, data) => { // B-1.4:首次不带 Last-Event-ID
if (type === 'permission_decision') {
handlePermissionDecision(data, mailTools).catch((e) =>
log(`权限决策处理失败: ${describeError(e)}`));
return;
}
if (type !== 'new_mail') return;
if (data?.role && data.role !== 'to' && data.role !== 'cc') return;
const id = data?.mail_id;
if (!id || deliveredMails.has(id)) return; // B-3 第 1 步:去重
deliveredMails.add(id);
deliverMail(data, 'mail', mailTools).catch((e) => log(`投递 ${id} 失败: ${describeError(e)}`));
}, log);
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => shutdown(sig));
}
function shutdown(reason) {
if (shuttingDown) return;
shuttingDown = true;
log(`收到 ${reason},关停中…`);
if (heartbeatTimer) clearInterval(heartbeatTimer); // B-9.1
client?.stopSSE();
// B-9.2 / N-9所有未决权限询问 fail closed。
// 不唤醒的话 pi 侧那些 await 永不返回,整条会话挂死;
// 而默认放行一个没人批准的危险操作,比让它失败严重得多。
for (const [key, p] of pendingPermissions) {
log(`未决权限 ${key} fail closed`);
p.resolve('shutdown');
}
pendingPermissions.clear();
for (const { session } of sessions.values()) {
try { session.dispose?.(); } catch { /* 关停期的报错没有价值 */ }
}
releaseLock();
// B-9.3:不发「插件下线」通知邮件
process.exit(0);
}
main().catch((e) => {
log(`启动失败: ${describeError(e)}`);
releaseLock();
process.exit(1);
});

View File

@ -0,0 +1,115 @@
/**
* 会话命名的双向一致C-11 / W-7 / D-5
*
* 一句话:**Gateway 定稿pi 接受定稿**。
*
* pi/pi-web 生成名字 ──①观测──▶ 桥 ──②POST /sessions/{id}/sync──▶ Gateway
* pi 的 session_info ◀──④回写──── ③响应里的 final alias
*
* 为什么不能各自命名然后指望撞上:别名在 AgentMail 侧负有寻址唯一性义务
* partial unique index + 撞名自动追 -2/-3pi 侧没有这个约束。
* 而 `alias_source='manual'` 的会话(人在界面上改过名)永远不接受平台同步,
* `SyncSessionAlias` 会把**当前别名原样返回**。所以只有用响应里的值回写,
* 两边看到的才是同一个名字。单向推送做不到这一点。
*
* ④ 必须判「与上次写入的值不同」才执行,否则 setSessionName 触发
* session_info_changed钩子又去 sync成自激循环。
*
* 三个实测出来的约束(探针脚本验证过,见 test/naming.test.mjs 里的注释):
* - pi 首条 assistant 消息落盘前会话文件**不存在**SessionManager 首次落盘用
* `openSync(file, "wx")`;桥抢先按路径写会让 pi 侧 flush 抛 EEXIST。
* → 回写只用 `session.setSessionName()`(走 pi 自己的写入路径),
* 绝不自己拼路径写文件。
* - 活着的 SessionManager 不 watch 文件;外部改名它看不见,之后它自己
* append 一条 session_info 反而会盖掉外部的("最后一条生效")。
* - 空名字是**清除**语义(`appendSessionInfo(" ")` 之后 getSessionName() 变
* undefined因此不能用空串表达「无变化」。
*/
import { slugFromTitle, isUnusableName } from '../lib/session-snapshot.js';
/**
* 决定这一轮要不要向 Gateway 同步命名,以及同步什么。
*
* 别名的降级阶梯D-5
* 1. 平台生成的名字派生的 slug
* 2. 名字不可用pi-web 的思维链泄漏、纯符号)或**根本没有名字**
* → 退到邮件主题派生
* 3. 两者都没有 → **不写回**W-7.2:绝不写占位别名)
*
* 第 2 步里的「根本没有名字」是 pi 的常态而非例外:桥用 SDK 起的会话不经过
* pi-web 的标题生成器(那个生成器在 pi-web 包里,不在 pi 内核里),
* 因此 `session.sessionName` 一直是 undefined。只等平台命名的话别名永远是空的
* `name@path.<别名>` 续谈无从下手 —— 实测过这个后果。
*
* 标题一律用平台原文(不派生、不清洗):`I-4` 说插件只搬运。
* 唯一的例外是判废 —— 判废的结果是「不写」,不是「改写成别的」。
*
* 返回值里的 `signature` 是「本次提交内容的指纹」,调用方存下它并在下一轮
* 作为 `lastSynced` 传回,用来判「没变化就别重复提交」。**不能用平台名字本身**
* 充当这个角色:名字为空时(上面那个常态)它无法区分「还没提交过」与
* 「提交过、内容没变」,于是每轮心跳都白打一次 sync。
*
* @param {object} input
* @param {string} input.platformName pi 侧 session_info 里的名字
* @param {string} input.mailSubject 该会话最近一封来信的主题(兜底用)
* @param {string} input.lastSynced 上一次提交的 signature
* @returns {{skip: true, reason: string} | {skip: false, alias: string, title: string, source: string, signature: string}}
*/
export function planNamingSync({ platformName, mailSubject, lastSynced }) {
const name = String(platformName ?? '').trim();
const prev = String(lastSynced ?? '').trim();
const decide = () => {
if (name && !isUnusableName(name)) {
const alias = slugFromTitle(name);
// 名字看着正常但全是分隔符("..." / "@@@")→ 派生不出别名,
// 但**标题仍然值得写**subject 那一列不负责寻址,没有字符限制。
if (alias) return { alias, title: name, source: 'platform' };
return { alias: '', title: name, source: 'platform-title-only' };
}
// 平台名字不可用或不存在:退到邮件主题。它是人写的,
// 天然比模型的思维链靠谱,而 SDK 起的会话本来就没有平台名字。
const subject = String(mailSubject ?? '').trim();
if (subject) {
const alias = slugFromTitle(subject);
if (alias) return { alias, title: '', source: 'mail-subject' };
}
return null;
};
const plan = decide();
// 什么都没有:不写。宁可让会话保持无别名(数据库允许 NULL
// 也不要写一个 "session-123" 这样的占位值 —— 那种别名对人毫无指代作用,
// 而且一旦落库就把 alias 位占住了,真正的名字来了也只能追 -2 后缀。
if (!plan) return { skip: true, reason: 'no-usable-name' };
const signature = `${plan.source}:${plan.alias}|${plan.title}`;
if (signature === prev) return { skip: true, reason: 'unchanged' };
return { ...plan, skip: false, signature };
}
/**
* 决定要不要把 Gateway 定稿的别名回写进 pi。
*
* 回写的三种触发情形:
* - 撞名:提议 `fix-leak`Gateway 给了 `fix-leak-2`
* - manual 保护:人在界面上改成了 `紧急排查`Gateway 原样返回它
* - 规范化:提议里含 `.` `@` `/` 空白,被 normalizeAlias 换成了 `-`
*
* @param {object} input
* @param {string} input.finalAlias Gateway 响应里的 alias
* @param {string} input.currentPiName pi 侧当前的名字
* @returns {{write: boolean, name: string, reason: string}}
*/
export function planWriteBack({ finalAlias, currentPiName }) {
const final = String(finalAlias ?? '').trim();
// 服务端没回别名(本次只同步了标题)→ 没有定稿值可写
if (!final) return { write: false, name: '', reason: 'no-alias-in-response' };
const cur = String(currentPiName ?? '').trim();
if (cur === final) return { write: false, name: '', reason: 'already-equal' };
// 空名字是清除语义,这里 final 非空,所以安全
return { write: true, name: final, reason: cur ? 'diverged' : 'pi-unnamed' };
}

View File

@ -0,0 +1,137 @@
/**
* pi 会话池 —— 每条 AgentMail 会话对应一条 pi 会话。
*
* 为什么桥必须自己持有 pi 会话(而不是写成一个 pi 扩展):
* 扩展被加载进**一条已经存在的**会话里cwd 由启动 pi 的人决定;而 B-3.1 要求
* 每封邮件的 to_workspace 成为会话 cwd。扩展做不到「按邮件新开一条 cwd 不同的
* 会话」所以桥是一个常驻进程C-7用 SDK 的 createAgentSession 起会话。
*
* 每条会话一套 SettingsManager / ResourceLoader / SessionManager它们都按 cwd
* 解析项目级配置(.pi/、skills、prompts共用一份会把 A 项目的配置带进 B 项目。
*/
import { createAgentSession, SessionManager, SettingsManager, DefaultResourceLoader, getAgentDir }
from '@earendil-works/pi-coding-agent';
/**
* 起一条 pi 会话。
*
* @param {object} opts
* @param {string} opts.cwd 会话工作目录(已由 resolveWorkspaceCwd 校验过存在)
* @param {any} opts.modelRuntime 共享的 ModelRuntime建一次很贵池外传进来
* @param {any} [opts.model] 指定模型;省略则用 settings 里的默认
* @param {any[]} opts.customTools 邮件工具send_mail / read_inbox / …)
* @param {(pi: any) => void} [opts.extension] 内联扩展工厂,用来挂 tool_call 权限钩子
* @returns {Promise<{session: any, sessionManager: any, diagnostics: any[]}>}
*/
export async function openSession({ cwd, modelRuntime, model, customTools, extension }) {
const agentDir = getAgentDir();
const settingsManager = SettingsManager.create(cwd, agentDir);
const resourceLoader = new DefaultResourceLoader({
cwd,
agentDir,
settingsManager,
// 关掉磁盘上的全局扩展。两个理由:
// 1. 本机的 pi-a2a / pi-acp 在加载时 listen 固定端口12010/12011
// 守护进程里加载会 EADDRINUSE把整条会话拖死。
// 2. 桥起的会话是给邮件用的不该继承人类交互用的那套扩展TUI 命令、
// 快捷键、状态栏都没有意义)。
// 邮件工具走 customTools权限钩子走下面的 extensionFactories。
noExtensions: true,
extensionFactories: extension
? [{ name: 'agentmail-bridge', factory: extension }]
: [],
});
await resourceLoader.reload();
const sessionManager = SessionManager.create(cwd);
const created = await createAgentSession({
cwd,
agentDir,
modelRuntime,
// model 为 undefined 时 SDK 用 settings 里的默认模型,正好对应
// modelAttemptOrder 里那个 `undefined`= 不指定、交给平台)。
...(model ? { model } : {}),
sessionManager,
settingsManager,
resourceLoader,
customTools,
});
return {
session: created.session,
sessionManager,
diagnostics: created.extensionsResult?.diagnostics ?? [],
};
}
/**
* 跑一轮并等到真正的结论C-4 / D-3
*
* `session.prompt()` 的 promise 在**这一轮彻底结束**时才 resolve所以不需要
* 额外订阅 agent_end 去等。但它 resolve 了**不代表模型跑成功了** ——
* 判定交给 classifyTurnOutcome三条互不重叠的失败信号见那里的注释
*
* 60 秒超时算成功(与另两个插件同一取舍):长任务很正常,把它判成失败会
* 换模型重跑一遍,等于同一封邮件跑两次。超时只是「不再等着上报结论」,
* 会话仍在跑,轮次结束后 agent_end 会照常触发自动转发。
*
* 会话正在跑时走排队(返回 queued**不能**在那种情况下判结论:
* prompt 排完队就 resolve此时 session.messages 里最后一条是**上一轮**的,
* 拿它判定会把上一轮的成败当成这一轮的。
*
* @param {any} session
* @param {string} promptText
* @param {number} timeoutMs
* @returns {Promise<{ok: boolean, error: string, aborted: boolean, timedOut: boolean, queued: boolean}>}
*/
export async function runTurn(session, promptText, timeoutMs = 60_000) {
const { classifyTurnOutcome } = await import('./turn.mjs');
// 排队分支:模型还在说话时又来一封邮件。
//
// streamingBehavior 必选,缺了 prompt 直接抛
// "Agent is already processing. Specify streamingBehavior…"。
// 取 followUp 而不是 steersteer 会把当前这一轮打断,
// 而当前这一轮正在处理**上一封邮件** —— 那封邮件的发件人也在等回信。
if (session.isStreaming) {
await session.prompt(promptText, { streamingBehavior: 'followUp' });
return { ok: true, error: '', aborted: false, timedOut: false, queued: true };
}
let timer = null;
const timeout = new Promise((resolve) => {
timer = setTimeout(
() => resolve({ ok: true, error: '', aborted: false, timedOut: true, queued: false }),
timeoutMs,
);
});
const run = session.prompt(promptText)
.then(() => ({ ...classifyTurnOutcome({ messages: session.messages }), timedOut: false, queued: false }))
.catch((e) => ({ ...classifyTurnOutcome({ error: e }), timedOut: false, queued: false }));
try {
return await Promise.race([run, timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}
/**
* 续谈:往一条已经存在的会话里追加一轮。
*
* 这就是 `runTurn` —— 不需要第二个函数。
*
* **不能**用 `session.followUp()`:那个方法只往 followUpQueue 里塞消息,
* 队列**只在运行中的轮次末尾**被 drainpi-agent-core/agent.js 的 run 循环,
* 以及 `continue()`)。会话空闲时(上一轮早已结束)塞进去的消息永远没人取,
* 于是这封邮件既没有回信也没有报错 —— 实测踩过:日志打了「续谈」,
* 收件箱里只有来信没有回复。
*
* `runTurn` 按 `isStreaming` 分流,两种状态都正确:
* - 空闲 → `prompt()` 直接起一轮
* - 正在跑 → `prompt(text, {streamingBehavior:'followUp'})` 排到当轮之后
*/
export { runTurn as followUpTurn };

View File

@ -0,0 +1,355 @@
/**
* 邮件工具T-1..T-6—— 注册给 pi 里的模型。
*
* pi 的工具定义用 TypeBox schema这里直接写等价的 JSON Schema 字面量:
* TypeBox 的 `Type.Object({...})` 产出的就是这个形状,而桥是 .mjs无编译步骤
* 少一个运行时依赖。
*
* `execute(toolCallId, params, signal, onUpdate, ctx)` 的 ctx 是 ExtensionContext
* 由此可以拿到 `ctx.sessionManager.getSessionId()` —— 这就是 C-6 要求的
* 「工具能拿到当前会话 id」自动转发去重B-5.3)靠它把发信记到正确的会话上。
*/
import { readFile, writeFile } from 'node:fs/promises';
import { basename } from 'node:path';
import {
renderInbox,
idsToMarkRead,
formatSize,
DEFAULT_INBOX_STATUS,
DEFAULT_INBOX_LIMIT,
} from '../lib/inbox-format.js';
import {
renderNameSuggestions,
renderPathSuggestions,
renderSessionSuggestions,
renderParticipants,
renderContacts,
renderThread,
} from '../lib/discovery.js';
import { noteExplicitSend } from '../lib/relay-dedup.js';
const text = (s) => ({ content: [{ type: 'text', text: s }] });
/**
* @param {object} deps
* @param {import('./gateway.mjs').GatewayClient} deps.client
* @param {(msg: string) => void} deps.log
* @param {string} [deps.agentName] 自己的 Agent 名。收件箱渲染靠它判定
* 「我是收件人还是抄送方」并给出可投递地址。
*/
export function createMailTools({ client, log, agentName = '' }) {
const sendMail = {
name: 'send_mail',
label: 'SendMail',
description:
'发送邮件。三维地址 name@path.session省略 session 投递到默认会话,' +
'.new 强制新建,.具体别名 必须已存在。回复来信请传 reply_to。',
parameters: {
type: 'object',
properties: {
to: { type: 'string', description: '收件人三维地址,如 admin@/home/program/x' },
subject: { type: 'string', description: '邮件主题' },
body: { type: 'string', description: '邮件正文Markdown' },
cc: { type: 'string', description: '抄送,逗号分隔多个三维地址' },
reply_to: { type: 'string', description: '回复某封邮件时传其 mail_id' },
session_alias: { type: 'string', description: '给新会话命名(仅 .new 时生效)' },
attachment_ids: {
type: 'array',
items: { type: 'string' },
description: '附件 ID 列表(先用 upload_attachment 取得)',
},
},
required: ['to', 'subject', 'body'],
additionalProperties: false,
},
async execute(_id, params, _signal, _onUpdate, ctx) {
const result = await client.post('/mail/send', {
to: params.to,
subject: params.subject,
body: params.body,
cc: params.cc || '',
reply_to: params.reply_to || '',
session_alias: params.session_alias || '',
attachment_ids: params.attachment_ids || [],
// 这里**不带 relay**N-5模型的自主发信要计配额
// 免配额通道只给插件代劳的转发(总结、权限询问、故障报告)。
});
// 记下「模型这一轮亲手发过信」,供 B-5.3 让位判定。
// 会话 id 从 ctx 取:工具不知道自己被哪条会话调用,就没法正确归属。
noteExplicitSend(ctx?.sessionManager?.getSessionId?.(), params.to, params.reply_to);
const budget = typeof result.budget_remaining === 'number'
? ` 本任务剩余 ${result.budget_remaining}/${result.budget_max} 个来回。`
: '';
return text(`邮件已发送ID: ${result.mail_id}${budget}`);
},
};
const readInbox = {
name: 'read_inbox',
label: 'ReadInbox',
description:
'查阅收件箱中的邮件。收到新邮件通知后应立即调用此工具。' +
'每封含 mail_id、发件人、主题、正文与附件清单带 attachment_id。',
parameters: {
type: 'object',
properties: {
status: { type: 'string', description: '过滤条件 unread|all默认 unread' },
limit: { type: 'number', description: '返回数量,默认 5' },
},
additionalProperties: false,
},
async execute(_id, params) {
const status = params.status || DEFAULT_INBOX_STATUS;
const { mails } = await client.get(
`/mail/inbox?status=${encodeURIComponent(status)}&limit=${params.limit || DEFAULT_INBOX_LIMIT}`,
);
// 渲染与已读策略走共用模块:与另两个平台必须一致,
// 每条规则对应过一次真实的错误行为(见 lib/inbox-format.js
//
// 传 agentName 才能判定身份并给出可投递地址 —— 不传的话模型只能
// 从抄送行里抄一个 `.new`,而那是一次性的,回过去只会再建一条平行会话。
const listed = renderInbox(mails, 200, agentName);
const ids = idsToMarkRead(params.status, mails);
if (ids.length) {
// 标记失败不该让 read_inbox 失败:正文已经取到了,
// 代价只是下次重复看到,比丢掉这次读取轻。
client.post('/mail/read', { mail_ids: ids }).catch((e) =>
log(`[pi-mail-bridge] 标记已读失败: ${e?.message || e}`));
}
return text(listed);
},
};
const forwardMail = {
name: 'forward_mail',
label: 'ForwardMail',
description:
'转发一封邮件给新的收件人(引用原文)。与回复不同:回复落回原会话,' +
'转发按目标地址另行定位会话。只能转发自己参与过的邮件。',
parameters: {
type: 'object',
properties: {
mail_id: { type: 'string', description: '要转发的邮件 ID从 read_inbox 获得)' },
to: { type: 'string', description: '新收件人的三维地址' },
comment: { type: 'string', description: '转发说明,置于引用原文之前' },
cc: { type: 'string', description: '抄送,逗号分隔多个三维地址' },
subject: { type: 'string', description: '自定义主题;留空则自动加 Fwd: 前缀' },
session_alias: { type: 'string', description: '仅在目标地址以 .new 结尾时生效:给新会话命名' },
},
required: ['mail_id', 'to'],
additionalProperties: false,
},
async execute(_id, params, _signal, _onUpdate, ctx) {
// 路径带 mail_idPOST /mail/{id}/forward不是请求体里的字段
const result = await client.post(`/mail/${params.mail_id}/forward`, {
to: params.to,
comment: params.comment || '',
cc: params.cc || '',
subject: params.subject || '',
session_alias: params.session_alias || '',
});
noteExplicitSend(ctx?.sessionManager?.getSessionId?.(), params.to, '');
return text(`已转发。新 Mail ID: ${result.mail_id}Session: ${result.session_id}`);
},
};
const uploadAttachment = {
name: 'upload_attachment',
label: 'UploadAttachment',
description: '上传本地文件作为邮件附件,返回 attachment_id。',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string', description: '本地文件的绝对路径' },
},
required: ['file_path'],
additionalProperties: false,
},
async execute(_id, params) {
const buf = await readFile(params.file_path);
const a = await client.uploadFile(buf, basename(params.file_path) || 'file');
return text(
`已上传 ${a.filename}${formatSize(a.size_bytes)}。attachment_id: ${a.attachment_id}`,
);
},
};
const downloadAttachment = {
name: 'download_attachment',
label: 'DownloadAttachment',
description: '下载邮件附件到本地文件。',
parameters: {
type: 'object',
properties: {
attachment_id: { type: 'string', description: '附件 IDread_inbox 的清单里给出)' },
save_path: { type: 'string', description: '保存路径' },
},
required: ['attachment_id', 'save_path'],
additionalProperties: false,
},
async execute(_id, params) {
const buf = await client.downloadFile(params.attachment_id);
await writeFile(params.save_path, buf);
return text(`已保存到 ${params.save_path}${formatSize(buf.length)}`);
},
};
// ─── 寻址发现工具(读 Agent 侧只读端点)───
//
// 在这一组之前send_mail 的 to 是个只能靠记忆拼写的自由文本字段,
// 而拼错不报错:生产上另一个平台猜了 `opencode@/home`,投递成功,
// 但那不是 opencode 的工作目录,静默变成了新会话的 workspace。
//
// 渲染逻辑在 lib/discovery.js三平台共用
const suggestAddress = {
name: 'suggest_address',
label: 'SuggestAddress',
description:
'查询可用的收件人地址,用于精准发信。不带参数给候选收件人名;带 name 给它可用的' +
'工作目录name+path 都带则给该目录下可续谈的会话与现成地址。' +
'**发信前应先用它确认地址**,不要凭记忆拼写 —— 拼错不会报错,只会投到别的会话。',
parameters: {
type: 'object',
properties: {
name: { type: 'string', description: '收件人名;留空则列出所有候选收件人' },
path: { type: 'string', description: '工作目录;与 name 同时给出才列会话' },
},
additionalProperties: false,
},
async execute(_id, params) {
const name = String(params.name || '').trim();
const path = String(params.path || '').trim();
const qs = new URLSearchParams();
if (name) qs.set('name', name);
if (path) qs.set('path', path);
const data = await client.get(`/agent/contacts/suggest?${qs.toString()}`);
// 按服务端回的 kind 分派而不是按本地参数:省略与传空串在服务端
// 是同一个意思,但「哪一段该渲染成什么」只有服务端知道。
switch (data?.kind) {
case 'name': return text(renderNameSuggestions(data.suggestions));
case 'path': return text(renderPathSuggestions(data.suggestions, name));
default: return text(renderSessionSuggestions(data, name, path));
}
},
};
const listContacts = {
name: 'list_contacts',
label: 'ListContacts',
description:
'列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。' +
'用于回答「我还有什么没处理」与「上次跟某人聊的那条线索地址是什么」。',
parameters: {
type: 'object',
properties: {
limit: { type: 'number', description: '最多列出多少条,默认 20' },
},
additionalProperties: false,
},
async execute(_id, params) {
const data = await client.get('/agent/contacts');
return text(renderContacts(data, params.limit || 20));
},
};
const sessionParticipants = {
name: 'session_participants',
label: 'SessionParticipants',
description:
'列出某条会话的全部参与方(发件人/收件人/抄送方)及各自的可投递地址,' +
'并标出谁还没回应。**要回给抄收方或向第三方转达时先用它拿地址**。',
parameters: {
type: 'object',
properties: {
session_id: { type: 'string', description: '会话 ID' },
},
required: ['session_id'],
additionalProperties: false,
},
async execute(_id, params) {
const data = await client.get(`/agent/sessions/${params.session_id}/participants`);
return text(renderParticipants(data));
},
};
const readThread = {
name: 'read_thread',
label: 'ReadThread',
description:
'查看一封邮件所在线索的完整往来(谁回了谁、谁还没回)。多方抄送协作时' +
'用它确认别人已经说了什么,避免重复提问或重复汇报。',
parameters: {
type: 'object',
properties: {
mail_id: { type: 'string', description: '线索中任一封邮件的 ID' },
offset: { type: 'number', description: '分页偏移,续取时传上次返回的 next_offset' },
},
required: ['mail_id'],
additionalProperties: false,
},
async execute(_id, params) {
const qs = params.offset ? `?offset=${params.offset}` : '';
const data = await client.get(`/agent/mail/${params.mail_id}/thread${qs}`);
return text(renderThread(data, agentName));
},
};
const readMail = {
name: 'read_mail',
label: 'ReadMail',
description:
'读一封邮件的完整内容,含收件人、抄送清单、附件与每个参与方的可投递地址。' +
'收件箱只给摘要;要回给抄收方就得先看清这封信发给了谁。',
parameters: {
type: 'object',
properties: {
mail_id: { type: 'string', description: '邮件 ID' },
},
required: ['mail_id'],
additionalProperties: false,
},
async execute(_id, params) {
const data = await client.get(`/agent/mail/${params.mail_id}`);
const m = data?.mail || {};
const lines = [
`发件人: ${m.from_name || '?'}`,
`收件人: ${m.to_name || '?'}${m.to_workspace ? '@' + m.to_workspace : ''}`,
`主题: ${m.subject || '(无主题)'}`,
`会话: #${data.session_alias || '未命名'}session_id: ${m.session_id || '?'}`,
];
if (Array.isArray(m.cc_list) && m.cc_list.length) {
lines.push(`抄送: ${m.cc_list.map(c => c?.raw || c?.name).join('、')}`);
}
if (Array.isArray(m.attachments) && m.attachments.length) {
lines.push(`附件: ${m.attachments
.map(a => `${a.filename}${formatSize(a.size_bytes)}, id=${a.attachment_id}`)
.join('、')}`);
}
lines.push('', m.body || '(空正文)', '');
if (Array.isArray(data.participants) && data.participants.length) {
lines.push('可投递地址: ' + data.participants
.filter(p => p.address && p.name !== agentName)
.map(p => `${p.address}${p.role}`)
.join('、'));
}
if (data.reply_address) {
lines.push(`回信给发件人用 ${data.reply_address},或传 reply_to=${m.mail_id}`);
}
return text(lines.join('\n'));
},
};
// 故意**没有** request_permissionN-1 / T-7
// 权限询问由 tool_call 钩子接管 —— 模型可能忘了调,也可能在不需要时乱调,
// 而真正被 pi 拦下的那一次才是事实。
return [
sendMail, readInbox, readMail, forwardMail,
uploadAttachment, downloadAttachment,
// 寻址发现:让模型选地址而不是拼地址
suggestAddress, listContacts, sessionParticipants, readThread,
];
}

View File

@ -0,0 +1,187 @@
/**
* pi 侧的纯逻辑:提示词、轮次结论判定、消息文本提取、回信主题。
*
* 单独一个文件而不是塞进 index.mjs这几件事每一件都对应过一次真实的错误行为
* 而它们都不需要 pi SDK —— 因此可以直接用 node --test 钉住,不必起模型。
*
* 与 lib/ 的区别lib/ 下的文件三个平台**逐字节相同**deploy/check-shared-libs.sh
* 校验),这里的东西是 pi 专属的消息形状、stopReason 语义),不参与那个约束。
*/
/** 去掉已有的 Re: 前缀,避免 Re: Re: Re: 叠加。 */
export function stripRe(subject) {
return String(subject ?? '').replace(/^(\s*Re:\s*)+/i, '');
}
/** 自动转发时的回信主题。 */
export function replySubject(subject) {
const base = stripRe(subject).trim();
return base ? `Re: ${base}` : '本轮工作总结';
}
/**
* 取最后一条 assistant 消息里的纯文本。
*
* pi 的消息形状:`{ role, content: [{ type: 'text'|'thinking'|'toolCall', ... }] }`。
*
* **只取 `type === 'text'`**B-5.1thinking 块是思考过程,转进邮件对收件人
* 没有意义,而且经常包含「我先假设…」这类会被误读为结论的话。
*
* 从后往前找第一条**有文本**的 assistant 消息,而不是「最后一条 assistant 消息」:
* 一轮的收尾常常是纯工具调用消息content 里只有 toolCall
* 取到它会得到空字符串,于是 B-5.4 判成「无话可说」而漏掉真正的结论。
*
* @param {any[]} messages `session.messages` 或 `agent_end` 事件里的 messages
* @returns {string} 纯文本,找不到时为空串
*/
export function lastAssistantText(messages) {
const list = Array.isArray(messages) ? messages : [];
for (let i = list.length - 1; i >= 0; i--) {
const m = list[i];
if (m?.role !== 'assistant') continue;
const blocks = Array.isArray(m.content) ? m.content : [];
const text = blocks
.filter((b) => b?.type === 'text' && typeof b.text === 'string')
.map((b) => b.text)
.join('\n')
.trim();
if (text) return text;
}
return '';
}
/**
* 判定这一轮到底跑起来了没有C-4 / D-3
*
* 「submit 返回了」不等于「模型跑了」—— 这是两次适配都踩过的坑(契约 9.2)。
* pi 侧有三条互不重叠的失败信号,必须全查:
*
* 1. `prompt()` 直接 reject。凭证缺失就是这条实测无 API key 的 provider
* 抛 `No API key found for amazon-bedrock.`,一个事件都不发。
* 2. 最后一条 assistant 消息 `stopReason === 'error'`,原因在 `errorMessage`。
* 模型请求发出去了但上游报错走这条。
* 3. 一条 assistant 消息都没有。既没抛也没报错却什么都没产出,
* 当成功处理会让 B-5 转发一个空字符串回去 —— 发件人收到一封空邮件。
*
* `stopReason: 'aborted'` **算失败**但要区别对待:那是有人主动打断
* Esc / dispose不是模型故障因此不该触发换模型重试。
*
* @param {{error?: any, messages?: any[]}} input
* @returns {{ok: boolean, error: string, aborted: boolean}}
*/
export function classifyTurnOutcome({ error, messages } = {}) {
if (error) {
return { ok: false, error: describeError(error), aborted: false };
}
const list = Array.isArray(messages) ? messages : [];
let lastAssistant = null;
for (let i = list.length - 1; i >= 0; i--) {
if (list[i]?.role === 'assistant') { lastAssistant = list[i]; break; }
}
if (!lastAssistant) {
return { ok: false, error: '模型没有产出任何回复(一条 assistant 消息都没有)', aborted: false };
}
const stop = lastAssistant.stopReason;
if (stop === 'error') {
return {
ok: false,
error: describeError(lastAssistant.errorMessage) || '模型报错但未给出原因',
aborted: false,
};
}
if (stop === 'aborted') {
return { ok: false, error: '本轮被中断aborted', aborted: true };
}
// 'stop' 正常收尾;'length' 是被 max tokens 截断 —— 内容不完整但**是模型的产出**
// 判成失败会让一封「说了一半」的回信变成「换个模型重试」,那更糟。
// 'toolUse' 出现在这里说明轮次在等工具,正常流程下 agent_end 时不会是它。
return { ok: true, error: '', aborted: false };
}
/** 把各种形态的错误拼成一行可读文本。 */
export function describeError(err) {
if (!err) return '';
if (typeof err === 'string') return err.split('\n')[0].trim();
const parts = [err.code, err.message ?? String(err)].filter(Boolean);
return parts.join(': ').split('\n')[0].trim() || '未知错误';
}
/**
* 投递一封邮件时给模型的提示词。
*
* 三条硬要求B-3.4 / B-3.5
* - 写明「回信由插件自动发」。不说的话模型会自己调 send_mail
* 而插件在轮次结束时也会转发一次 —— 同一件事两封邮件(生产里真实发生过)。
* - 带上 mail_id让模型能自己定位这一封。
* - 让它先调 read_inbox事件里只有主题正文和附件清单都在收件箱里。
*
* @param {{agentName: string, data: any, kind: string, reused: boolean}} input
* @returns {string}
*/
export function buildMailPrompt({ agentName, data, kind, reused }) {
if (kind === 'permission') {
return [
`你之前发起的权限请求已有结论:${data?.decision ?? '(未给出)'}` +
`(决策人:${data?.decided_by || '用户'})。`,
`请据此继续后续工作。`,
].join('\n');
}
const head = reused
? '本会话收到一封新邮件AgentMail 续谈)。'
: '你收到一封新邮件AgentMail。';
const lines = [
head,
'',
`发件人:${data?.from_name || 'unknown'}`,
`主题:${data?.subject || '(无主题)'}`,
`邮件 ID${data?.mail_id || 'unknown'}`,
];
if (!reused) lines.push(`身份:你是 ${agentName}`);
// 服务端算好的回信地址(`new_mail` 的 reply_address。带上它是因为模型
// **确实会**自己发信 —— 尤其是要抄送第三方、或分多封交代不同的事时。
// 让它自己拼三维地址的话,`.new` 会被拼进去,于是回信静默开出一条新会话,
// 原来的线索里再无下文。
if (data?.reply_address) {
lines.push(`回信地址:${data.reply_address}(如需自己发信,用这个地址)`);
}
if (data?.catchup) {
// 补投的邮件要说明,否则模型会以为这是刚到的、按「立即响应」的语气回
lines.push('说明:这是插件离线期间积压的邮件,现在补投给你。');
}
lines.push(
'',
'请先调用 read_inbox 读取完整正文(附带附件清单,如有附件可用 download_attachment 取回),',
'然后处理其中的请求。',
'回信不用你自己发:把这一轮做完、把结论说出来就行,插件会把你最后那段话作为回信发出去。',
);
return lines.join('\n');
}
/**
* 自动转发的幂等键W-6 / B-5.2)。
*
* 用 pi 侧的会话 id + 会话树叶子条目 id两者都由 pi 生成且落盘,
* 插件重启后重放同一轮也会得到同一个键。用「消息条数」之类的派生量不行 ——
* 压缩compaction会改变条数于是同一轮结论换了个键被当成新消息再转一次。
*
* @param {string} piSessionId
* @param {string} leafId
* @returns {string}
*/
export function relayKeyFor(piSessionId, leafId) {
return `${piSessionId || 'unknown'}:${leafId || 'noleaf'}`;
}
/**
* pi 会话文件名里的 cwd 编码(`/home/x` → `--home-x--`)。
*
* 只用于日志与排查提示,不参与任何决策 —— 真正的路径一律用 SDK 给的
* `session.sessionFile`。自己拼路径去读会话文件是错的:编码规则属于 pi。
*
* @param {string} cwd
* @returns {string}
*/
export function sessionDirLabel(cwd) {
return `--${String(cwd ?? '').replace(/\//g, '-')}--`;
}

View File

@ -0,0 +1,145 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
formatAddress,
roleOf,
replyAddressFor,
selfAddressFor,
participantsOfMail,
} from '../lib/addressing.js';
// 地址拼错不会报错,只会投到别处 —— 所以这一组测试全部落在
// 「拼出来的东西还能不能被正确解析回三段」上。
test('formatAddress: 空 path 仍保留 @ 与 .', () => {
// 生产事故:朴素拼接得到 admin.silent-harbor没有 @
// 整串被 ParseAddress 当成名字session 位静默丢失。
assert.equal(formatAddress('admin', '', 'silent-harbor'), 'admin@.silent-harbor');
});
test('formatAddress: 省略 session 位', () => {
assert.equal(formatAddress('dsh', '/home/program/agentmail', ''), 'dsh@/home/program/agentmail');
// 名字与 path 都有但都不带会话 → 默认会话语义
assert.equal(formatAddress('dsh', '', ''), 'dsh');
});
test('formatAddress: path 含 . 与 / 时仍按最后一个 . 切', () => {
// path 里允许 . 与 /,切分靠最后一个 . —— 拼出来的必须满足这个约定
const addr = formatAddress('bot', '/srv/app.v2', 'fix-leak');
assert.equal(addr, 'bot@/srv/app.v2.fix-leak');
assert.equal(addr.slice(addr.lastIndexOf('.') + 1), 'fix-leak');
});
test('formatAddress: 名字为空返回空串而不是残缺地址', () => {
// 返回 "@/path.alias" 会被投递端当成缺名字报错,
// 但那是在很后面才发现;这里直接给空串让调用方立刻看出没法拼。
assert.equal(formatAddress('', '/p', 'a'), '');
assert.equal(formatAddress(null, '/p', 'a'), '');
});
test('formatAddress: 去掉首尾空白', () => {
assert.equal(formatAddress(' dsh ', ' /home ', ' alias '), 'dsh@/home.alias');
});
const ccMail = {
from_name: 'admin',
to_name: 'dsh',
to_workspace: '/home/program/llmsproxy',
cc_list: [{ name: 'opencode', path: '/home', session: 'new', raw: 'opencode@/home.new' }],
session_alias: 'silent-harbor',
};
test('roleOf: 区分主收件人与抄送方', () => {
// 被抄送方与主收件人职责不同:线上那封联调邮件里 dsh 负责汇报、
// opencode 只提供信息。不区分身份两方都会以为自己是负责人。
assert.equal(roleOf(ccMail, 'dsh'), 'to');
assert.equal(roleOf(ccMail, 'opencode'), 'cc');
assert.equal(roleOf(ccMail, 'someone-else'), 'unknown');
});
test('roleOf: 名字为空时不猜', () => {
assert.equal(roleOf(ccMail, ''), 'unknown');
assert.equal(roleOf(ccMail, undefined), 'unknown');
});
test('replyAddressFor: 用会话别名而非原地址的 .new', () => {
// 关键回归:把 .new 原样当回信地址会再建一条平行会话。
const addr = replyAddressFor(ccMail);
assert.equal(addr, 'admin@.silent-harbor');
assert.ok(!addr.endsWith('.new'), '回信地址不得以 .new 结尾');
});
test('replyAddressFor: 发件人一侧不带 path', () => {
// Agent 回信时 from_workspace 存的是 Agent 名而不是路径,
// 拿它拼会得到 dsh@dsh.alias —— 投不出去。
const mail = { from_name: 'dsh', from_workspace: 'dsh', session_alias: 'x' };
assert.equal(replyAddressFor(mail), 'dsh@.x');
});
test('replyAddressFor: 无别名时退回默认会话形式', () => {
const mail = { from_name: 'admin', session_alias: '' };
const addr = replyAddressFor(mail);
assert.equal(addr, 'admin');
// 调用方靠有没有 . 判断这是不是「投回同一条会话」
assert.ok(!addr.includes('.'), '默认会话形式不含 session 位');
});
test('selfAddressFor: 抄送方取自己那个地址的 path', () => {
// to_workspace 是主收件人的工作目录。抄送方拿它当自己的 path
// 「我是谁」这句话就指向了别人的目录。
assert.equal(selfAddressFor(ccMail, 'opencode'), 'opencode@/home.silent-harbor');
assert.equal(selfAddressFor(ccMail, 'dsh'), 'dsh@/home/program/llmsproxy.silent-harbor');
});
test('participantsOfMail: 抄送方的 path 是自己那个', () => {
const parts = participantsOfMail(ccMail, 'dsh');
const byName = Object.fromEntries(parts.map(p => [p.name, p]));
assert.equal(byName.opencode.path, '/home');
assert.equal(byName.opencode.address, 'opencode@/home.silent-harbor');
assert.equal(byName.dsh.path, '/home/program/llmsproxy');
// 发件人 path 留空,理由同 replyAddressFor
assert.equal(byName.admin.address, 'admin@.silent-harbor');
});
test('participantsOfMail: 地址一律用会话别名,不带 .new', () => {
// cc_list 里原本记的是 opencode@/home.new。参与方地址必须换成别名
// 否则「回给抄收方」这个动作每次都会新开会话。
for (const p of participantsOfMail(ccMail, 'dsh')) {
assert.ok(!p.address.endsWith('.new'), `${p.name} 的地址仍是 .new: ${p.address}`);
}
});
test('participantsOfMail: 自己被标记而不是被剔除', () => {
// 剔掉的话模型无法确认这封信是不是也发给了自己,
// 也就无法判断自己该不该回。
const parts = participantsOfMail(ccMail, 'opencode');
const me = parts.find(p => p.name === 'opencode');
assert.ok(me, '自己应出现在参与方列表里');
assert.equal(me.is_self, true);
assert.equal(parts.filter(p => p.is_self).length, 1);
});
test('participantsOfMail: 角色齐全且顺序为 from → to → cc', () => {
// 主收件人稳定排在抄送方之前,模型据此判断谁是负责人、谁是配合方
const parts = participantsOfMail(ccMail, 'dsh');
assert.deepEqual(parts.map(p => p.role), ['from', 'to', 'cc']);
});
test('participantsOfMail: 无抄送时只有两方', () => {
const mail = { from_name: 'admin', to_name: 'dsh', to_workspace: '/w', session_alias: 'a' };
const parts = participantsOfMail(mail, 'dsh');
assert.equal(parts.length, 2);
});
test('participantsOfMail: 跳过空名字条目', () => {
// cc_list 里出现空对象(历史数据或解析残缺)不该产出一个 address 为空的参与方
const mail = {
from_name: 'admin', to_name: 'dsh', to_workspace: '/w',
cc_list: [{ name: '', path: '/x' }, {}],
session_alias: 'a',
};
const parts = participantsOfMail(mail, 'dsh');
assert.equal(parts.length, 2);
for (const p of parts) assert.notEqual(p.address, '');
});

View File

@ -0,0 +1,81 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { MAX_CATCHUP, mailToEvent, selectCatchup } from '../lib/catchup.js';
const mail = (over = {}) => ({
mail_id: 'm1',
session_id: 's1',
from_name: 'admin',
subject: '主题',
mail_type: 'normal',
to_workspace: '/tmp/ws',
...over,
});
test('mailToEvent 产出与 SSE new_mail 同形的对象', () => {
const ev = mailToEvent(mail());
// 投递侧读的就是这几个键,形状不一致会让补拉那条路径静默地少带信息
for (const k of ['mail_id', 'session_id', 'from_name', 'subject', 'mail_type', 'to_workspace']) {
assert.ok(k in ev, `缺少 ${k}`);
}
assert.equal(ev.role, 'to');
assert.equal(ev.catchup, true);
});
test('mailToEvent 对缺字段的行给出空串而非 undefined', () => {
const ev = mailToEvent({});
assert.equal(ev.mail_id, '');
assert.equal(ev.to_workspace, '');
assert.equal(ev.mail_type, 'normal');
});
test('已经通过 SSE 投过的不再补投', () => {
const mails = [mail({ mail_id: 'a' }), mail({ mail_id: 'b' })];
const got = selectCatchup(mails, new Set(['a']));
assert.deepEqual(got.map(e => e.mail_id), ['b']);
});
test('按时间正序补投(收件箱是倒序返回的)', () => {
// 收件箱:新的在前
const mails = [mail({ mail_id: 'new' }), mail({ mail_id: 'mid' }), mail({ mail_id: 'old' })];
const got = selectCatchup(mails, new Set());
assert.deepEqual(
got.map(e => e.mail_id),
['old', 'mid', 'new'],
'先来的邮件必须先处理,否则同一会话里的上下文顺序是乱的',
);
});
test('permission 类邮件不补投', () => {
const mails = [mail({ mail_id: 'p', mail_type: 'permission' }), mail({ mail_id: 'n' })];
const got = selectCatchup(mails, new Set());
assert.deepEqual(got.map(e => e.mail_id), ['n']);
});
test('超过上限的部分留在收件箱里', () => {
const mails = Array.from({ length: MAX_CATCHUP + 4 }, (_, i) => mail({ mail_id: 'm' + i }));
const got = selectCatchup(mails, new Set());
assert.equal(got.length, MAX_CATCHUP, '一次补拉不该把几十封邮件同时放出去');
});
test('上限可显式压到 0用于禁用补拉', () => {
const got = selectCatchup([mail()], new Set(), 0);
assert.deepEqual(got, []);
});
test('空输入与非数组不炸', () => {
assert.deepEqual(selectCatchup([], new Set()), []);
assert.deepEqual(selectCatchup(undefined, new Set()), []);
assert.deepEqual(selectCatchup(null, new Set()), []);
});
test('没有 mail_id 的行跳过', () => {
const got = selectCatchup([mail({ mail_id: '' }), mail({ mail_id: 'ok' })], new Set());
assert.deepEqual(got.map(e => e.mail_id), ['ok']);
});
test('seen 传 undefined 时不去重也不报错', () => {
const got = selectCatchup([mail({ mail_id: 'x' })], undefined);
assert.deepEqual(got.map(e => e.mail_id), ['x']);
});

View File

@ -0,0 +1,218 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
renderNameSuggestions,
renderPathSuggestions,
renderSessionSuggestions,
renderParticipants,
renderContacts,
renderThread,
} from '../lib/discovery.js';
// 这一组渲染的唯一目的是让模型**不要自己拼地址**。
// 所以断言集中在两点:给出的地址能原样使用;以及模型知道下一步该查什么。
test('renderNameSuggestions 只给名字并指向下一步', () => {
// 此时还不知道 path 与 session硬拼裸名字地址会投到「默认会话」——
// 那不一定是调用方想要的那条。
const got = renderNameSuggestions(['opencode', 'admin']);
assert.match(got, /opencode/);
assert.match(got, /admin/);
assert.match(got, /suggest_address/, '要告诉模型下一步查什么');
});
test('renderNameSuggestions 空列表给明确文案', () => {
assert.match(renderNameSuggestions([]), /没有可投递的收件人/);
assert.match(renderNameSuggestions(undefined), /没有可投递的收件人/);
});
test('renderPathSuggestions 空列表要说清「仍然能发」', () => {
// 不解释的话模型会卡在这一步,或者编一个路径出来。
const got = renderPathSuggestions([], 'admin');
assert.match(got, /可以留空/);
assert.match(got, /admin/);
});
test('renderPathSuggestions 列出目录并指向下一步', () => {
const got = renderPathSuggestions(['/home', '/home/program/agentmail'], 'opencode');
assert.match(got, /\/home\/program\/agentmail/);
assert.match(got, /最近使用/);
assert.match(got, /suggest_address\(name="opencode", path="/);
});
const sessionData = {
kind: 'session',
suggestions: ['silent-harbor', 'happy-tiger', 'new'],
addresses: [
'opencode@/home.silent-harbor',
'opencode@/home.happy-tiger',
'opencode@/home.new',
],
candidates: [
{ alias: 'silent-harbor', title: '联调 llmsproxy', source: 'mail', unread: 2 },
{ alias: 'happy-tiger', title: '补投验证', source: 'mail', unread: 0 },
{ alias: 'new', title: '新建会话', source: 'new' },
],
};
test('renderSessionSuggestions 用服务端拼好的完整地址', () => {
// 插件自己拼过一次,拼错了(空 path 时漏掉 @。addresses 与 suggestions
// 同序由服务端保证,直接用。
const got = renderSessionSuggestions(sessionData, 'opencode', '/home');
assert.match(got, /opencode@\/home\.silent-harbor/);
assert.match(got, /opencode@\/home\.happy-tiger/);
assert.match(got, /原样填进 send_mail 的 to/);
});
test('renderSessionSuggestions 带出标题与未读数', () => {
const got = renderSessionSuggestions(sessionData, 'opencode', '/home');
assert.match(got, /联调 llmsproxy/);
assert.match(got, /2 封未读/);
});
test('不变量new 不与已存在会话混列,且带警告', () => {
// new 排在前面会让模型在想续谈时顺手开出一条新线索 —— 生产上已经发生过。
const got = renderSessionSuggestions(sessionData, 'opencode', '/home');
const lines = got.split('\n');
const newLineIdx = lines.findIndex(l => l.includes('.new'));
const harborIdx = lines.findIndex(l => l.includes('silent-harbor'));
assert.ok(harborIdx >= 0 && newLineIdx > harborIdx, 'new 必须排在已存在会话之后');
assert.match(got, /新\*\*线索|新\*\*/, 'new 要带「这是开新线索」的提示');
});
test('renderSessionSuggestions 无已存在会话时引导命名', () => {
// 这是关键引导:开新会话时传 session_alias之后才能按名字续谈。
// 不传的话服务端会自动命名,但模型不知道那个名字。
const got = renderSessionSuggestions(
{ suggestions: ['new'], addresses: ['dsh@/tmp.new'], candidates: [{ alias: 'new', source: 'new' }] },
'dsh', '/tmp',
);
assert.match(got, /还没有可续谈的会话/);
assert.match(got, /session_alias/);
});
const participantData = {
session_id: 'f3d824ce',
session_alias: 'silent-harbor',
participants: [
{ name: 'admin', path: '', roles: ['from'], is_self: false, mail_count: 1, address: 'admin@.silent-harbor' },
{ name: 'dsh', path: '/home/program/llmsproxy', roles: ['to'], is_self: true, mail_count: 0, address: 'dsh@/home/program/llmsproxy.silent-harbor' },
{ name: 'opencode', path: '/home', roles: ['cc'], is_self: false, mail_count: 0, address: 'opencode@/home.silent-harbor' },
],
};
test('renderParticipants 给出每个参与方的地址', () => {
const got = renderParticipants(participantData);
assert.match(got, /opencode@\/home\.silent-harbor/);
assert.match(got, /admin@\.silent-harbor/);
assert.match(got, /原样填进 send_mail 的 to/);
});
test('不变量:标出「尚未回应」的人', () => {
// mail_count 为 0 就是还没开口的人。服务端只数「作为发件人」的邮件,
// 正是为了让这个判断成立。
const got = renderParticipants(participantData);
const line = got.split('\n').find(l => l.includes('opencode'));
assert.match(line, /尚未回应/);
// 自己不该被标「尚未回应」—— 自己正在处理这封
const selfLine = got.split('\n').find(l => l.includes('dsh'));
assert.ok(!selfLine.includes('尚未回应'));
assert.match(selfLine, /就是你/);
});
test('renderParticipants 用中文角色标签', () => {
// 模型读到「抄送方」比读到 cc 更容易判对分工。
const got = renderParticipants(participantData);
assert.match(got, /抄送方/);
assert.match(got, /发件人/);
});
test('renderParticipants 无地址时说明原因', () => {
const got = renderParticipants({
session_alias: '',
participants: [{ name: 'x', roles: ['to'], mail_count: 0, address: '' }],
});
assert.match(got, /尚未命名/);
});
test('renderParticipants 空会话不崩', () => {
assert.match(renderParticipants({ participants: [] }), /还没有参与方/);
assert.match(renderParticipants({}), /还没有参与方/);
});
test('renderContacts 未读优先排序', () => {
// 模型问「我还有什么没处理」时,有未读的那些才是答案。
const got = renderContacts({
contacts: [
{ address: 'a@.x', unread_count: 0, last_activity: '2026-09-03T02:00:00Z' },
{ address: 'b@.y', unread_count: 3, last_activity: '2026-09-01T00:00:00Z' },
],
});
const lines = got.split('\n').filter(l => l.startsWith('- '));
assert.match(lines[0], /b@\.y/, '有未读的应排在最前');
assert.match(lines[0], /3 封未读/);
});
test('renderContacts 带出剩余预算', () => {
const got = renderContacts({
contacts: [{ address: 'a@.x', unread_count: 0, max_rounds: 20, used_rounds: 17 }],
});
assert.match(got, /剩 3\/20 个来回/);
});
test('renderContacts 未命名会话说明只能 reply_to', () => {
const got = renderContacts({ contacts: [{ address: '', unread_count: 1 }] });
assert.match(got, /reply_to/);
});
test('renderContacts 空列表', () => {
assert.match(renderContacts({ contacts: [] }), /还没有任何往来会话/);
});
const threadData = {
anchor_mail_id: 'm-2',
total: 3,
hidden: 1,
nodes: [
{ mail_id: 'm-1', from_name: 'admin', to_name: 'dsh', subject: '抄收联调', depth: 0 },
{ mail_id: 'm-2', from_name: 'dsh', to_name: 'opencode', subject: '[联调] 请提供部署现状', depth: 1 },
{ mail_id: 'm-3', from_name: 'opencode', to_name: 'dsh', subject: 'Re: 联调', depth: 2, detached: true, parent_hidden: true },
],
};
test('renderThread 用缩进表示层级', () => {
const got = renderThread(threadData, 'dsh');
const lines = got.split('\n');
const l1 = lines.find(l => l.includes('m-1'));
const l2 = lines.find(l => l.includes('m-2'));
assert.ok(l2.indexOf('- ') > l1.indexOf('- '), '子节点应更深缩进');
});
test('不变量detached 必须标出来', () => {
// 不标的话模型会以为这是一条独立线索,而它其实挂在一封看不到的邮件下面。
const got = renderThread(threadData, 'dsh');
const line = got.split('\n').find(l => l.includes('m-3'));
assert.match(line, /父邮件无权查看/);
});
test('renderThread 标出自己发的与当前这封', () => {
const got = renderThread(threadData, 'dsh');
assert.match(got.split('\n').find(l => l.includes('m-2')), /你发的/);
assert.match(got.split('\n').find(l => l.includes('m-2')), /当前这封/);
});
test('renderThread 报告不可见数量', () => {
// 「共 3 封」与实际列出 3 条一致,但另有 1 封无权查看 ——
// 不说的话模型会以为自己看到了全貌。
assert.match(renderThread(threadData), /另有 1 封无权查看/);
});
test('renderThread 有更多时给出 offset', () => {
const got = renderThread({ ...threadData, has_more: true, next_offset: 60 });
assert.match(got, /offset=60/);
});
test('renderThread 空线索不崩', () => {
assert.match(renderThread({ nodes: [] }), /没有可见的邮件/);
assert.match(renderThread({}), /没有可见的邮件/);
});

View File

@ -0,0 +1,266 @@
/**
* 收件箱渲染与已读策略的测试。
*
* 每条断言都对应一次真实的错误行为(见 lib/inbox-format.js 里的注释):
* 漏掉 attachment_id 模型就无从下载附件;漏掉抄送它会以为这是私信;
* status=all 时标记已读会让下一轮的新邮件混在历史里认不出来。
*
* node --test 'test/*.test.mjs'
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
formatSize,
renderMail,
renderInbox,
idsToMarkRead,
DEFAULT_INBOX_STATUS,
DEFAULT_INBOX_LIMIT,
} from '../lib/inbox-format.js';
const mail = (over = {}) => ({
mail_id: 'm-1',
from_name: 'admin',
subject: '缓存选型',
status: 'unread',
session_alias: 'brisk-harbor',
body_preview: '我们需要评估一下缓存层',
...over,
});
// ─── formatSize ───
test('formatSize 分档', () => {
assert.equal(formatSize(512), '512 B');
assert.equal(formatSize(2048), '2.0 KB');
assert.equal(formatSize(3 * 1024 * 1024), '3.0 MB');
});
test('formatSize 容错', () => {
assert.equal(formatSize(undefined), '?');
assert.equal(formatSize(NaN), '?');
assert.equal(formatSize('x'), '?');
});
// ─── renderMail ───
test('renderMail 带出 mail_id 与会话别名', () => {
const got = renderMail(mail());
assert.match(got, /邮件 ID: m-1/);
assert.match(got, /#brisk-harbor/);
assert.match(got, /admin: 缓存选型/);
});
test('无别名时显示「未命名」而不是空', () => {
const got = renderMail(mail({ session_alias: '' }));
assert.match(got, /#未命名/);
});
test('不变量:附件必须带 attachment_id', () => {
// 只说「有附件」模型就无从下载 —— download_attachment 要的正是这个 id。
const got = renderMail(mail({
attachments: [{ filename: 'report.md', size_bytes: 2048, attachment_id: 'att-9' }],
}));
assert.match(got, /id=att-9/, `附件行缺 id${got}`);
assert.match(got, /report\.md/);
assert.match(got, /2\.0 KB/);
assert.match(got, /download_attachment/, '要提示模型用哪个工具下载');
});
test('多个附件都列出来', () => {
const got = renderMail(mail({
attachments: [
{ filename: 'a.md', size_bytes: 10, attachment_id: 'att-1' },
{ filename: 'b.md', size_bytes: 20, attachment_id: 'att-2' },
],
}));
assert.match(got, /att-1/);
assert.match(got, /att-2/);
});
test('不变量:抄送人要显示出来', () => {
// 不显示的话模型会以为这是私下发给它一个人的,回信时漏掉其他参与方。
const got = renderMail(mail({
cc_list: [{ name: 'opencode', raw: 'opencode@/home.new' }],
}));
assert.match(got, /抄送/);
assert.match(got, /opencode@\/home\.new/, '应优先用 raw带路径与会话段');
});
test('无抄送时不出现抄送行', () => {
assert.ok(!renderMail(mail()).includes('抄送'));
assert.ok(!renderMail(mail({ cc_list: [] })).includes('抄送'));
});
test('正文优先取 body_preview缺失时退回 body', () => {
assert.match(renderMail(mail({ body_preview: '预览', body: '全文' })), /内容: 预览/);
assert.match(renderMail(mail({ body_preview: '', body: '全文' })), /内容: 全文/);
});
test('正文按 bodyLimit 截断', () => {
const got = renderMail(mail({ body_preview: 'x'.repeat(500) }), 50);
const line = got.split('\n').find(l => l.startsWith('内容: '));
assert.equal(line.length, '内容: '.length + 50);
});
test('renderMail 容错:字段全缺不崩', () => {
const got = renderMail({});
assert.match(got, /unknown/);
const got2 = renderMail(undefined);
assert.equal(typeof got2, 'string');
});
test('附件字段不是数组时忽略', () => {
const got = renderMail(mail({ attachments: 'oops', cc_list: 'oops' }));
assert.ok(!got.includes('附件:'));
assert.ok(!got.includes('抄送'));
});
// ─── 收件人与身份(只有知道自己是谁才能判定)───
test('不变量:收件人要显示出来', () => {
// 不显示的后果:被抄送方不知道主收件人是谁,无法向对方转达或汇报。
// 线上那封联调邮件要求「由收件人汇报」,而抄送方看不到收件人叫什么。
const got = renderMail(mail({ to_name: 'dsh', to_workspace: '/home/program/llmsproxy' }));
assert.match(got, /收件人: dsh@\/home\/program\/llmsproxy/);
});
test('收件人无工作目录时只显名字', () => {
const got = renderMail(mail({ to_name: 'admin', to_workspace: '' }));
assert.match(got, /收件人: admin$/m);
});
test('不传 selfName 时不出现身份行(兼容旧调用)', () => {
const got = renderMail(mail({ to_name: 'dsh' }));
assert.ok(!got.includes('你的身份'));
});
test('不变量:区分收件人与抄送方身份', () => {
// 两者职责不同。不区分的话两方都会以为自己是负责人,
// 或者都以为自己只是旁观者。
const m = mail({
to_name: 'dsh',
to_workspace: '/home/program/llmsproxy',
cc_list: [{ name: 'opencode', path: '/home', raw: 'opencode@/home.new' }],
});
assert.match(renderMail(m, 200, 'dsh'), /你的身份: 收件人/);
assert.match(renderMail(m, 200, 'opencode'), /你的身份: 抄送方/);
// 不相关的名字不编造身份
assert.ok(!renderMail(m, 200, 'someone').includes('你的身份'));
});
// ─── 可投递地址(「精准发信」的关键)───
const joint = () => mail({
mail_id: 'm-7',
from_name: 'admin',
to_name: 'dsh',
to_workspace: '/home/program/llmsproxy',
cc_list: [{ name: 'opencode', path: '/home', session: 'new', raw: 'opencode@/home.new' }],
session_alias: 'silent-harbor',
});
test('不变量:给出每个参与方的可投递地址', () => {
// 之前模型只能从抄送行里拄一个 `opencode@/home.new`
// 而那个地址回过去只会再建一条平行会话。
const got = renderMail(joint(), 200, 'dsh');
assert.match(got, /可投递地址/);
assert.match(got, /opencode@\/home\.silent-harbor抄送方/);
assert.match(got, /admin@\.silent-harbor发件人/);
});
test('不变量:可投递地址里绝不出现 .new', () => {
// 这是本轮修的根因的直接回归:`.new` 是一次性动作,
// 把它当回信地址会让双方各说各话。
const got = renderMail(joint(), 200, 'dsh');
const line = got.split('\n').find(l => l.startsWith('可投递地址'));
assert.ok(line, '应有可投递地址行');
assert.ok(!line.includes('.new'), `地址行仍含 .new: ${line}`);
});
test('可投递地址不列自己', () => {
const got = renderMail(joint(), 200, 'dsh');
const line = got.split('\n').find(l => l.startsWith('可投递地址'));
assert.ok(!line.includes('dsh@'), `不该把自己当成收件人选项: ${line}`);
});
test('同时给出 reply_to 这条更稳的路', () => {
// 地址可能拼错reply_to 不会 —— 两条路都告诉模型。
const got = renderMail(joint(), 200, 'dsh');
assert.match(got, /reply_to=m-7/);
});
test('无会话别名时不给地址(宁可不给不可给错)', () => {
// 别名为空时拼不出「投回这条会话」的地址。给一个看着能用
// 实际指向默认会话的地址,比不给危险。
const got = renderMail(mail({
to_name: 'dsh', session_alias: '',
cc_list: [{ name: 'opencode', path: '/home' }],
}), 200, 'dsh');
assert.ok(!got.includes('可投递地址'));
});
test('renderInbox 透传 selfName', () => {
const got = renderInbox([joint()], 200, 'opencode');
assert.match(got, /你的身份: 抄送方/);
assert.match(got, /dsh@\/home\/program\/llmsproxy\.silent-harbor收件人/);
});
// ─── renderInbox ───
test('renderInbox 空收件箱给明确文案', () => {
assert.equal(renderInbox([]), '收件箱为空。');
assert.equal(renderInbox(undefined), '收件箱为空。');
assert.equal(renderInbox(null), '收件箱为空。');
});
test('renderInbox 用空行分隔多封', () => {
const got = renderInbox([mail({ mail_id: 'a' }), mail({ mail_id: 'b' })]);
assert.match(got, /邮件 ID: a[\s\S]*\n\n[\s\S]*邮件 ID: b/);
});
// ─── idsToMarkRead ───
test('不变量:只标本次列出的那些', () => {
// limit 之外的还没看过,一并标掉等于让它们凭空消失。
const ids = idsToMarkRead('unread', [mail({ mail_id: 'a' }), mail({ mail_id: 'b' })]);
assert.deepEqual(ids, ['a', 'b']);
});
test('不变量status=all 时不标记', () => {
// 那是「回顾历史」的读法。把历史邮件标成已读会让下一轮真正的新邮件
// 混在里面认不出来。
assert.deepEqual(idsToMarkRead('all', [mail({ mail_id: 'a' })]), []);
});
test('status 省略时按默认unread标记', () => {
assert.deepEqual(idsToMarkRead(undefined, [mail({ mail_id: 'a' })]), ['a']);
});
test('idsToMarkRead 过滤掉无 id 的条目', () => {
const ids = idsToMarkRead('unread', [
mail({ mail_id: 'a' }),
mail({ mail_id: '' }),
mail({ mail_id: undefined }),
{ },
]);
assert.deepEqual(ids, ['a']);
});
test('idsToMarkRead 容错非数组', () => {
assert.deepEqual(idsToMarkRead('unread', undefined), []);
assert.deepEqual(idsToMarkRead('unread', 'oops'), []);
});
// ─── 默认值 ───
test('默认只看未读', () => {
// 默认 all 会让模型每轮重读旧邮件,把处理过的和新来的混在一起。
assert.equal(DEFAULT_INBOX_STATUS, 'unread');
});
test('默认条数是个小数字', () => {
// 收件箱一次给几十封会把上下文塞满,而模型一轮通常只处理一两封。
assert.ok(DEFAULT_INBOX_LIMIT > 0 && DEFAULT_INBOX_LIMIT <= 10);
});

View File

@ -0,0 +1,248 @@
/**
* 模型范围与降级尝试的测试。
*
* 最要紧的一条:范围为空时必须返回 `[undefined]`(试一次平台默认)而不是 `[]`。
* 返回空数组会让调用方一次都不试,等于「管理员没配」就把 Agent 彻底哑掉。
*
* node --test 'test/*.test.mjs'
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
snapshotOpencodeModels,
snapshotDshModels,
snapshotPiModels,
modelAttemptOrder,
renderFailureReport,
MAX_CATALOG,
} from '../lib/model-scope.js';
// ─── opencode 目录 ───
const ocConfig = {
providers: [
{
id: 'llmsproxy',
models: {
AUTO: { name: 'AUTO (smart routing)' },
'claude-sonnet-4-6': { name: 'claude-sonnet-4-6' },
},
},
{ id: 'huawei', models: { 'deepseek-v4-flash': { name: 'dpkv4' } } },
],
};
test('opencode 目录拍平 provider × model', () => {
const got = snapshotOpencodeModels(ocConfig);
assert.equal(got.length, 3);
assert.deepEqual(got[0], {
provider: 'llmsproxy',
model: 'AUTO',
display_name: 'AUTO (smart routing)',
});
});
test('models 是对象而非数组(键是 model id', () => {
// 实测 opencode 的 /config/providers 返回 { models: { "AUTO": {...} } }。
// 当成数组处理会得到零条目而不是报错。
const got = snapshotOpencodeModels(ocConfig);
assert.ok(got.some(m => m.model === 'claude-sonnet-4-6'));
});
test('无 id 的 provider 被跳过', () => {
const got = snapshotOpencodeModels({
providers: [{ models: { a: {} } }, { id: 'ok', models: { b: {} } }],
});
assert.equal(got.length, 1);
assert.equal(got[0].provider, 'ok');
});
test('缺 name 时 display_name 为空串而不是 undefined', () => {
const got = snapshotOpencodeModels({ providers: [{ id: 'p', models: { m: {} } }] });
assert.equal(got[0].display_name, '');
});
test('opencode 目录容错:结构缺失不崩', () => {
assert.deepEqual(snapshotOpencodeModels(undefined), []);
assert.deepEqual(snapshotOpencodeModels({}), []);
assert.deepEqual(snapshotOpencodeModels({ providers: 'oops' }), []);
assert.deepEqual(snapshotOpencodeModels({ providers: [{ id: 'p', models: null }] }), []);
});
// ─── DSH 目录 ───
test('DSH 目录用 provider + id', () => {
const got = snapshotDshModels([
{ provider: 'llmsproxy', id: 'AUTO', name: 'AUTO' },
{ provider: 'deepseek', id: 'chat', name: 'DeepSeek Chat' },
]);
assert.equal(got.length, 2);
assert.deepEqual(got[1], { provider: 'deepseek', model: 'chat', display_name: 'DeepSeek Chat' });
});
test('DSH 目录跳过缺 provider 或 id 的条目', () => {
const got = snapshotDshModels([
{ provider: '', id: 'x' },
{ provider: 'p', id: '' },
{ provider: 'p', id: 'ok' },
]);
assert.equal(got.length, 1);
assert.equal(got[0].model, 'ok');
});
test('重复的 provider/model 组合去重', () => {
const got = snapshotDshModels([
{ provider: 'p', id: 'm', name: '第一次' },
{ provider: 'p', id: 'm', name: '第二次' },
]);
assert.equal(got.length, 1);
assert.equal(got[0].display_name, '第一次');
});
test('目录截断到 MAX_CATALOG', () => {
const many = Array.from({ length: MAX_CATALOG + 20 }, (_, i) => ({
provider: 'p', id: `m${i}`, name: `M${i}`,
}));
assert.equal(snapshotDshModels(many).length, MAX_CATALOG);
});
// ─── pi 目录 ───
test('pi 目录用 provider + id', () => {
const got = snapshotPiModels([
{ provider: 'llmsproxy', id: 'AUTO', name: 'AUTO' },
{ provider: 'anthropic', id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6' },
]);
assert.equal(got.length, 2);
assert.deepEqual(got[1], {
provider: 'anthropic',
model: 'claude-sonnet-4-6',
display_name: 'Claude Sonnet 4.6',
});
});
test('pi 目录跳过缺 provider 或 id 的条目', () => {
const got = snapshotPiModels([
{ provider: '', id: 'x' },
{ provider: 'p' },
{ provider: 'p', id: 'ok' },
]);
assert.equal(got.length, 1);
assert.equal(got[0].model, 'ok');
});
test('pi 目录容错:非数组不崩', () => {
assert.deepEqual(snapshotPiModels(undefined), []);
assert.deepEqual(snapshotPiModels(null), []);
assert.deepEqual(snapshotPiModels('oops'), []);
});
test('pi 目录同样受 MAX_CATALOG 截断', () => {
// 本机 pi 的完整目录有 1221 个模型getModels远超上限。
// 桥实际上报的是 getAvailable() 的结果(只有带凭证的),但截断仍要生效。
const many = Array.from({ length: MAX_CATALOG + 50 }, (_, i) => ({
provider: 'p', id: `m${i}`, name: `M${i}`,
}));
assert.equal(snapshotPiModels(many).length, MAX_CATALOG);
});
// ─── modelAttemptOrder ───
test('管理员划定范围时按 rank 顺序尝试', () => {
const got = modelAttemptOrder(
[{ provider: 'a', model: '1' }, { provider: 'b', model: '2' }],
{ provider: 'env', model: 'x' }
);
assert.deepEqual(got, [
{ provider: 'a', model: '1' },
{ provider: 'b', model: '2' },
]);
});
test('不变量:范围为空时返回 [undefined] 而不是 []', () => {
// 返回空数组会让调用方一次都不试 —— 「管理员没配」的正确含义是不限定,
// 不是「一个都不许用」。后者等于让 Agent 彻底哑掉。
const got = modelAttemptOrder([], undefined);
assert.equal(got.length, 1, `应有一次尝试,实际 ${got.length}`);
assert.equal(got[0], undefined, 'undefined 表示交给平台自己选');
});
test('范围为空但有环境变量时用环境变量', () => {
const got = modelAttemptOrder([], { provider: 'llmsproxy', model: 'AUTO' });
assert.deepEqual(got, [{ provider: 'llmsproxy', model: 'AUTO' }]);
});
test('不变量:范围优先于环境变量', () => {
// 范围是运行时可改的策略,环境变量是部署时的兜底。
// 反过来的话管理员在配置页改了范围却不生效,得去改 service 文件重启。
const got = modelAttemptOrder(
[{ provider: 'chosen', model: 'm' }],
{ provider: 'env', model: 'x' }
);
assert.equal(got.length, 1);
assert.equal(got[0].provider, 'chosen');
});
test('过滤掉范围里字段不全的项', () => {
const got = modelAttemptOrder(
[{ provider: 'a', model: '' }, { provider: '', model: '1' }, { provider: 'ok', model: 'm' }],
undefined
);
assert.deepEqual(got, [{ provider: 'ok', model: 'm' }]);
});
test('环境变量只给一半时不采用', () => {
assert.deepEqual(modelAttemptOrder([], { provider: 'p' }), [undefined]);
assert.deepEqual(modelAttemptOrder([], { model: 'm' }), [undefined]);
});
test('modelAttemptOrder 容错非数组', () => {
assert.deepEqual(modelAttemptOrder(undefined, undefined), [undefined]);
assert.deepEqual(modelAttemptOrder('oops', undefined), [undefined]);
});
// ─── renderFailureReport ───
test('失败报告列出每次尝试的路由与原因', () => {
const got = renderFailureReport(
[
{ provider: 'llmsproxy', model: 'AUTO', error: '429 Too Many Requests' },
{ provider: 'huawei', model: 'dpk', error: 'connect ECONNREFUSED' },
],
'缓存选型'
);
assert.match(got, /缓存选型/);
assert.match(got, /已尝试 2 个/);
assert.match(got, /llmsproxy\/AUTO/);
assert.match(got, /429 Too Many Requests/);
assert.match(got, /huawei\/dpk/);
assert.match(got, /ECONNREFUSED/);
});
test('没有路由信息时标为平台默认模型', () => {
const got = renderFailureReport([{ error: 'boom' }], '主题');
assert.match(got, /平台默认模型/);
});
test('失败报告给出可操作的下一步', () => {
// 只报错误不说怎么办,收信的人只能来问。
const got = renderFailureReport([{ error: 'x' }], '主题');
assert.match(got, /配置页/);
});
test('多行报错缩进后不破坏 Markdown 排版', () => {
const got = renderFailureReport([{ error: 'line1\nline2' }], '主题');
// 第二行也要带缩进,否则会脱离代码块、其中的字符被当作 Markdown 解析
assert.match(got, / line1\n line2/);
});
test('空主题有兜底', () => {
assert.match(renderFailureReport([{ error: 'x' }], ''), /\(无主题\)/);
assert.match(renderFailureReport([{ error: 'x' }], undefined), /\(无主题\)/);
});
test('renderFailureReport 容错非数组', () => {
const got = renderFailureReport(undefined, '主题');
assert.match(got, /已尝试 0 个/);
});

View File

@ -0,0 +1,185 @@
/**
* 会话命名一致性的测试。
*
* 这是 pi 接入里最容易做错的一块,因为「两边各自命名然后指望撞上」看起来能用:
* 单条会话、不撞名、没人手工改过名时,两边确实一致。上面任何一条不成立就分叉。
*
* 因此这里的每个 test 都对应一条**分叉场景**。
*
* node --test 'test/*.test.mjs'
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { planNamingSync, planWriteBack } from '../src/naming.mjs';
// ─── 向 Gateway 提交W-7───
test('平台名字正常时派生别名并原样带标题', () => {
const got = planNamingSync({ platformName: '排查连接泄漏', mailSubject: '别用我', lastSynced: '' });
assert.equal(got.skip, false);
assert.equal(got.alias, '排查连接泄漏');
assert.equal(got.title, '排查连接泄漏');
assert.equal(got.source, 'platform');
});
test('不变量:标题原样提交,不清洗不派生', () => {
// I-4插件只搬运。标题那一列不负责寻址没有字符限制
// 改写它等于让邮箱里显示的和 pi 里显示的是两个东西。
const name = 'Fix: cache TTL (v2) — 缓存/过期';
const got = planNamingSync({ platformName: name, mailSubject: '', lastSynced: '' });
assert.equal(got.title, name);
// 别名要按寻址规则剥掉分隔符
assert.doesNotMatch(got.alias, /[.@/]/);
});
test('不变量:内容没变就不重复提交', () => {
// setSessionName 会触发 session_info_changed钩子又去 sync
// 不判「与上次相同」就是自激循环 —— 每 30 秒刷一次 Gateway。
const first = planNamingSync({ platformName: '同一个名字', mailSubject: '', lastSynced: '' });
assert.equal(first.skip, false);
const again = planNamingSync({ platformName: '同一个名字', mailSubject: '', lastSynced: first.signature });
assert.equal(again.skip, true);
assert.equal(again.reason, 'unchanged');
});
test('不变量pi 没有名字时退到邮件主题SDK 路径的常态)', () => {
// 桥用 SDK 起的会话不经过 pi-web 的标题生成器sessionName 一直是 undefined。
// 只等平台命名的话别名永远是空的,`name@path.<别名>` 续谈无从下手 ——
// 第一次端到端跑通时就是这个结果sessions.session_alias 是空串)。
const got = planNamingSync({ platformName: undefined, mailSubject: '主链路验证', lastSynced: '' });
assert.equal(got.skip, false);
assert.equal(got.alias, '主链路验证');
assert.equal(got.source, 'mail-subject');
});
test('不变量:无名字 + 主题未变时也要判 unchanged', () => {
// 用平台名字本身充当「上次提交了什么」的记录时,名字为空就无法区分
// 「还没提交过」与「提交过、内容没变」,于是每轮心跳都白打一次 sync。
const first = planNamingSync({ platformName: '', mailSubject: '固定主题', lastSynced: '' });
const again = planNamingSync({ platformName: '', mailSubject: '固定主题', lastSynced: first.signature });
assert.equal(again.skip, true, '空名字场景同样要能判出「没变化」');
});
test('思维链泄漏的名字退到邮件主题派生别名', () => {
// pi-web 的标题生成器不防这个cleanSessionName 只取首行 + 截 60 字符)。
// 本机 81 条会话里实测捞到过这条。
const got = planNamingSync({
platformName: 'The user is asking me to generate a title for a coding-agent',
mailSubject: '排查连接泄漏',
lastSynced: '',
});
assert.equal(got.skip, false);
assert.equal(got.alias, '排查连接泄漏');
assert.equal(got.source, 'mail-subject');
});
test('不变量:退到邮件主题时不写标题', () => {
// 标题那一列的语义是「平台生成的会话标题」。把邮件主题填进去会让
// 邮箱里看起来像是 pi 生成了这个标题,而 pi 侧其实是另一个名字(或没有)。
const got = planNamingSync({
platformName: '我们只需要生成标题,不包含其他内容。标题应反映请求内容:测试。简短:测试。或者更简',
mailSubject: '压测报告',
lastSynced: '',
});
assert.equal(got.title, '');
assert.equal(got.alias, '压测报告');
});
test('不变量无可用名字时什么都不写W-7.2', () => {
// 宁可让会话保持无别名session_alias 允许 NULL也不要写 "session-123"
// 这种占位值 —— 它对人毫无指代作用,而且一旦落库就把 alias 位占住了,
// 真正的名字来了只能追 -2 后缀。
const got = planNamingSync({ platformName: '', mailSubject: '', lastSynced: '' });
assert.equal(got.skip, true);
assert.equal(got.reason, 'no-usable-name');
});
test('纯符号名字:写标题但不写别名', () => {
// slugFromTitle('...') 是空串作为别名非法Gateway 会 400
// 但这个名字本身是平台产出,标题列该照实反映。
const got = planNamingSync({ platformName: '...', mailSubject: '', lastSynced: '' });
assert.equal(got.skip, false);
assert.equal(got.alias, '');
assert.equal(got.title, '...');
assert.equal(got.source, 'platform-title-only');
});
test('主题也派生不出别名时不写', () => {
const got = planNamingSync({ platformName: 'The user is asking me to', mailSubject: '@@@', lastSynced: '' });
assert.equal(got.skip, true);
});
// ─── 回写进 piD-5 / 一致性的关键)───
test('不变量:撞名后缀必须回写进 pi', () => {
// Gateway 侧别名负有寻址唯一性义务partial unique index撞名自动追 -2。
// pi 侧没有这个约束。不回写的话:邮箱里是 fix-leak-2、pi-web 里是 fix-leak
// 用户按界面上看到的名字发信会 404。
const got = planWriteBack({ finalAlias: 'fix-leak-2', currentPiName: 'fix-leak' });
assert.equal(got.write, true);
assert.equal(got.name, 'fix-leak-2');
assert.equal(got.reason, 'diverged');
});
test('不变量manual 别名(人手工改过)优先,回写进 pi', () => {
// SyncSessionAlias 遇到 alias_source='manual' 时不覆盖,**原样返回当前别名**。
// 于是「人在 AgentMail 界面上定的名字」赢pi 侧要跟着改 —— 这是有意的:
// 人的意图优先于模型生成的标题。
const got = planWriteBack({ finalAlias: '紧急排查', currentPiName: 'connection-leak' });
assert.equal(got.write, true);
assert.equal(got.name, '紧急排查');
});
test('规范化改写过的别名也要回写', () => {
// normalizeAlias 把 . / @ 空白换成 -。提议 "a.b c" 会变成 "a-b-c"。
const got = planWriteBack({ finalAlias: 'a-b-c', currentPiName: 'a.b c' });
assert.equal(got.write, true);
assert.equal(got.name, 'a-b-c');
});
test('两边已经一致就不回写', () => {
// 回写会 append 一条 session_info 并触发 session_info_changed。
// 无条件回写 = 每轮多一条无意义的历史条目 + 一次多余的 sync。
const got = planWriteBack({ finalAlias: 'fix-leak', currentPiName: 'fix-leak' });
assert.equal(got.write, false);
assert.equal(got.reason, 'already-equal');
});
test('pi 侧还没有名字时也要回写', () => {
// 桥用 SDK 起的会话没有名字pi-web 的生成器不在这条链路上),
// 此时 Gateway 定稿的别名就是这条会话的第一个名字。
const got = planWriteBack({ finalAlias: 'fix-leak', currentPiName: undefined });
assert.equal(got.write, true);
assert.equal(got.reason, 'pi-unnamed');
});
test('不变量:响应没带别名时不回写', () => {
// 本次只同步了标题planNamingSync 的 platform-title-only 分支)→ 没有定稿值。
// 拿空串去 setSessionName 是**清除**语义(实测 appendSessionInfo(" ")
// 之后 getSessionName() 变 undefined会把 pi 侧原有的名字抹掉。
assert.equal(planWriteBack({ finalAlias: '', currentPiName: 'keep-me' }).write, false);
assert.equal(planWriteBack({ finalAlias: undefined, currentPiName: 'keep-me' }).write, false);
assert.equal(planWriteBack({ finalAlias: ' ', currentPiName: 'keep-me' }).write, false);
});
// ─── 端到端的一致性推理 ───
test('完整链路:提议 → 撞名定稿 → 回写 → 再观测不再动', () => {
// 这个 test 钉住「不会自激循环」这条性质,它是分四步的:
// 1. pi 有了名字 fix-leak提交
// 2. Gateway 撞名,定稿 fix-leak-2
// 3. 回写进 pipi 的名字变成 fix-leak-2
// 4. session_info_changed 再次触发 → 必须 skip否则无限循环
const step1 = planNamingSync({ platformName: 'fix-leak', mailSubject: '', lastSynced: '' });
assert.equal(step1.alias, 'fix-leak');
const step3 = planWriteBack({ finalAlias: 'fix-leak-2', currentPiName: 'fix-leak' });
assert.equal(step3.write, true);
// 桥在回写后把指纹更新成「定稿别名当作平台名字」会算出的那个值,
// 因此第 4 步(回写触发的事件)看到的指纹与它相同。
const afterWriteBack = `platform:${step3.name}|${step3.name}`;
const step4 = planNamingSync({ platformName: 'fix-leak-2', mailSubject: '', lastSynced: afterWriteBack });
assert.equal(step4.skip, true, '回写触发的事件必须被指纹挡住,否则无限循环');
});

View File

@ -0,0 +1,326 @@
/**
* 平台会话快照的纯函数测试。
*
* 这些函数的产出直接决定「写信时能不能选到某条会话」slug 错了就填出一个
* 送不到的 session 位(三态语义下会 404workspace 错了就归到别的工作区去。
*
* node --test test/
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
snapshotOpencodeSessions,
snapshotDshSessions,
snapshotPiSessions,
isUnusableName,
slugFromTitle,
MAX_REPORTED,
} from '../lib/session-snapshot.js';
// ─── opencode ───
const ocSession = (over = {}) => ({
id: 'ses_abc',
slug: 'witty-planet',
title: '重构导入路径',
directory: '/home/program/agentmail',
path: '',
time: { created: 1788300000000, updated: 1788344476744 },
...over,
});
test('opencode 快照取 directory 作为 workspace', () => {
const [got] = snapshotOpencodeSessions([ocSession()]);
assert.equal(got.workspace, '/home/program/agentmail');
});
test('不变量workspace 取 directory 而不是 path', () => {
// opencode 的 path 是项目内的子路径通常是空串cwd 在 directory 上。
// 取错的后果是所有会话的 workspace 都变成空串,一条都匹配不上。
const [got] = snapshotOpencodeSessions([
ocSession({ directory: '/home/real/cwd', path: 'src/sub' }),
]);
assert.equal(got.workspace, '/home/real/cwd');
});
test('opencode 快照带出 slug 与标题', () => {
const [got] = snapshotOpencodeSessions([ocSession()]);
assert.equal(got.slug, 'witty-planet');
assert.equal(got.title, '重构导入路径');
assert.equal(got.platform_id, 'ses_abc');
});
test('不变量:无 slug 的会话不上报', () => {
// slug 是填进 session 位的值。没有它,这一项在补全里点下去
// 只能得到 `name@path.` —— 一个空的 session 段。
const got = snapshotOpencodeSessions([
ocSession({ id: 'a', slug: '' }),
ocSession({ id: 'b', slug: undefined }),
ocSession({ id: 'c', slug: 'good-name' }),
]);
assert.equal(got.length, 1);
assert.equal(got[0].slug, 'good-name');
});
test('无 id 的条目被跳过', () => {
const got = snapshotOpencodeSessions([ocSession({ id: '' }), ocSession({ id: undefined })]);
assert.equal(got.length, 0);
});
test('mail_driven 由回调判定', () => {
const got = snapshotOpencodeSessions(
[ocSession({ id: 'driven' }), ocSession({ id: 'manual' })],
id => id === 'driven'
);
assert.equal(got.find(s => s.platform_id === 'driven').mail_driven, true);
assert.equal(got.find(s => s.platform_id === 'manual').mail_driven, false);
});
test('updated_at 由毫秒时间戳转 ISO', () => {
const [got] = snapshotOpencodeSessions([ocSession()]);
assert.equal(got.updated_at, new Date(1788344476744).toISOString());
});
test('没有 updated 时退回 created', () => {
const [got] = snapshotOpencodeSessions([
ocSession({ time: { created: 1788300000000 } }),
]);
assert.equal(got.updated_at, new Date(1788300000000).toISOString());
});
test('时间完全缺失时 updated_at 为 undefined 而不是崩', () => {
const [got] = snapshotOpencodeSessions([ocSession({ time: undefined })]);
assert.equal(got.updated_at, undefined);
});
test('按最近活跃降序排列', () => {
const got = snapshotOpencodeSessions([
ocSession({ id: 'old', slug: 'old', time: { updated: 1000 } }),
ocSession({ id: 'new', slug: 'new', time: { updated: 9000 } }),
ocSession({ id: 'mid', slug: 'mid', time: { updated: 5000 } }),
]);
assert.deepEqual(got.map(s => s.platform_id), ['new', 'mid', 'old']);
});
test('截断到 MAX_REPORTED', () => {
const many = Array.from({ length: MAX_REPORTED + 50 }, (_, i) =>
ocSession({ id: `s${i}`, slug: `slug-${i}`, time: { updated: i } })
);
assert.equal(snapshotOpencodeSessions(many).length, MAX_REPORTED);
});
test('非数组输入不崩', () => {
assert.deepEqual(snapshotOpencodeSessions(undefined), []);
assert.deepEqual(snapshotOpencodeSessions(null), []);
assert.deepEqual(snapshotOpencodeSessions({}), []);
});
// ─── DSH ───
test('DSH 快照从标题派生 slug', () => {
const [got] = snapshotDshSessions([
{ id: 'mail-1', cwd: '/home/x', title: '缓存层选型评估', updatedAt: 1788344476744 },
]);
assert.equal(got.slug, '缓存层选型评估');
assert.equal(got.title, '缓存层选型评估');
assert.equal(got.workspace, '/home/x');
});
test('DSH 无标题时不上报(派生不出别名)', () => {
const got = snapshotDshSessions([
{ id: 'a', cwd: '/home/x', title: '' },
{ id: 'b', cwd: '/home/x' },
]);
assert.equal(got.length, 0);
});
// ─── slugFromTitle ───
test('slugFromTitle 空白转连字符', () => {
assert.equal(slugFromTitle('处理新邮件 任务'), '处理新邮件-任务');
assert.equal(slugFromTitle('a b c'), 'a-b-c');
});
test('不变量slug 不含寻址分隔符', () => {
// `.` 是 session 位的分隔符、`@` 是 path 位的分隔符。留在 slug 里
// 会让别名自己被解析器切开 —— 填进去的地址会指向一个完全不同的目标。
const slug = slugFromTitle('修 a.b@c/d 的问题');
for (const ch of ['.', '@', '/', '\\', ':']) {
assert.ok(!slug.includes(ch), `slug 里不该有 ${ch}${slug}`);
}
});
test('slugFromTitle 压缩连续连字符', () => {
assert.equal(slugFromTitle('a...b'), 'ab');
assert.equal(slugFromTitle('a - b'), 'a-b');
});
test('slugFromTitle 去掉首尾连字符', () => {
assert.equal(slugFromTitle(' 中间 '), '中间');
assert.equal(slugFromTitle('--x--'), 'x');
});
test('slugFromTitle 截断到 48 字符且不留尾部连字符', () => {
const long = 'a'.repeat(60);
assert.equal(slugFromTitle(long).length, 48);
// 第 48 个字符正好落在空格上时,截断后不该留下尾部 -
const tricky = `${'b'.repeat(47)} tail`;
const slug = slugFromTitle(tricky);
assert.ok(!slug.endsWith('-'), `尾部残留连字符:${slug}`);
});
test('slugFromTitle 保留中文', () => {
// 不转拼音huancunceng-xuanxing 既不好读也不好打,
// 而三维地址按最后一个 . 切分,中文不影响解析。
assert.equal(slugFromTitle('缓存选型'), '缓存选型');
});
test('slugFromTitle 纯符号标题返回空串', () => {
assert.equal(slugFromTitle('...'), '');
assert.equal(slugFromTitle('@@@'), '');
assert.equal(slugFromTitle(' '), '');
});
test('slugFromTitle 容错非字符串', () => {
assert.equal(slugFromTitle(undefined), '');
assert.equal(slugFromTitle(null), '');
assert.equal(slugFromTitle(42), '42');
});
// ─── 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);
});
// ─── pi名字来自会话文件的 session_info ───
const piSession = (over = {}) => ({
id: '01a064cc-df57-7b2d-bebb-736776105485',
cwd: '/home/program/agentmail',
name: '重构导入路径',
messageCount: 6,
created: new Date(1788300000000),
modified: new Date(1788344476744),
...over,
});
test('pi 快照取 cwd 与 session_info 名字', () => {
const [got] = snapshotPiSessions([piSession()]);
assert.equal(got.workspace, '/home/program/agentmail');
assert.equal(got.title, '重构导入路径');
assert.equal(got.slug, '重构导入路径');
assert.equal(got.platform_id, '01a064cc-df57-7b2d-bebb-736776105485');
});
test('不变量pi 无名会话不上报', () => {
// pi 的列表在无名时显示首条消息,而邮件驱动会话的首条消息是桥自己拼的提示词
// 「你收到一封新邮件AgentMail…」—— 拿它当别名毫无区分度,且条条撞名。
const got = snapshotPiSessions([
piSession({ id: 'named', name: '有名字' }),
piSession({ id: 'anon', name: undefined }),
piSession({ id: 'blank', name: '' }),
]);
assert.deepEqual(got.map(s => s.platform_id), ['named']);
});
test('不变量pi 老会话的空 cwd 照实上报', () => {
// SessionInfo 的注释写明老会话 cwd 是空串。拿桥自己的 cwd 冒充会让
// 那条会话在补全里挂到一个它其实不属于的工作区下。
const [got] = snapshotPiSessions([piSession({ cwd: '' })]);
assert.equal(got.workspace, '');
});
test('不变量pi 的 updated_at 取 modified文件 mtime', () => {
const [got] = snapshotPiSessions([piSession()]);
assert.equal(got.updated_at, new Date(1788344476744).toISOString());
});
test('pi 快照按最近活跃排序并对撞名 slug 去重', () => {
const got = snapshotPiSessions([
piSession({ id: 'old', name: '同一个标题', modified: new Date(1000) }),
piSession({ id: 'new', name: '同一个标题', modified: new Date(9000) }),
]);
assert.equal(got.length, 1);
assert.equal(got[0].platform_id, 'new');
});
test('pi 快照标记邮件驱动的会话', () => {
const got = snapshotPiSessions(
[piSession({ id: 'mail-one' }), piSession({ id: 'human', name: '人开的' })],
(id) => id === 'mail-one'
);
assert.equal(got.find(s => s.platform_id === 'mail-one').mail_driven, true);
assert.equal(got.find(s => s.platform_id === 'human').mail_driven, false);
});
// ─── isUnusableNamepi-web 标题生成器的思维链泄漏 ───
test('不变量:思维链泄漏的标题判废', () => {
// 都是本机 ~/.pi/agent/sessions 里实测捞到的真实 session_info 名字。
// pi-web 的 cleanSessionName 只做「取首行 + 去引号 + 截 60 字符」,不防这个。
assert.equal(isUnusableName('The user is asking me to generate a title for a coding-agent'), true);
assert.equal(
isUnusableName('我们只需要生成标题不包含其他内容。标题应反映请求内容测试opencode的源。简短Opencode源测试。或者更简'),
true
);
});
test('isUnusableName 放过正常标题', () => {
// 宁可漏判也不误判:错杀一个好名字会让那条会话失去可寻址的别名。
assert.equal(isUnusableName('查看Agent接入群聊'), false);
assert.equal(isUnusableName('你应该知道内网拓扑结构吧'), false);
assert.equal(isUnusableName('homeagent-gateway'), false);
assert.equal(isUnusableName('重构导入路径'), false);
assert.equal(isUnusableName('Fix flaky auth test'), false);
});
test('isUnusableName 判废空名字', () => {
assert.equal(isUnusableName(''), true);
assert.equal(isUnusableName(' '), true);
assert.equal(isUnusableName(undefined), true);
});
test('判废的名字不进快照', () => {
const got = snapshotPiSessions([
piSession({ id: 'leaked', name: 'The user is asking me to generate a title for a coding-agent' }),
piSession({ id: 'clean', name: '正常标题' }),
]);
assert.deepEqual(got.map(s => s.platform_id), ['clean']);
});

View File

@ -0,0 +1,254 @@
/**
* pi 专属纯逻辑的测试:轮次结论判定、消息文本提取、提示词。
*
* 这些不在 lib/ 下(那里的六个文件三平台逐字节相同),因为它们依赖 pi 的
* 消息形状与 stopReason 语义。但同样是纯函数,因此可以不起模型就钉住。
*
* node --test 'test/*.test.mjs'
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
stripRe,
replySubject,
lastAssistantText,
classifyTurnOutcome,
describeError,
buildMailPrompt,
relayKeyFor,
} from '../src/turn.mjs';
// ─── 主题 ───
test('stripRe 去掉叠加的 Re: 前缀', () => {
assert.equal(stripRe('Re: Re: Re: 缓存选型'), '缓存选型');
assert.equal(stripRe('缓存选型'), '缓存选型');
assert.equal(stripRe('RE: RE: x'), 'x');
});
test('replySubject 只加一层 Re:', () => {
assert.equal(replySubject('Re: 缓存选型'), 'Re: 缓存选型');
assert.equal(replySubject('缓存选型'), 'Re: 缓存选型');
});
test('无主题时回信有兜底主题', () => {
// 空主题会被 Gateway 拒400 Missing subject不兜底就发不出去
assert.equal(replySubject(''), '本轮工作总结');
assert.equal(replySubject(undefined), '本轮工作总结');
});
// ─── 消息文本提取 ───
const asst = (blocks, over = {}) => ({ role: 'assistant', content: blocks, stopReason: 'stop', ...over });
test('只取 text 块,丢掉 thinking', () => {
// 思考过程不该出现在邮件里B-5.1 / N-6它对收件人没有意义
// 而且经常包含「我先假设…」这类会被误读为结论的话。
const got = lastAssistantText([
asst([
{ type: 'thinking', thinking: '先看看有没有缓存层' },
{ type: 'text', text: '已定位到问题:连接池没有复用。' },
]),
]);
assert.equal(got, '已定位到问题:连接池没有复用。');
});
test('不变量:跳过纯工具调用的收尾消息,往前找有文本的那条', () => {
// 一轮的最后一条 assistant 消息常常只有 toolCall。取到它会得到空串
// 于是 B-5.4 判成「无话可说」而漏掉真正的结论 —— 发件人再无音讯。
const got = lastAssistantText([
asst([{ type: 'text', text: '结论在这里。' }]),
asst([{ type: 'toolCall', toolName: 'bash', input: {} }]),
]);
assert.equal(got, '结论在这里。');
});
test('多个 text 块按顺序拼接', () => {
const got = lastAssistantText([asst([
{ type: 'text', text: '第一段' },
{ type: 'text', text: '第二段' },
])]);
assert.equal(got, '第一段\n第二段');
});
test('忽略 user 消息里的文本', () => {
const got = lastAssistantText([
asst([{ type: 'text', text: 'assistant 说的' }]),
{ role: 'user', content: [{ type: 'text', text: 'user 说的' }] },
]);
assert.equal(got, 'assistant 说的');
});
test('没有 assistant 消息时返回空串', () => {
assert.equal(lastAssistantText([{ role: 'user', content: [{ type: 'text', text: 'x' }] }]), '');
assert.equal(lastAssistantText([]), '');
assert.equal(lastAssistantText(undefined), '');
});
// ─── 轮次结论D-3两次适配都踩过───
test('不变量prompt 抛错判为失败', () => {
// 实测:无凭证的 provider 让 prompt() rejectNo API key found for
// amazon-bedrock.**一个事件都不发**。只看事件的话这轮会被当成没跑完。
const got = classifyTurnOutcome({ error: new Error('No API key found for amazon-bedrock.') });
assert.equal(got.ok, false);
assert.match(got.error, /No API key/);
});
test('不变量:一条 assistant 消息都没有判为失败', () => {
// 判成功会让 B-5 转发一个空字符串回去 —— 发件人收到一封空邮件,
// 而不是错误说明。这是契约里 C-4「必须能区分成功与出错」的核心。
const got = classifyTurnOutcome({ messages: [{ role: 'user', content: [] }] });
assert.equal(got.ok, false);
assert.match(got.error, /没有产出/);
});
test('不变量stopReason=error 判为失败并带出 errorMessage', () => {
const got = classifyTurnOutcome({
messages: [asst([{ type: 'text', text: '半句' }], {
stopReason: 'error',
errorMessage: 'upstream 503 rate limited',
})],
});
assert.equal(got.ok, false);
assert.equal(got.error, 'upstream 503 rate limited');
});
test('stopReason=error 但没给原因也要有话可说', () => {
const got = classifyTurnOutcome({ messages: [asst([], { stopReason: 'error' })] });
assert.equal(got.ok, false);
assert.ok(got.error, '失败原因不能是空串renderFailureReport 会把它填进邮件');
});
test('正常收尾判为成功', () => {
const got = classifyTurnOutcome({ messages: [asst([{ type: 'text', text: '好了' }])] });
assert.deepEqual(got, { ok: true, error: '', aborted: false });
});
test('不变量length被 max tokens 截断)判为成功', () => {
// 内容不完整,但**是模型的产出**。判失败会让一封「说了一半」的回信
// 变成「换个模型重试」,那更糟 —— 用户什么都收不到。
const got = classifyTurnOutcome({ messages: [asst([{ type: 'text', text: '说了一半' }], { stopReason: 'length' })] });
assert.equal(got.ok, true);
});
test('aborted 判为失败但标记 aborted', () => {
// 有人主动打断Esc / dispose不是模型故障 —— 不该触发换模型重试
const got = classifyTurnOutcome({ messages: [asst([], { stopReason: 'aborted' })] });
assert.equal(got.ok, false);
assert.equal(got.aborted, true);
});
test('取最后一条 assistant 消息判定,不是第一条', () => {
const got = classifyTurnOutcome({
messages: [
asst([{ type: 'text', text: '第一轮好的' }], { stopReason: 'stop' }),
asst([], { stopReason: 'error', errorMessage: '第二轮炸了' }),
],
});
assert.equal(got.ok, false);
assert.equal(got.error, '第二轮炸了');
});
// ─── describeError ───
test('describeError 只取首行', () => {
// 报错原文会被填进故障邮件的正文,多行堆栈会把那封信淹掉
assert.equal(describeError(new Error('炸了\n at foo (bar.js:1)')), '炸了');
assert.equal(describeError('单行错误'), '单行错误');
});
test('describeError 带上 code', () => {
const e = new Error('connect failed');
e.code = 'ECONNREFUSED';
assert.equal(describeError(e), 'ECONNREFUSED: connect failed');
});
test('describeError 容错', () => {
assert.equal(describeError(null), '');
assert.equal(describeError(undefined), '');
});
// ─── 提示词B-3.4 / B-3.5)───
const mailData = {
mail_id: 'm-1',
from_name: 'admin',
subject: '排查连接泄漏',
to_workspace: '/home/program/agentmail',
};
test('不变量:提示词写明回信由桥自动发', () => {
// 不说的话模型会自己调 send_mail而桥在轮次结束时也会转发一次 ——
// 同一件事两封邮件(生产里真实发生过)。
const p = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: false });
assert.match(p, /回信不用你自己发/);
});
test('不变量:提示词带 mail_id 与 read_inbox 指引', () => {
// 事件里只有主题,正文和附件清单都在收件箱里;不给 mail_id 模型无法定位这一封
const p = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: false });
assert.match(p, /m-1/);
assert.match(p, /read_inbox/);
});
test('首封带身份,续谈不重复带', () => {
const first = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: false });
const again = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: true });
assert.match(first, /你是 pi/);
assert.doesNotMatch(again, /你是 pi/);
assert.match(again, /续谈/);
});
test('补投的邮件在提示词里说明来源', () => {
// 不说明的话模型会以为这是刚到的、按「立即响应」的语气回
const p = buildMailPrompt({
agentName: 'pi',
data: { ...mailData, catchup: true },
kind: 'mail',
reused: false,
});
assert.match(p, /积压/);
});
test('不变量:带上服务端算好的 reply_address', () => {
// 模型确实会自己发信(要抄送第三方、或分多封交代不同的事)。
// 让它自己拼三维地址的话,`.new` 会被拼进去 —— 回信静默开出一条新会话,
// 原来的线索里再无下文。服务端在 new_mail 里已经算好了这个地址。
const p = buildMailPrompt({
agentName: 'pi',
data: { ...mailData, reply_address: 'admin@.排查连接泄漏' },
kind: 'mail',
reused: false,
});
assert.match(p, /admin@\.排查连接泄漏/);
});
test('没有 reply_address 时不留空行占位', () => {
const p = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: false });
assert.doesNotMatch(p, /回信地址/);
});
test('权限决策的提示词带决策与决策人', () => {
const p = buildMailPrompt({
agentName: 'pi',
data: { decision: '同意', decided_by: 'zhang' },
kind: 'permission',
reused: true,
});
assert.match(p, /同意/);
assert.match(p, /zhang/);
});
// ─── 幂等键 ───
test('relayKey 由会话 id 与叶子 id 组成', () => {
assert.equal(relayKeyFor('sess-1', 'leaf-9'), 'sess-1:leaf-9');
});
test('不变量:叶子 id 缺失时仍产出稳定键', () => {
// 返回空串会让服务端把 relay_key 当作「没给」,于是幂等失效、同一轮转两次
assert.equal(relayKeyFor('sess-1', null), 'sess-1:noleaf');
assert.equal(relayKeyFor('sess-1', undefined), 'sess-1:noleaf');
});

View File

@ -0,0 +1,128 @@
/**
* 工作目录解析的回归测试。
*
* 这是「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, mailSessionFallback } from '../lib/workspace.js';
// 兜底目录现在由调用方给各平台不同。DSH 用 mailSessionFallback
// opencode 用插件启动时的 directory。
const fallbackOf = key => mailSessionFallback(key);
test('存在的绝对路径直接用作 cwd', () => {
const dir = mkdtempSync(join(tmpdir(), 'ws-test-'));
try {
const got = resolveWorkspaceCwd(dir, fallbackOf('mail-1'));
assert.equal(got.cwd, dir);
assert.equal(got.grouped, true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('不变量:同一 path 的多封邮件得到同一个 cwd这才能同组', () => {
const dir = mkdtempSync(join(tmpdir(), 'ws-test-'));
try {
const a = resolveWorkspaceCwd(dir, fallbackOf('mail-aaa'));
const b = resolveWorkspaceCwd(dir, fallbackOf('mail-bbb'));
assert.equal(a.cwd, b.cwd, 'fallbackKey 不同却应得到同一个 cwd');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('path 为空时回退到兜底目录', () => {
const got = resolveWorkspaceCwd('', fallbackOf('mail-2'));
assert.equal(got.cwd, fallbackOf('mail-2'));
assert.equal(got.grouped, false);
});
test('path 缺失/非字符串时回退', () => {
for (const v of [undefined, null, 42, {}]) {
const got = resolveWorkspaceCwd(v, fallbackOf('mail-3'));
assert.equal(got.grouped, false);
assert.equal(got.cwd, fallbackOf('mail-3'));
}
});
test('不变量:不存在的目录不创建,回退到兜底', () => {
// 一个笔误(/home/porgram/x不该在磁盘上落下真目录 ——
// Agent 会在里面一无所获地干活,比明确回退更难排查。
const got = resolveWorkspaceCwd('/nonexistent/path/xyz-should-not-exist', fallbackOf('mail-4'));
assert.equal(got.grouped, false);
assert.equal(got.cwd, fallbackOf('mail-4'));
});
test('不变量:相对路径被拒绝', () => {
// cwd 的相对基准是 harness 进程的启动目录systemd 下通常是 /
// 那是个与邮件语义完全无关的量。
for (const rel of ['relative/path', './x', '../y', 'src']) {
const got = resolveWorkspaceCwd(rel, fallbackOf('mail-5'));
assert.equal(got.grouped, false, `${rel} 不该被当作工作目录`);
}
});
test('指向文件而非目录时回退', () => {
const dir = mkdtempSync(join(tmpdir(), 'ws-test-'));
const file = join(dir, 'a-file');
writeFileSync(file, 'x');
try {
const got = resolveWorkspaceCwd(file, fallbackOf('mail-6'));
assert.equal(got.grouped, false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('两端空白被修掉', () => {
const dir = mkdtempSync(join(tmpdir(), 'ws-test-'));
try {
const got = resolveWorkspaceCwd(` ${dir} `, fallbackOf('mail-7'));
assert.equal(got.cwd, dir);
assert.equal(got.grouped, true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('ensureCwd 只建兜底目录,不碰寻址指定的目录', () => {
const base = mkdtempSync(join(tmpdir(), 'ws-ensure-'));
try {
const target = join(base, 'made-by-ensure');
ensureCwd(target, false);
// 建出来了
const got = resolveWorkspaceCwd(target, '');
assert.equal(got.grouped, true, 'ensureCwd 应已创建该目录');
// grouped=true 时不该创建(那种目录本来就存在)
const never = join(base, 'should-not-exist');
ensureCwd(never, true);
assert.equal(resolveWorkspaceCwd(never, '').grouped, false);
} finally {
rmSync(base, { recursive: true, force: true });
}
});
test('兜底为空串时返回空 cwd交给平台自己决定', () => {
// opencode 没配 directory 时就是这种情况session.create 不带 query.directory
// 由平台按自己的默认规则选目录。比硬塞一个我们猜的路径好。
const got = resolveWorkspaceCwd('', '');
assert.equal(got.cwd, '');
assert.equal(got.grouped, false);
});
test('mailSessionFallback 同一 key 稳定、不同 key 不同', () => {
assert.equal(mailSessionFallback('a'), mailSessionFallback('a'));
assert.notEqual(mailSessionFallback('a'), mailSessionFallback('b'));
assert.match(mailSessionFallback('a'), /mail-sessions/);
});

1240
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -8,9 +8,11 @@
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "node test/markdown-xss.test.mjs && node test/narrow-layout.test.mjs",
"test": "node test/markdown-xss.test.mjs && node test/narrow-layout.test.mjs && vitest run",
"test:narrow": "node test/manual/narrow-verify.mjs",
"test:wide": "node test/manual/wide-regression.mjs"
"test:wide": "node test/manual/wide-regression.mjs",
"test:components": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"react": "^18.3.1",
@ -20,13 +22,19 @@
"zustand": "^4.5.4"
},
"devDependencies": {
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"jsdom": "26.1.0",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.6",
"typescript": "^5.5.3",
"vite": "^5.3.4"
"vite": "^5.3.4",
"vitest": "3.2.4"
}
}

View File

@ -475,7 +475,14 @@ function ThreadCard({ mail, onForward }: { mail: Mail; onForward?: () => void })
);
}
function PermissionPanel({ mail }: { mail: Mail }) {
/**
* 权限请求的决策面板。
*
* 导出供测试单独渲染:通过整个 MailView 渲染它需要先把 mailStore 与
* sessionStore 摆到「当前正看着一封 permission_request 邮件」的状态,
* 那些铺垫与这个组件本身的行为无关。
*/
export function PermissionPanel({ mail }: { mail: Mail }) {
const [note, setNote] = useState('');
const [busy, setBusy] = useState(false);
const [decided, setDecided] = useState(mail.permission_result || '');

View File

@ -122,8 +122,10 @@ export function WorkCard({
* 0 = 不限,此时不显示 —— 一个「0/0」或「不限」的徽标对每张卡片都成立
* 等于纯噪声。只在真正设了上限时才占位置。
* 剩 1 个来回时转红:那是需要人介入的时刻(要么加预算,要么让它收尾)。
*
* 导出供测试单独渲染 —— 它是纯展示件,而通过 WorkCard 渲染要先造一整个 Contact。
*/
function BudgetChip({ max, used }: { max: number; used: number }) {
export function BudgetChip({ max, used }: { max: number; used: number }) {
if (!max || max <= 0) return null;
const remaining = Math.max(max - used, 0);

View File

@ -0,0 +1,264 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import AddressInput from '../../src/components/AddressInput';
import * as api from '../../src/api/client';
import type { SessionCandidate } from '../../src/types';
/**
* 三段式地址补全。
*
* 每条断言都对应一个真实存在过或很容易犯的错:
* - 问错层(写了 @ 还在问 name 的候选)
* - 过滤后 suggestions 与 candidates 错位,标题挂到别的别名上
* - 选中 name 后没自动补 `@`,人得自己敲
* - session 段选完还开着下拉,挡住下面的输入框
*/
/** 造一个 suggestAddress 的响应。candidates 与 suggestions 必须同序同长。 */
function res(
kind: 'name' | 'path' | 'session',
suggestions: string[],
candidates?: Partial<SessionCandidate>[]
) {
return {
kind,
suggestions,
candidates: candidates?.map((c, i) => ({
alias: suggestions[i],
title: '',
source: 'mail' as const,
unread: 0,
...c
}))
};
}
/** 渲染一个受控的 AddressInput返回 input 与「当前值」读取器。 */
function setup(initial = '') {
let current = initial;
const onChange = vi.fn((v: string) => {
current = v;
rerender();
});
const { rerender: rr } = render(
React.createElement(AddressInput, { value: current, onChange })
);
function rerender() {
rr(React.createElement(AddressInput, { value: current, onChange }));
}
return {
onChange,
value: () => current,
input: () => screen.getByRole('textbox') as HTMLInputElement
};
}
describe('AddressInput 三段式补全', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('还没写 @ 时问 name 层', async () => {
const spy = vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh', 'opencode']));
const { input } = setup();
await userEvent.click(input());
await userEvent.type(input(), 'ds');
await waitFor(() => expect(spy).toHaveBeenCalled());
// 问 name 层时不带任何参数
expect(spy.mock.calls[spy.mock.calls.length - 1]).toEqual([]);
});
it('写了 @ 没写 . 时带 name 去问 path 层', async () => {
const spy = vi
.spyOn(api, 'suggestAddress')
.mockResolvedValue(res('path', ['/home/program/agentmail']));
const { input } = setup();
await userEvent.click(input());
await userEvent.type(input(), 'dsh@/home');
// 问错层的后果:候选里全是 Agent 名,人以为这个工作区下没有会话
await waitFor(() => expect(spy).toHaveBeenCalledWith('dsh'));
});
it('写了 . 时带 name 与 path 去问 session 层', async () => {
const spy = vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('session', ['refactor']));
const { input } = setup();
await userEvent.click(input());
await userEvent.type(input(), 'dsh@/home/x.ref');
await waitFor(() => expect(spy).toHaveBeenCalledWith('dsh', '/home/x'));
});
it('path 里的 . 不被当作 session 分隔符(按最后一个 . 切)', async () => {
const spy = vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('session', ['a']));
const { input } = setup();
await userEvent.click(input());
// path 自身含 .a.b/proj
await userEvent.type(input(), 'dsh@/home/a.b/proj.ref');
await waitFor(() => expect(spy).toHaveBeenCalledWith('dsh', '/home/a.b/proj'));
});
it('选中 name 候选后自动补 @', async () => {
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh', 'opencode']));
const { input, value } = setup();
await userEvent.click(input());
await waitFor(() => expect(screen.getByText('dsh')).toBeInTheDocument());
await userEvent.click(screen.getByText('dsh'));
// 不补 @ 的话人得自己敲,而这是三段里唯一没有歧义的分隔符
expect(value()).toBe('dsh@');
});
it('选中 path 候选后自动补 .', async () => {
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('path', ['/home/program/agentmail']));
const { input, value } = setup('dsh@');
await userEvent.click(input());
await waitFor(() => expect(screen.getByText('/home/program/agentmail')).toBeInTheDocument());
await userEvent.click(screen.getByText('/home/program/agentmail'));
expect(value()).toBe('dsh@/home/program/agentmail.');
});
it('选中 session 候选后拼出完整地址并关掉下拉', async () => {
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('session', ['refactor', 'new']));
const { input, value } = setup('dsh@/home/x.');
await userEvent.click(input());
await waitFor(() => expect(screen.getByText('refactor')).toBeInTheDocument());
await userEvent.click(screen.getByText('refactor'));
expect(value()).toBe('dsh@/home/x.refactor');
// session 是最后一段,选完还开着下拉会挡住下面的输入框
await waitFor(() => expect(screen.queryByText('会话别名new 为新建)')).toBeNull());
});
it('标题参与过滤,且过滤后标题不错位到别的别名上', async () => {
// 三条候选,只有第二条的标题含「缓存」
vi.spyOn(api, 'suggestAddress').mockResolvedValue(
res(
'session',
['witty-planet', 'brisk-harbor', 'calm-river'],
[{ title: '重构导入路径' }, { title: '缓存选型讨论' }, { title: '修 CI' }]
)
);
const { input } = setup('dsh@/home/x.');
await userEvent.click(input());
await userEvent.type(input(), '缓存');
await waitFor(() => {
// 命中的是 brisk-harbor标题必须还是它自己的
expect(screen.getByText('brisk-harbor')).toBeInTheDocument();
expect(screen.getByText('缓存选型讨论')).toBeInTheDocument();
});
// 分别过滤两个数组会让 witty-planet 的标题挂到 brisk-harbor 上
expect(screen.queryByText('重构导入路径')).toBeNull();
expect(screen.queryByText('witty-planet')).toBeNull();
});
it('platform 来源的候选标出「平台」new 标出「新建会话」', async () => {
vi.spyOn(api, 'suggestAddress').mockResolvedValue(
res(
'session',
['ses-mirror', 'new'],
[
{ title: '平台侧在跑的会话', source: 'platform' },
{ source: 'new' }
]
)
);
const { input } = setup('dsh@/home/x.');
await userEvent.click(input());
await waitFor(() => {
// 不标的话人不知道这一封是「接入」一条已在跑的会话
expect(screen.getByText('平台')).toBeInTheDocument();
expect(screen.getByText('新建会话')).toBeInTheDocument();
});
});
it('未读数显示在候选上', async () => {
vi.spyOn(api, 'suggestAddress').mockResolvedValue(
res('session', ['busy-session'], [{ title: '有新消息', unread: 3 }])
);
const { input } = setup('dsh@/home/x.');
await userEvent.click(input());
await waitFor(() => expect(screen.getByText('3')).toBeInTheDocument());
});
it('键盘上下键移动选中项Enter 采用', async () => {
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh', 'opencode']));
const { input, value } = setup();
await userEvent.click(input());
await waitFor(() => expect(screen.getByText('opencode')).toBeInTheDocument());
await userEvent.keyboard('{ArrowDown}{Enter}');
// 初始 active=0dsh下移一格到 opencode
expect(value()).toBe('opencode@');
});
it('Escape 关掉下拉但不清空已输入的内容', async () => {
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh']));
const { input, value } = setup();
await userEvent.click(input());
await userEvent.type(input(), 'ds');
await waitFor(() => expect(screen.getByText('Agent 名')).toBeInTheDocument());
await userEvent.keyboard('{Escape}');
await waitFor(() => expect(screen.queryByText('Agent 名')).toBeNull());
expect(value()).toBe('ds');
});
it('接口失败时静默清空候选,不炸也不弹错', async () => {
vi.spyOn(api, 'suggestAddress').mockRejectedValue(new Error('network down'));
const { input, value } = setup();
await userEvent.click(input());
await userEvent.type(input(), 'ds');
// 补全只是便利功能,失败不该阻止人手动输入完整地址
await waitFor(() => expect(screen.queryByText('Agent 名')).toBeNull());
expect(value()).toBe('ds');
});
it('allowMultiple 时补全只作用于最后一段,前面的地址保留', async () => {
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['opencode']));
let current = 'dsh@/home/x.a, ';
const onChange = vi.fn((v: string) => {
current = v;
});
render(
React.createElement(AddressInput, {
value: current,
onChange,
allowMultiple: true
})
);
await userEvent.click(screen.getByRole('textbox'));
await waitFor(() => expect(screen.getByText('opencode')).toBeInTheDocument());
await userEvent.click(screen.getByText('opencode'));
// 抄送场景:前面已填好的地址不能被补全覆盖
expect(current).toBe('dsh@/home/x.a, opencode@');
});
});

View File

@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { BudgetChip } from '../../src/components/WorkCard';
/**
* 往返预算徽标。
*
* 它是「这个任务还剩几个来回」的唯一视觉提示,两件事必须对:
* - 显示的是**剩余**而不是已用(人做决定看的是「还能问几次」)
* - 0 = 不限时**不显示**而不是显示「0/0」
*/
describe('BudgetChip 预算渲染', () => {
it('显示剩余数而不是已用数', () => {
render(React.createElement(BudgetChip, { max: 20, used: 3 }));
// 20 个上限、用了 3 个 → 剩 17。显示 3/20 会让人以为快用完了
expect(screen.getByText('17/20')).toBeInTheDocument();
});
it('max 为 0不限时完全不渲染', () => {
const { container } = render(React.createElement(BudgetChip, { max: 0, used: 0 }));
// 「0/0」或「不限」对每张卡片都成立等于纯噪声
expect(container.firstChild).toBeNull();
});
it('max 为负数时也不渲染(脏数据兜底)', () => {
const { container } = render(React.createElement(BudgetChip, { max: -1, used: 0 }));
expect(container.firstChild).toBeNull();
});
it('用超时剩余按 0 显示,不出现负数', () => {
render(React.createElement(BudgetChip, { max: 5, used: 8 }));
// 「-3/5」看起来像 bug而实际情形免配额转发不计数、人手动下调过上限是合法的
expect(screen.getByText('0/5')).toBeInTheDocument();
});
it('剩余为 0 时转红并标注「已用尽」', () => {
render(React.createElement(BudgetChip, { max: 5, used: 5 }));
const chip = screen.getByText('0/5').closest('span')!;
expect(chip.className).toContain('bg-red-100');
expect(chip.getAttribute('title')).toContain('已用尽');
});
it('剩余 1 个时转橙 —— 那是需要人介入的时刻', () => {
render(React.createElement(BudgetChip, { max: 20, used: 19 }));
const chip = screen.getByText('1/20').closest('span')!;
// 要么加预算,要么让它收尾;这一步不提示的话下一封信就被挡住了
expect(chip.className).toContain('bg-orange-100');
});
it('余量充足时用中性灰,不抢注意力', () => {
render(React.createElement(BudgetChip, { max: 20, used: 2 }));
const chip = screen.getByText('18/20').closest('span')!;
expect(chip.className).toContain('bg-gray-100');
expect(chip.className).not.toContain('red');
expect(chip.className).not.toContain('orange');
});
it('title 里带已用/上限,供悬停查看细节', () => {
render(React.createElement(BudgetChip, { max: 20, used: 7 }));
const chip = screen.getByText('13/20').closest('span')!;
// 徽标上只有剩余,具体用了几个放在 title 里 —— 徽标要窄
expect(chip.getAttribute('title')).toContain('已用 7/20');
});
it('剩余 2 个时还不转橙(阈值是 <= 1', () => {
render(React.createElement(BudgetChip, { max: 10, used: 8 }));
const chip = screen.getByText('2/10').closest('span')!;
expect(chip.className).toContain('bg-gray-100');
});
});

View File

@ -0,0 +1,203 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { PermissionPanel } from '../../src/components/MailView';
import * as api from '../../src/api/client';
import { useMailStore } from '../../src/stores/mailStore';
import { useSessionStore } from '../../src/stores/sessionStore';
import type { Mail } from '../../src/types';
/**
* 权限决策面板。
*
* 这是全站唯一一处「人的一次点击直接放行 Agent 的危险操作」,
* 因此断言集中在两件事:
* - 点下去到底把什么发给了服务端(选项原文,不是归一化后的 allow/deny
* - 已决策的请求不能再点第二次
*/
function permMail(over: Partial<Mail> = {}): Mail {
return {
mail_id: 'm-1',
session_id: 's-1',
parent_mail_id: null,
from_name: 'dsh',
from_workspace: '/home/program/agentmail',
to_name: 'admin',
to_workspace: '',
cc_list: [],
subject: '请求批准:删除 build/',
body: '将执行 rm -rf build/',
mail_type: 'permission_request',
permission_options: undefined,
permission_result: '',
status: 'unread',
created_at: '2026-09-03T00:00:00Z',
hop_limit: 5,
...over
} as Mail;
}
describe('PermissionPanel 决策', () => {
beforeEach(() => {
vi.restoreAllMocks();
// fetchInbox / selectSession 会打网络,替换成空实现
useMailStore.setState({ fetchInbox: vi.fn(async () => {}) } as any);
useSessionStore.setState({ selectSession: vi.fn(async () => {}) } as any);
});
it('没有 permission_options 时给默认的同意/拒绝两个选项', () => {
render(React.createElement(PermissionPanel, { mail: permMail() }));
expect(screen.getByRole('button', { name: /同意/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /拒绝/ })).toBeInTheDocument();
});
it('有 permission_options 时用它,且顺序保持', () => {
render(
React.createElement(PermissionPanel, {
mail: permMail({ permission_options: ['只这一次', '总是允许', '拒绝'] })
})
);
const btns = screen.getAllByRole('button').map(b => b.textContent?.trim());
// 顺序是 Agent 给的语义顺序,重排会让「拒绝」跑到人的手指默认位置上
expect(btns).toEqual(['只这一次', '总是允许', '拒绝']);
});
it('点选项时把【选项原文】发给服务端', async () => {
const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
render(
React.createElement(PermissionPanel, {
mail: permMail({ permission_options: ['只这一次', '拒绝'] })
})
);
await userEvent.click(screen.getByRole('button', { name: /只这一次/ }));
// 关键:不能归一化成 allow/deny —— 「只这一次」与「总是允许」的区别
// 只有 Agent 侧的权限机制懂,服务端与前端都不该替它翻译
await waitFor(() =>
expect(spy).toHaveBeenCalledWith('m-1', '只这一次', undefined)
);
});
it('填了备注时一起发出去', async () => {
const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
render(React.createElement(PermissionPanel, { mail: permMail() }));
await userEvent.type(screen.getByPlaceholderText('备注(可选)'), '只删 build别动 dist');
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
await waitFor(() =>
expect(spy).toHaveBeenCalledWith('m-1', '同意', '只删 build别动 dist')
);
});
it('备注为空时传 undefined 而不是空字符串', async () => {
const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
render(React.createElement(PermissionPanel, { mail: permMail() }));
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
// 空串会在决策邮件里留一行空的「备注:」
await waitFor(() => expect(spy).toHaveBeenCalledWith('m-1', '同意', undefined));
});
it('决策后变成「已处理」,不再显示按钮', async () => {
vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
render(React.createElement(PermissionPanel, { mail: permMail() }));
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
await waitFor(() => expect(screen.getByText('已处理:')).toBeInTheDocument());
// 还能点第二次的话人会以为第一次没生效,而服务端那边早已决策
expect(screen.queryByRole('button')).toBeNull();
});
it('已经有 permission_result 的邮件直接显示结论', () => {
render(
React.createElement(PermissionPanel, {
mail: permMail({ permission_result: '拒绝' })
})
);
expect(screen.getByText('拒绝')).toBeInTheDocument();
expect(screen.queryByRole('button')).toBeNull();
});
it('提交中禁用所有按钮,避免重复决策', async () => {
let release: (v: any) => void = () => {};
vi.spyOn(api, 'decidePermission').mockReturnValue(
new Promise(res => {
release = res;
}) as any
);
render(React.createElement(PermissionPanel, { mail: permMail() }));
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
// 一次危险操作被批准两次Agent 那边可能真的执行两遍
await waitFor(() => {
for (const b of screen.getAllByRole('button')) {
expect(b).toBeDisabled();
}
});
// 收尾:让悬挂的 Promise 落定并等状态更新走完,
// 否则组件在测试结束后才 setStateReact 会报 act 警告
await act(async () => {
release({ status: 'decided' });
});
await waitFor(() => expect(screen.getByText('已处理:')).toBeInTheDocument());
});
it('提交失败时恢复可点,不假装已决策', async () => {
vi.spyOn(api, 'decidePermission').mockRejectedValue(new Error('500'));
vi.spyOn(console, 'error').mockImplementation(() => {});
render(React.createElement(PermissionPanel, { mail: permMail() }));
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
// 失败后显示「已处理」是最糟的结果人以为批过了Agent 还在等
await waitFor(() => expect(screen.getByRole('button', { name: /同意/ })).toBeEnabled());
expect(screen.queryByText('已处理:')).toBeNull();
});
it('决策成功后刷新收件箱并选中该会话', async () => {
vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
const fetchInbox = vi.fn(async () => {});
const selectSession = vi.fn(async () => {});
useMailStore.setState({ fetchInbox } as any);
useSessionStore.setState({ selectSession } as any);
render(React.createElement(PermissionPanel, { mail: permMail() }));
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
// 不刷新的话列表里那封还是「未读的权限请求」,人会以为没生效
await waitFor(() => {
expect(fetchInbox).toHaveBeenCalledWith('all');
expect(selectSession).toHaveBeenCalledWith('s-1');
});
});
it('同意类选项用绿色,其余用红色', () => {
render(
React.createElement(PermissionPanel, {
mail: permMail({ permission_options: ['允许', 'approve', '拒绝', '算了'] })
})
);
const cls = (name: string) =>
screen.getByRole('button', { name: new RegExp(name) }).className;
// 颜色是唯一的视觉提示:点错一次就放行了一个危险操作
expect(cls('允许')).toContain('bg-green-600');
expect(cls('approve')).toContain('bg-green-600');
expect(cls('拒绝')).toContain('text-red-700');
// 不在同意词表里的一律按「否」处理 —— 宁可让人多看一眼
expect(cls('算了')).toContain('text-red-700');
});
});

View File

@ -0,0 +1,45 @@
import { afterEach, expect } from 'vitest';
import { cleanup } from '@testing-library/react';
import * as matchers from '@testing-library/jest-dom/matchers';
/**
* 组件测试的全局准备。
*
* 三件事,每件都对应一类会串味的状态:
* 1. 每个用例后卸载组件树(不卸载的话下一个用例的 getByText 会命中上一个的 DOM
* 2. 重置 zustand store它是模块级单例跨用例共享
* 3. 提供 matchMedia —— jsdom 没有实现它,而 useIsNarrow 直接调
*/
expect.extend(matchers);
afterEach(() => {
cleanup();
});
/**
* jsdom 不实现 matchMedia。useIsNarrow 会直接调它并挂 change 监听,
* 缺了就抛 `matchMedia is not a function`,整个测试文件挂掉。
*
* 默认返回 false宽屏。要测窄屏的用例用 setViewport(true) 覆盖。
*/
function installMatchMedia(narrow: boolean) {
const listeners = new Set<(e: MediaQueryListEvent) => void>();
(window as any).matchMedia = (query: string) => ({
matches: narrow,
media: query,
onchange: null,
addEventListener: (_: string, cb: (e: MediaQueryListEvent) => void) => listeners.add(cb),
removeEventListener: (_: string, cb: (e: MediaQueryListEvent) => void) => listeners.delete(cb),
// 老式 API某些库仍在用
addListener: (cb: (e: MediaQueryListEvent) => void) => listeners.add(cb),
removeListener: (cb: (e: MediaQueryListEvent) => void) => listeners.delete(cb),
dispatchEvent: () => false
});
}
installMatchMedia(false);
/** 让当前测试文件里的组件按窄屏/宽屏渲染。在 render 之前调。 */
export function setViewport(narrow: boolean) {
installMatchMedia(narrow);
}

View File

@ -14,7 +14,11 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
// vitest 的 globals: true 让 describe/it/expect 无需 import
// jest-dom 提供 toBeInTheDocument 等 matcher 的类型。
// 不加这两个的话 tsc --noEmit 会把整套测试报成「找不到名称」。
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src"]
"include": ["src", "test/components", "vitest.config.ts"]
}

27
web/vitest.config.ts Normal file
View File

@ -0,0 +1,27 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
/**
* 组件测试配置。
*
* 与 vite.config.ts 分开:那份是构建与开发服务器的配置,插件链和 test 段混在
* 一起时 `vite build` 也会解析 jsdom 这些只有测试才需要的依赖。
*
* 只跑 test/components/ 下的用例。test/ 顶层那几个markdown-xss、
* narrow-layout是 node:test / 手写断言脚本,由 `npm test` 直接用 node 跑 ——
* 它们不需要 DOM套一层 vitest 只是变慢。
*/
export default defineConfig({
plugins: [react()],
test: {
include: ['test/components/**/*.test.tsx'],
environment: 'jsdom',
globals: true,
setupFiles: ['test/components/setup.ts'],
// 每个文件跑完清掉 DOM 与 mock避免上一个用例的残留影响下一个
restoreMocks: true,
clearMocks: true,
unstubEnvs: true,
unstubGlobals: true
}
});