Files
MailUI4Agents/plugins/dsh-mail-bridge/test/inbound-prompt-reads-mail.test.mjs
JianFeeeee c851bef5cb fix(4 bridges): 投递提示词改指向 read_mail,不再引 read_inbox
投递时 `markDelivered` 就把信标成已读(dsh `src/index.ts:505` 定义,
`:551`/`:2214`/`:2276` 三处调用,SSE 主投递与 catchUp 都走它 ——
2026-09-26 为治"重启重投→回声"定的性)。而提示词却要求"先调
read_inbox 读正文",read_inbox 默认 `status=unread` ⇒ **看不到刚投递的
那封信**。

危险的不是"白费一次调用",是模型看到空之后以为"没有新邮件"就结束
回合 —— 那会**静默丢掉一个真实请求**。mail_id 就在同一段提示词里。

## 改了什么

投递提示词(8 处)与 read_inbox 工具描述(4 处):

    请用 read_mail(mail_id 用上面「邮件 ID」那处) 读取这封邮件的完整正文……
    这封信在投递时已标为已读,而 read_inbox 默认只看未读,读不到它;
    read_inbox 只用来看本会话的其它未读。

工具描述那句「收到新邮件通知后应立即调用此工具」是**模型看到的第一句话**,
只改提示词不改它,模型照样走偏,所以一并改。另给 read_mail 的描述补
一句「刚投递到本会话的那封邮件就读这个」。

落点(dsh 桥是**三处**,不是一处 —— adoptPrompt / resume 复用 / 新开会话
三条建会话路径各带一份文案):

- dsh `index.ts:1047`、`:1163`、`:1219` + 工具描述 `:1428`/`:1680`
- opencode `index.js:1016` + `:261`/`:473`
- pi `turn.mjs:170` + `tools.mjs:197`/`:430`
- zcode `prompt.mjs:152` + `lib/tools.mjs:123`

## 为什么指向 read_mail 是安全的

`workspace` 必需只加在**两个**端点上:GET /mail/inbox
(`server/internal/handler/mail.go:626`,用户裁定:不带 workspace 是错误
发件格式)与全部标已读(`:806`)。`read_mail` 走
`GET /agent/mail/{id}` → `AgentGetMail`(`agent_discovery.go:306`),
该路径**没有 workspace 参数**,鉴权走 `canReadSession()` 按会话归属判定。

桥侧也印证:`read_mail` 走 `withScope()`(只拼 session_id),
`read_inbox` 的 scope 单独拼 `&workspace=`。**两条路分开,400 搬不过去。**

## 不动默认行为

`read_inbox` 的默认 `status=unread` 保持不变(`lib/inbox-format.js:157`
刻意如此:默认 all 会让模型每轮重读旧邮件),`idsToMarkRead` 在 `all`
下不标已读与"投递即标已读"配套。改默认值等于把 2026-09-26 定的性放松
回去,不是另一种修法。

## homeagent 故意不动

`plugin.go:673,1005,250` 三处留着。改 Go 源码需重出 `plugin.bin`,产物在
**跨机器**的 `/home/newqqagent/plugins/`,你我都碰不到 —— 源码改了线上
没生效,仓库与线上分叉比不改更难排查。待跨机重出。

## 测试

新增 test/inbound-prompt-reads-mail.test.mjs(5 项),钉住:旧文案不存在、
新文案三处齐全、工具描述改过、read_mail 描述指过、附件指引保留。同时
断言 src 与 **dist**(dist 是 dsh 真正加载的那份,忘了 build 就是
"源码对、线上旧代码")。

四桥测试:dsh 419 / opencode 344 / pi 517 / zcode 394,全绿。
(改前 407/344/517/394,无回归。)

## dist 变更(不在 diff 里,.gitignore:20 忽略 plugins/*/dist/)

重出后 `请先调用 read_inbox` 由 **3 处 → 0 处**,新文案 3 处;
旧工具描述 1 处 → 0 处。已用 `npx tsc -p tsconfig.json` 重出。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-28 09:29:26 +08:00

87 lines
4.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 投递提示词不能把模型引向 `read_inbox`(dsh)。
*
* # 为什么不只是"措辞过时"
*
* 投递时 `markDelivered` 就把信标成已读(`src/index.ts:505` 的定义,
* `:551`/`:2214`/`:2276` 三处调用,SSE 主投递与 catchUp 都走它 ——
* 2026-09-26 为治"重启重投→回声"定的性)。于是:
*
* 提示词说「调 read_inbox 读正文」 ⇒ read_inbox 默认 status=unread
* ⇒ 看不到刚投递的那封 ⇒ 返回空。
*
* **危险的形状**不是"白费一次调用",是模型看到空之后以为"没有新邮件"
* 就结束回合 —— 那样会**静默丢掉一个真实请求**。mail_id 就在同一段
* 提示词里,`read_mail` 能拿到正文与附件清单。
*
* # 钉住三件事
*
* 1. 投递提示词指向 read_mail,且带得出 mail_id(提示词里有「邮件 ID」行)。
* 2. read_inbox 的工具描述不再说"收到新邮件通知后应立即调用此工具"
* —— 那句是模型看到的第一句话,只改提示词不改它,模型照样走偏。
* 3. 三条建会话路径(adoptPrompt / resume 复用 / 新开会话)**各带一份**
* 文案。只改其中一处 = 另外两条路径上的会话照样走偏。dsh 桥曾
* 长期是 3 处而外部只数出 1 处。
*
* 反向对照:必须同时断言「旧文案不存在」与「新文案存在」,
* 否则把提示词整段删空也能过。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const src = readFileSync(join(HERE, '..', 'src', 'index.ts'), 'utf8');
// dist 是**真正被 dsh 加载**的那份(package.json main: dist/index.js)。
// 改了 src 忘了 build 就是"源码对、线上旧代码"。
const dist = readFileSync(join(HERE, '..', 'dist', 'index.js'), 'utf8');
test('★ 投递提示词不再让模型先调 read_inbox', () => {
assert.ok(
!src.includes('请先调用 read_inbox'),
'src 里还有旧文案:投递时信已标为已读,read_inbox 默认只看未读,读不到',
);
assert.ok(!dist.includes('请先调用 read_inbox'), 'dist 里还有 —— 忘了 npm run build?');
});
test('★ 新文案指向 read_mail 并说明为什么不用 read_inbox', () => {
const hits = src.match(/请用 read_mail\(mail_id 用上面「邮件 ID」那处\)/g) || [];
assert.equal(hits.length, 3, `三条建会话路径各带一份文案,应为 3 处,实际 ${hits.length}`);
assert.ok(
src.includes('这封信在投递时已标为已读'),
'必须说明原因,否则模型可能"顺手"再去调一次 read_inbox',
);
assert.ok(src.includes('read_inbox 只用来看本会话的其它未读'), '要写清 read_inbox 仍有什么用途');
assert.equal(
(dist.match(/请用 read_mail\(mail_id 用上面「邮件 ID」那处\)/g) || []).length,
3,
'dist 里应是 3 处',
);
});
test('★ read_inbox 的工具描述不再声称"收到新邮件通知后应立即调用"', () => {
assert.ok(
!src.includes('收到新邮件通知后应立即调用'),
'那句与「投递即标已读」直接矛盾,且它是模型看到的第一句话',
);
assert.ok(!dist.includes('收到新邮件通知后应立即调用'), 'dist 里还有 —— 忘了 npm run build?');
assert.ok(
src.includes('刚投递到本会话的那封已在投递时标为已读'),
'read_inbox 描述要说明它为什么没有刚投递的那封',
);
});
test('★ read_mail 的描述指明「刚投递的那封读这个」', () => {
assert.ok(
src.includes('刚投递到本会话的那封邮件就读这个'),
'两个工具描述都在往 read_inbox 上引时,光改提示词不够',
);
});
test('★ 附件提示保留(新开会话那份原文有 download_attachment 指引)', () => {
// 新开会话分支原本是唯一带附件说明的;改写时不能顺手丢掉。
assert.ok(src.includes('有附件可用 download_attachment 取回'), '附件取回指引丢了');
});