test(网关): 载荷必须带父邮件发件人 —— 并更正我说它"要新增查询"是错的

## 新增 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 我这两个文件**,那两个探针原样留在工作树里未动。
This commit is contained in:
2026-09-28 10:18:43 +08:00
parent 018d5b3bd8
commit f1c74fc4ce
2 changed files with 114 additions and 1 deletions

View File

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