Files
HomeAgent/internal/agent/core/turn_hint_test.go
JianFeeeee 7cef7df6f2 test: 判据改为面向接口输入输出 + 文档立规 + CI 补缺口
本提交是三件事的第一批,后续按计划分步推进。

## 一、判据改造(两条实证)

### 1. turn_hint 判据原是自证循环

原测试在测试里**手动重放**了真实注入逻辑:

    f.Msgs = a.buildMessages(...)
    hint := stageHint(f.StageCtx, "turn_hint")
    f.Msgs = append(f.Msgs, Message{Role:"system", Content:"[Turn hints] "+hint})
    // 然后断言"最后一条是 system、内容含 [Turn hints]"

它断言的是**它自己刚 append 的那一行** —— 把 task.go:656-661 抄进测试
再验证抄得对不对。真实代码那段删掉,它照样绿。

改为:把 turn_hint 放进 StageCtx,跑**真实的 stepPrepare**
(newTaskFrame 起始就是 StepPrepare),检查它产出的 Msgs。

变异验证:删掉 task.go 里 `if hint := stageHint(...)` 那段 ⇒ 实测红。

### 2. webui 文件下发判据原是白盒

原测试直接调未导出的 `stageWebFile(src, isImage)`、断言 `webFilesDir`
包级变量 —— 那都是**实现方式**。

改为从**输出通道 handler** 走(`RegisterOutputChannel("webui", …)`,
agent 发图/发文件时走的就是它):

    输入:{payload: "<路径或URL>", type: "image"|"file"}
    输出:{status:"ok", url:"/files/<name>.<ext>", size:N}

4 条判据(新版 handler_files_contract_test.go):
  · 图片 → 可下载 URL(前缀/扩展名/size)
  · 远端 URL 原样透传(不下载)
  · 危险扩展名被中和(.html 保留会在 webui 域执行脚本)
  · 空 payload 被拒

变异验证:丢扩展名 ⇒ 红;远端也拷贝 ⇒ 红。

## 二、文档立规:engineering-lessons 新增第五节

原来只有「判据会骗人」,没有「判据本身形状错了」。新增 6 小节:

    5.1  判据必须把被测模块当黑箱(含"如果重写内部实现、接口不变,
         这条判据还会绿吗"这个可操作判据)
    5.2  禁止读源码做断言(附正确替代表)
    5.2.1 判据不得以文档为依据
    5.3  测试文件与业务代码分离(现状:521 文件 / 11.2 万行,
         agent/core 一个包 196 文件里 144 个是测试)
    5.4  CI 必须覆盖每个独立 module
    5.5  CI 与本地必须跑同一套门

§5.2.1 的直接触发:`interrupt_invariant_test.go` 里有一条判据写着
「事实清单(来自探测,见 docs/zh/interrupt-subsystem-split-plan.md §1.5)」
—— 被引用方自己标着「状态:计划(未实施)」,而那条判据的实际断言
只是"入队后队列里 1 条",与声称的"反向依赖现状快照"毫无关系。
该判据已删除(入队行为已由 scheduler_preempt_test 的黑箱断言覆盖)。

## 三、CI 补两个缺口

### gofmt 门

提交纪律要求 `gofmt -l` 干净,但三个 workflow 里 `gofmt` 出现 **0 次**
—— 纪律只靠人自觉。补上后未格式化的代码进不了主干。

只查本仓自管目录:SDK 是独立仓,格式由它自己的 CI 管。

### SDK 判据步骤

`third_party/hologramagentsdk/` 有自己独立的 go.mod:

    go list ./... | grep third_party   →  0 个

主仓 `go test ./...` **永远不包含** SDK 任何测试。实测 SDK 有 30 个判据
文件,主仓 CI 一次都没跑过。

补的步骤只跑主仓 git 跟踪的文件(CI 上只有 22 个 vendored 文件,
工作机上是完整独立仓)—— 实测 `git archive` 出 22 个文件后
`go test ./sdk/ ./meta/` 全绿。

## 四、SDK 仓测试迁移(独立仓,已单独提交 3a38ef5)

`sdk/{plugin,proxy,stress}_test.go` → `sdk/test/`,包名 `sdk` → `test`。

关键:原测试直接写 `&PluginSDK{regStage: X, name: "test"}` —— **私有字段**。
而 `New(name, sett, regTool, regStage, regAPI, regOutput)` 本来就把所有
registrar 作为参数,等价且是生产路径。共改 19 处,0 处剩余。

主仓同步更新了跟踪路径(这三个文件主仓也 vendor 了一份)。

## 验证

    go build ./...            PASS
    go vet ./...              PASS
    gofmt -l                  干净
    go test ./... -count=1    62 包全过,0 FAIL
    干净检出(worktree)       build + 改造后的判据全绿

## 后续(按计划分步)

  步骤 2  webui 29 个测试分类迁移(已实测出精确分类)
  步骤 3  agent/core 739 处白盒引用先分类再动
  步骤 4  SDK tools/hmapdev(package main,无法迁出,只加说明)
  步骤 5  CI 补 coverage + staticcheck
  步骤 6  文档双语(需定术语口径)
2026-11-04 18:11:06 +08:00

104 lines
3.9 KiB
Go
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.

package core
// 插件本轮提示(SDK InjectOptions.TurnHint)的注入判据。
//
// 设计分工(用户 2026-10-26):
//
// 人设(personal.md) 怎么说话 —— 静态
// TurnHint 这一轮的事实 —— 动态,由插件声明
//
// 关键约束:提示必须作为**独立 system 消息**出现,绝不能拼进用户正文。
import (
"strings"
"testing"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
)
// ① 有 TurnHint ⇒ 出现一条独立的 system 消息,用户消息原样不动。
//
// ## 这条判据曾经是假的(2026-11-04 修)
//
// 原实现自己在测试里**手动重放**了真实注入逻辑:
//
// f.Msgs = a.buildMessages(...)
// hint := stageHint(f.StageCtx, "turn_hint")
// f.Msgs = append(f.Msgs, agentAPI.Message{Role: "system", Content: "[Turn hints] " + hint})
// // 然后断言 "最后一条是 system、内容含 [Turn hints]"
//
// 它断言的是**它自己刚 append 的那一行** —— 把 task.go:656-661 的实现
// 抄进测试,再验证抄得对不对。真实代码里那段被删掉,这条判据照样绿。
//
// 正确做法:把 turn_hint 放进 StageCtx,跑**真实的 stepPrepare**
// (newTaskFrame 起始就是 StepPrepare,runTaskSteps 会执行它),
// 然后检查它产出的 Msgs。这样才测到 task.go 那段注入。
//
// 变异:删掉 task.go 里 `if hint := stageHint(f.StageCtx, "turn_hint")`
// 那一段 ⇒ 本例红。
func TestTurnHintInjectedAsSeparateSystemMessage(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
stageCtx := &sdk.StageContext{Extra: map[string]interface{}{
"turn_hint": "【本轮渠道】QQ 私聊 → 用 output_send__qq",
}}
f := a.newTaskFrame("在吗", stageCtx)
// 只跑到 stepPrepare 结束(下一步是 LLM 调用,本判据不关心)。
a.stepPrepare(f)
// ① 必须有一条独立的 system 消息承载提示
var hintMsgs []agentAPI.Message
for _, m := range f.Msgs {
if strings.Contains(m.Content, "[Turn hints]") {
hintMsgs = append(hintMsgs, m)
}
}
if len(hintMsgs) != 1 {
t.Fatalf("★ 含 [Turn hints] 的消息有 %d 条,期望恰好 1 条 ——\n"+
"0 条 = 注入逻辑没生效;多条 = 被重复追加", len(hintMsgs))
}
if hintMsgs[0].Role != "system" {
t.Fatalf("★ 提示必须是 system 角色(否则模型会当成人话),实际 %s", hintMsgs[0].Role)
}
if !strings.Contains(hintMsgs[0].Content, "output_send__qq") {
t.Fatalf("★ 提示内容丢了(%q)", hintMsgs[0].Content)
}
// ② 用户消息不能被污染:user 角色的消息里不能出现 [Turn hints]
for _, m := range f.Msgs {
if m.Role == "user" && strings.Contains(m.Content, "[Turn hints]") {
t.Fatalf("★ 提示被拼进了 user 消息: %q", m.Content)
}
}
}
// ①b 没有 TurnHint ⇒ 真实的 stepPrepare 不产出任何 [Turn hints] 消息。
//
// 与 ① 成对:只测"有"会放过"永远注入"的实现。
func TestNoTurnHintNoSystemMessageFromPrepare(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
f := a.newTaskFrame("在吗", &sdk.StageContext{Extra: map[string]interface{}{}})
a.stepPrepare(f)
for _, m := range f.Msgs {
if strings.Contains(m.Content, "[Turn hints]") {
t.Fatalf("★ 没给 turn_hint 却注入了提示: role=%s content=%q", m.Role, m.Content)
}
}
}
// ② 没有 TurnHint ⇒ 不追加任何消息(零行为变化)。
func TestNoTurnHintAppendsNothing(t *testing.T) {
if got := stageHint(&sdk.StageContext{Extra: map[string]interface{}{}}, "turn_hint"); got != "" {
t.Fatalf("没有提示时应返回空,实际 %q", got)
}
if got := stageHint(nil, "turn_hint"); got != "" {
t.Fatalf("nil 上下文应返回空,实际 %q", got)
}
if got := stageHint(&sdk.StageContext{Extra: map[string]interface{}{"turn_hint": " "}}, "turn_hint"); got != "" {
t.Fatalf("纯空白应视为没有提示,实际 %q", got)
}
}