From f1c74fc4ce14e3ba9d5844e3d6e1c07c283f6bbb Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 28 Sep 2026 10:18:43 +0800 Subject: [PATCH] =?UTF-8?q?test(=E7=BD=91=E5=85=B3):=20=E8=BD=BD=E8=8D=B7?= =?UTF-8?q?=E5=BF=85=E9=A1=BB=E5=B8=A6=E7=88=B6=E9=82=AE=E4=BB=B6=E5=8F=91?= =?UTF-8?q?=E4=BB=B6=E4=BA=BA=20=E2=80=94=E2=80=94=20=E5=B9=B6=E6=9B=B4?= =?UTF-8?q?=E6=AD=A3=E6=88=91=E8=AF=B4=E5=AE=83"=E8=A6=81=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E6=9F=A5=E8=AF=A2"=E6=98=AF=E9=94=99=E7=9A=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 新增 server/internal/notify/parent_direction_test.go(当前**故意红**) `docs/DEBTS.json` 的 `in-reply-to-ignores-direction` 的**数据层**判据, 与插件侧 `cross-bridge-prompt.test.mjs` 第 5 条配对(一条钉服务端、 一条钉四个桥的读法,两头都红才算这条债被完整挡住)。 ## ★ 更正:上一条 commit(018d5b3)里我说错了一处 我在那里面写「修法:服务端补 `parent_from` 字段**更便宜**,不用多一次 往返」—— 方向对,但**没查证就下了结论**,而且把成本说满了。 现已回读确认,实际比那更便宜: `resolveTarget` 的 `reply_to` 分支(`internal/handler/mail.go:80-85`) **已经把父邮件整行 `repo.GetMailByID` 读进内存**(`mail` 变量), 只用了它的 `SessionID` 就把它丢掉;而 `models.Mail` 上就有 `FromName` (`internal/models/models.go:142`)。 ⇒ 判方向所需的**全部数据已经在函数里**,不需要新查询、不需要新 join、 不需要改表。**这不是"补一个字段",是"别把已经在手的数据扔掉"。** 已在 DEBTS 的 note 里留下更正,不静默改口(与 aab92f17 同一个教训: 说过的话要能在记录里看到被改掉)。 ## 为什么这条判据是「读源码」而不是「跑行为」 缺陷形状是**载荷少一个字段**。直接跑行为可以断言"payload 里有 parent_from",但那要求先在 repo 里造出「父邮件由别人发出」的数据 —— 而造那串数据的前提正是这个字段已经存在 ⇒ **写不出一个不预设修法的红灯**。 故改为按形状断言源码(AST):判据钉 `Recipients`(载荷是它内部的闭包), 找有没有从父邮件取发件人的取值。 ★ 这条判据自己踩了一次同类坑并已修:初版锚的是 `mailToEvent`, 那是我**臆测的函数名**,真机上直接报"找不到"。现已改锚 `Recipients`, 且 `t.Fatal` 的文案明确要求"同步更新判据而不是删掉它" —— 不能因为重构改了函数名就让判据悄悄失去锚点(那正是 `044a664` 的形状: 注释说判据在,而它其实没钉住任何东西)。 ## 验证:判据确实有牙(不是空判) 未修 → 红(报"载荷里没有父邮件发件人"); 模拟加一行 `"parent_from"` 到载荷 → **转绿**;随即完整还原, `git diff` 对 `notify/mail.go` 为空(已复验)。 ★ 第一次模拟时我写成 `m.ParentFrom`(结构体没这个字段)⇒ 编译失败, 那是模拟没写对、不是判据的问题;改成字面量再验,绿。 ## 全量 `GOCACHE=.tmp/gocache go test ./...`:除本条**故意红**的 notify 外全绿 (repo 1.3s / handler 12s / sse / sse 等 16 包)。 ⚠ 默认 `GOCACHE=/root/.cache/go-build` 权限被拒,须显式指定。 ## 共享工作树实况(`shared-workspace-unserialized-deploy` 正在发生) 本次 `git status` 看到 `server/internal/repo/zz_toctou_probe_test.go` 与 `zz_proposedfix_probe_test.go` 两个**不属于我**的未跟踪文件 (opencode 的 throwaway probe,同一包内 `go test ./internal/repo/` 仍绿)。 ⇒ 本 commit **只 stage 我这两个文件**,那两个探针原样留在工作树里未动。 --- docs/DEBTS.json | 2 +- .../internal/notify/parent_direction_test.go | 113 ++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 server/internal/notify/parent_direction_test.go diff --git a/docs/DEBTS.json b/docs/DEBTS.json index 6cd9f44..09612c8 100644 --- a/docs/DEBTS.json +++ b/docs/DEBTS.json @@ -265,7 +265,7 @@ "kind": "**归因错误且无声**:SSE 的 `in_reply_to` 非空就断言「这封是对我上一封信的回复」,不校验父邮件的发件人是不是我 —— 于是单向来信链被逐封读成双向对话", "due": "给 `inboundHeadline` / `replyInstruction` 的 `inReplyTo` 加上方向判据(父邮件的 from == 本方),或让服务端只在父邮件确由收件方发出时才填 `in_reply_to` 时。★ 到期动作不是'在提示词里写清楚'——本次已证明写清楚没用:四封通知的正文里已经逐字写明'回的是你那封:',模型照样每封都去核一遍,然后照样被误导。", "where": "`plugins/*-mail-bridge/lib/relay-policy.js:105-111`(`inboundHeadline`:`if (inReplyTo)` 直接出'你上一封信的回复到了');`plugins/zcode-mail-bridge/src/prompt.mjs:141`(`if (data?.in_reply_to) lines.push('回的是你那封:…')`,无方向判断);服务端 `server/internal/notify/mail.go:202` 把 `ParentMailID` 原样透传;实测线索 `stress-thread-21863-15348`(session aa2a600d,8 封全为 opencode→pi,层号跳过 3/6/9)", - "note": "★★ 2026-09-28 登记(pi 实测,opencode 复核)。\n\n## 形状:**单向 8 封被读成双向 4 轮**\n\n压测线索 `stress-thread-21863-15348` 里 8 封全是 `opencode → pi`,\n`read_thread` 逐层确认,pi 侧一封未发(唯一一次 `send_mail` 被\n'Agent 互发 8 封上限'拦下)。但四封投递通知各自宣称\n'回的是你那封:<上一封的 mail_id>' ——\n\n| 通知 | 宣称的父邮件 | 实际 |\n|---|---|---|\n| 层1 de4e212f | 6298f78a | 6298f78a 是 **opencode 自己的**信 |\n| 层2 e0e8b3c7 | de4e212f | 同上,仍是 opencode 的 |\n| 层4 6358cfc7 | e0e8b3c7 | 同上 |\n\n⇒ 通知里**不存在**一封是 pi 发出的。\n\n## 根因不是'通知乱序',是**方向被省略**\n\n`resolveTarget` 里 `reply_to` 被解析后 `parentMailID = &replyID`\n(`server/internal/handler/mail.go:88`),notify 原样透传(`notify/mail.go:202`)。\n**模型是对的**:父邮件 = 上一封 = 我上一封收到的。\n\n漏掉的是**方向判据**:`inboundHeadline` 只问'有没有父邮件',\n不问'这封父邮件是不是我发的'。单向续信也满足'有父邮件',\n于是一条纯单向的压测链被逐封判定成'对方在回我'。\n\n## 为什么第一封没被骗\n\n6298f78a 走的是真·新会话路径,`parentMailID == nil`\n⇒ `in_reply_to` 为空 ⇒ `inboundHeadline` 落到默认分支\n⇒ 显示'你收到一封新邮件'。**四个桥的同名字符串都在\n`relay-policy.js:111` 这一行**,改动会同时影响 dsh/zcode/opencode/pi。\n\n## 后果:撞 hop 上限,掩盖真实缺陷\n\n每被误判一轮,模型就'处理'一次并回一封,客套到上限被拦\n(生产实测 6 轮)。这次 8 封单向压测消耗的正是这份额度,\n把真正的缺陷挤出了视野。\n\n## 我做过的核对(避免重蹈 aab92f17 的归因错误)\n\n· 4 次 `read_inbox` 全空(unread 与 all 都空,`all` 返回的 8 封全标 `[archived]`);\n· `read_thread` 三次均为 8 封、全 `opencode → pi`、无任何 pi→opencode;\n· 逐封 `read_mail` 核对发件人与正文(正文是'层 N 的正文'占位);\n· 层号跳过 3/6/9,但 mail_id 序列连续 ⇒ **发送侧跳号,不是丢信**;\n· 本条根因定位所依据的行号均已回读原文确认。\n\n## 修法的一处取舍(尚未做)\n\n最小改动是在 `relay-policy.js` 加 `inReplyToFromMe` 判据,但那需要\n父邮件的发件人信息 —— 当前 payload **没有**这个字段(只有 `from_name`\n即本封发件人)。所以两个选项:服务端补一个 `parent_from` 字段,\n或插件侧用 `read_mail(parent_id)` 查一次。**前者更便宜且不用多一次往返**。\n\n★ 顺带记一笔:另有两个观察(`read_inbox` unread 视图与通知不一致、\n`session_participants` 回显把 path 段吞掉)**可能同源** ——\n都指向'通知/展示层没有回读真实数据',但**我没有查证**,不并入本条。" + "note": "★★ 2026-09-28 登记(pi 实测,opencode 复核)。\n\n## 形状:**单向 8 封被读成双向 4 轮**\n\n压测线索 `stress-thread-21863-15348` 里 8 封全是 `opencode → pi`,\n`read_thread` 逐层确认,pi 侧一封未发(唯一一次 `send_mail` 被\n'Agent 互发 8 封上限'拦下)。但四封投递通知各自宣称\n'回的是你那封:<上一封的 mail_id>' ——\n\n| 通知 | 宣称的父邮件 | 实际 |\n|---|---|---|\n| 层1 de4e212f | 6298f78a | 6298f78a 是 **opencode 自己的**信 |\n| 层2 e0e8b3c7 | de4e212f | 同上,仍是 opencode 的 |\n| 层4 6358cfc7 | e0e8b3c7 | 同上 |\n\n⇒ 通知里**不存在**一封是 pi 发出的。\n\n## 根因不是'通知乱序',是**方向被省略**\n\n`resolveTarget` 里 `reply_to` 被解析后 `parentMailID = &replyID`\n(`server/internal/handler/mail.go:88`),notify 原样透传(`notify/mail.go:202`)。\n**模型是对的**:父邮件 = 上一封 = 我上一封收到的。\n\n漏掉的是**方向判据**:`inboundHeadline` 只问'有没有父邮件',\n不问'这封父邮件是不是我发的'。单向续信也满足'有父邮件',\n于是一条纯单向的压测链被逐封判定成'对方在回我'。\n\n## 为什么第一封没被骗\n\n6298f78a 走的是真·新会话路径,`parentMailID == nil`\n⇒ `in_reply_to` 为空 ⇒ `inboundHeadline` 落到默认分支\n⇒ 显示'你收到一封新邮件'。**四个桥的同名字符串都在\n`relay-policy.js:111` 这一行**,改动会同时影响 dsh/zcode/opencode/pi。\n\n## 后果:撞 hop 上限,掩盖真实缺陷\n\n每被误判一轮,模型就'处理'一次并回一封,客套到上限被拦\n(生产实测 6 轮)。这次 8 封单向压测消耗的正是这份额度,\n把真正的缺陷挤出了视野。\n\n## 我做过的核对(避免重蹈 aab92f17 的归因错误)\n\n· 4 次 `read_inbox` 全空(unread 与 all 都空,`all` 返回的 8 封全标 `[archived]`);\n· `read_thread` 三次均为 8 封、全 `opencode → pi`、无任何 pi→opencode;\n· 逐封 `read_mail` 核对发件人与正文(正文是'层 N 的正文'占位);\n· 层号跳过 3/6/9,但 mail_id 序列连续 ⇒ **发送侧跳号,不是丢信**;\n· 本条根因定位所依据的行号均已回读原文确认。\n\n## 修法的一处取舍(尚未做)\n\n最小改动是在 `relay-policy.js` 加 `inReplyToFromMe` 判据,但那需要\n父邮件的发件人信息 —— 当前 payload **没有**这个字段(只有 `from_name`\n即本封发件人)。所以两个选项:服务端补一个 `parent_from` 字段,\n或插件侧用 `read_mail(parent_id)` 查一次。**前者更便宜且不用多一次往返**。\n\n★ 顺带记一笔:另有两个观察(`read_inbox` unread 视图与通知不一致、\n`session_participants` 回显把 path 段吞掉)**可能同源** ——\n都指向'通知/展示层没有回读真实数据',但**我没有查证**,不并入本条。\n\n## 2026-09-28 校正(pi自查,上条那节写得太快)\n\n上次写「修法:服务端补 `parent_from` 字段更便宜」——**方向对,但我没查证\n就下了结论**。现已回读确认,且**比原先想的更便宜**:\n\n`resolveTarget` 的 `reply_to` 分支(`server/internal/handler/mail.go:80-85`)\n**已经把父邮件整行 `repo.GetMailByID` 读进内存**了,只用了它的 `SessionID`\n就把 `mail` 丢掉;而 `models.Mail` 上就有 `FromName`\n(`server/internal/models/models.go:142`)—— 即判据需要的方向信息\n**在函数里现成,不需要任何一次额外查询或新 join**。\n\n⇒ 修法应当是:`resolveTarget` 一并返回父邮件发件人,notify 载荷带出去。\n**这不是\"补一个字段的成本\",是\"别把已经在手的数据扔掉\"。**\n上次那句把它说成新增成本,是我把话说满了。\n\n判据已就位:`client/electron/test/cross-bridge-prompt.test.mjs` 第 5 条\n(当前**故意红**,即本条债的判据)。" } ] } \ No newline at end of file diff --git a/server/internal/notify/parent_direction_test.go b/server/internal/notify/parent_direction_test.go new file mode 100644 index 0000000..afbce44 --- /dev/null +++ b/server/internal/notify/parent_direction_test.go @@ -0,0 +1,113 @@ +package notify + +import ( + "go/ast" + "go/parser" + "go/token" + "testing" +) + +/* + * `in_reply_to` 的**方向判据**:`docs/DEBTS.json` 的 `in-reply-to-ignores-direction`。 + * + * # 这个文件为什么是「读源码」而不是「跑行为」 + * + * 缺陷的形状是**载荷里少了一个字段**(父邮件的发件人),而载荷由 + * `mailToEvent` 之类的事件装配函数拼出。直接跑行为可以断言「某封信的 + * payload 里有 parent_from」,但那要求先在 repo 里造出「父邮件由别人发出」 + * 这串数据 —— 而**造那串数据的前提正是这个字段已经存在**,于是写不出 + * 一个不预设修法的红灯。 + * + * 于是改成**按形状断言源码**:判据只问「判 parent_from 的那个值, + * 是不是从已经读进内存的父邮件上取的」。 + * + * ★ 这条判据**当前是红的**,它就是那笔债的判据。判据先写好、修完转绿。 + */ + +// parentFromOwner 是我们要的形状:它必须**从父邮件上取发件人**。 +// 只钉住「有一个这样的判据」,不钉住它叫什么名字 —— 名字不该成为债的一部分。 +func TestInReplyToCarriesParentSender(t *testing.T) { + // 装配载荷的函数在哪:notify/mail.go 里的事件构造函数。 + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "mail.go", nil, parser.ParseComments) + if err != nil { + t.Fatalf("解析 mail.go 失败:%v", err) + } + + /* + * 载荷不是独立函数,而是 `Recipients` 内部的一个闭包 + * (`payload := func(role, workspace, forName string) map[string]interface{}`), + * 因为它要闭包住 reply_path / self_address 这几个按收件人现算的值。 + * ⇒ 这里锚定 `Recipients`,不锚 `mailToEvent`(那个函数名是本文件 + * 上一版的**臆测**,已删;`t.Fatal` 报的就是它)。 + */ + var payloadFn *ast.FuncDecl + for _, d := range file.Decls { + fd, ok := d.(*ast.FuncDecl) + if ok && fd.Name.Name == "Recipients" { + payloadFn = fd + break + } + } + if payloadFn == nil { + t.Fatal("notify/mail.go 里找不到 Recipients —— " + + "这条判据钉的是它(载荷是它内部的闭包),请同步更新本判据而不是删掉它") + } + + // 判据:载荷里必须能区分「父邮件是我发的」与「父邮件是别人发的」。 + // 按形状找:出现 parent 相关的发件人取值(parent_from / parentFrom …)。 + found := false + ast.Inspect(payloadFn, func(n ast.Node) bool { + switch v := n.(type) { + case *ast.BasicLit: + if v.Kind == token.STRING { + s := v.Value + if contains(s, "parent_from") || contains(s, "parentFrom") { + found = true + } + } + case *ast.Ident: + // 也接受「从父邮件结构体上取 FromName」这种不加新字段的落法: + // 它满足同一个性质,且更便宜(数据本来就在手)。 + if v.Name == "ParentFromName" || v.Name == "ParentFrom" { + found = true + } + } + return true + }) + + if !found { + t.Errorf(`载荷里没有父邮件发件人 ⇒ 插件无法判方向。 + +后果(生产已兜现,docs/DEBTS.json 的 in-reply-to-ignores-direction): + 压测线索 stress-thread-21863-15348 里 8 封全是 opencode → pi, + 投递通知却逐封宣称「回的是你那封:<上一封的 id>」—— 没有一封是 pi 发出的。 + 单向续信链同样满足「有父邮件」,于是纯单向的链被读成双向对话, + Agent 把「收到」当新任务,客套到撞 hop 上限。 + +★ 数据**已经在手**,不需要新查询: + resolveTarget 的 reply_to 分支(internal/handler/mail.go:80-85) + 已经把父邮件整行 GetMailByID 读进内存,只用了 SessionID 就丢掉; + 而 models.Mail 上就有 FromName(internal/models/models.go:142)。 + ⇒ 这不是"补一个字段",是"别把已经在手的数据扔掉"。 + +修法(任一,判据不预选): + · resolveTarget 一并返回父邮件 FromName,填进 notify.Mail, + 载荷带出 parent_from;或 + · 直接复用已读到的父邮件的 FromName,不新增字段。 + +插件侧对读判据:client/electron/test/cross-bridge-prompt.test.mjs 第 5 条。`) + } +} + +func contains(s, sub string) bool { + if len(sub) == 0 { + return true + } + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +}