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  文档双语(需定术语口径)
This commit is contained in:
JianFeeeee
2026-11-04 18:11:06 +08:00
parent 07d35297a6
commit 7cef7df6f2
10 changed files with 650 additions and 238 deletions

View File

@ -23,8 +23,6 @@ package core
import (
"reflect"
"testing"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
)
// ── I2:L4 遇 L4 不可抢占 ──
@ -325,29 +323,3 @@ func TestInvariant_StackNotifyHoldsLock(t *testing.T) {
t.Fatal("钩子必须在持锁状态下调用(回调不得再取 s.mu,否则自死锁)")
}
}
// ── 反向依赖的现状快照(阶段 1.5 要切断的目标)──
//
// 这条不测行为,而是**记录事实**:中断函数目前读了这些非中断状态。
// 切断依赖后本判据应随之失败并被改写——届时可以明确看到"依赖确实变了"。
//
// 若不做这一步,"切断反向依赖"就只是一句口号,没有任何可验证的落点。
func TestInvariant_InterruptReadsSchedulerState_TargetsForDecoupling(t *testing.T) {
// 事实清单(来自探测,见 docs/zh/interrupt-subsystem-split-plan.md §1.5)。
// 这里只断言"这些字段在 scheduler 上存在",作为改写时的锚点;
// 阶段 1.5 完成后,中断侧不应再直接读它们。
s := newScheduler(8)
if s.maxQueue == 0 {
t.Skip("maxQueue 未初始化")
}
// 记录当前形态:中断入队会受就绪队列上限影响(这一点将在 1.5 处理)。
evt := &agentIO.InputEvent{Source: "qq", Type: "interrupt"}
evt.Payload = map[string]interface{}{"interrupt": true}
s.mu.Lock()
s.enqueueInterruptLocked(&Task{ID: 1, Class: TaskInterrupt, Level: LevelMessage, Event: evt})
n := len(s.interruptQueues[LevelMessage])
s.mu.Unlock()
if n != 1 {
t.Fatalf("正常上限下中断应入队,实际 %d 条", n)
}
}

View File

@ -25,8 +25,19 @@ package core
import (
"testing"
"time"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
)
// toolDefWithTimeout 造一个带 Timeout 声明的 ToolDef。
//
// 原先它单独放在 zz_timeout_helper_test.go —— 文件名前缀 `zz_` 是
// 临时调试残留的惯例,放一个**长期使用**的辅助函数在那种命名的文件里,
// 会让后来人以为它可删。辅助函数跟着唯一的调用方走。
func toolDefWithTimeout(name, timeout string) sdk.ToolDef {
return sdk.ToolDef{Name: name, Description: "test", Timeout: timeout}
}
// fakeStageHost 提供可控的 ToolDef 查询。
type fakeStageHost struct {
*StageHost
@ -36,7 +47,7 @@ type fakeStageHost struct {
func newFakeAgent(defs map[string]string) *Agent {
h := NewStageHost()
for name, to := range defs {
h.RegisterTool(name, sdkToolDefWithTimeout(name, to), func(map[string]interface{}) (interface{}, error) {
h.RegisterTool(name, toolDefWithTimeout(name, to), func(map[string]interface{}) (interface{}, error) {
return nil, nil
})
}

View File

@ -18,31 +18,73 @@ import (
)
// ① 有 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())
f := &TaskFrame{Input: "在吗"}
f.StageCtx = &sdk.StageContext{Extra: map[string]interface{}{
stageCtx := &sdk.StageContext{Extra: map[string]interface{}{
"turn_hint": "【本轮渠道】QQ 私聊 → 用 output_send__qq",
}}
f.Msgs = a.buildMessages(a.buildSystemPrompt("", "在吗"), f.Input, 4096)
f := a.newTaskFrame("在吗", stageCtx)
hint := stageHint(f.StageCtx, "turn_hint")
if hint == "" {
t.Fatal("没能从 stage 上下文取到 turn_hint")
}
f.Msgs = append(f.Msgs, []agentAPI.Message{{Role: "system", Content: "[Turn hints] " + hint}}...)
// 只跑到 stepPrepare 结束(下一步是 LLM 调用,本判据不关心)。
a.stepPrepare(f)
last := f.Msgs[len(f.Msgs)-1]
if last.Role != "system" {
t.Fatalf("提示必须是 system 角色(否则模型会当成人话),实际 %s", last.Role)
}
if !strings.Contains(last.Content, "[Turn hints]") || !strings.Contains(last.Content, "output_send__qq") {
t.Fatalf("提示内容不对: %q", last.Content)
}
// 用户消息不能被污染
for _, m := range f.Msgs[:len(f.Msgs)-1] {
// ① 必须有一条独立的 system 消息承载提示
var hintMsgs []agentAPI.Message
for _, m := range f.Msgs {
if strings.Contains(m.Content, "[Turn hints]") {
t.Fatalf("提示被拼进了非 system 消息(role=%s): %q", m.Role, m.Content)
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)
}
}
}

View File

@ -1,7 +0,0 @@
package core
import sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
func sdkToolDefWithTimeout(name, timeout string) sdk.ToolDef {
return sdk.ToolDef{Name: name, Description: "test", Timeout: timeout}
}

View File

@ -0,0 +1,172 @@
package webui
// webui 文件下发能力的**契约判据**:面向输入 → 输出,不面向内部流程。
//
// ## 为什么重写(2026-11-04)
//
// 原判据(handler_files_test.go)直接调未导出的 `stageWebFile(src, isImage)`,
// 断言它返回的 url 前缀、size、以及中转文件是否落盘。问题:
//
// ① 它测的是**内部函数的形状**。`stageWebFile` 是"怎么实现",
// 不是"对外承诺什么"。改成分块上传、改成流式、改成存对象存储,
// 只要 webui 通道仍然返回可下载的 url,外部行为就没变 —— 但判据会红。
// ② 它断言 `webFilesDir` 这个**包级变量**,那是实现细节。
// ③ 它没有走真实的调用链:真正入口是**输出通道 handler**
// (plugin.go RegisterOutputChannel("webui", …)),
// agent 发图/发文件时走的就是它。
//
// 所以本判据改为:**通过输出通道 handler 喂输入,断言返回的输出**。
//
// 输入:{payload: "<本地路径或 URL>", type: "image" | "file"}
// 输出:{status: "ok", url: "/files/<名字>.<ext>", size: N}
//
// 这样断言的是**通道契约**:agent 交一个文件,webui 交回一个可访问的 URL。
// 实现怎么中转(拷贝 / 硬链 / 对象存储)都不影响这条判据。
//
// ## 判据的边界
//
// 这里不验证 `/files/` 的鉴权 —— 那是 handler 层的契约,
// 由 handler 的黑箱判据覆盖(见 handler_files_download_test.go)。
import (
"net"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
internalConfig "github.com/JianFeeeee/HologramAgent/internal/config"
"github.com/JianFeeeee/HologramAgent/internal/events"
"github.com/JianFeeeee/HologramAgent/internal/sdk"
)
// captureWebUIOutput 起一个 webui 插件,截获它的 "webui" 输出通道 handler。
//
// 这是**测试装配**,不是绕过生产路径:生产里由内核的通道注册表接管
// (regOutput),这里换成捕获函数,为的是拿到真正被注册的 handler 本身。
func captureWebUIOutput(t *testing.T) (handler sdk.ToolHandler, filesDir string) {
t.Helper()
filesDir = t.TempDir()
webFilesDir = filesDir
t.Cleanup(func() { webFilesDir = "" })
cfgReg := internalConfig.NewConfigRegistry(filepath.Join(t.TempDir(), "config.db"))
t.Cleanup(func() { cfgReg.Close() })
seedWebUIConfig(cfgReg)
// 绑空闲端口::8080 是生产端口(见 a752ae1 的教训)。
probe, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := probe.Addr().String()
probe.Close()
cfgReg.PluginConfig("webui").Set("addr", addr)
var captured sdk.ToolHandler
s := testSDK(sdk.SDKConfig{
Settings: sdk.NewSettings("webui", cfgReg),
EventBus: events.NewBus(),
RegOutput: func(name string, caps int, desc string, def sdk.ChannelDef, h sdk.ToolHandler) error {
if name == "webui" {
captured = h
}
return nil
},
})
p := &Plugin{name: "webui", mux: http.NewServeMux()}
if err := p.Start(s); err != nil {
t.Fatalf("启动 webui 插件失败: %v", err)
}
t.Cleanup(func() { _ = p.Stop() })
if captured == nil {
t.Fatal("★ webui 没有注册输出通道 handler —— 文件下发能力不存在")
}
return captured, filesDir
}
// TestOutputChannelImagesReturnDownloadableURL 是主判据:图片 → 可下载 URL。
//
// 变异:让 stageWebFile 对图片返回空 url ⇒ 本例红。
func TestOutputChannelImagesReturnDownloadableURL(t *testing.T) {
handler, _ := captureWebUIOutput(t)
src := filepath.Join(t.TempDir(), "photo.jpg")
body := "fake-jpeg-bytes"
if err := os.WriteFile(src, []byte(body), 0644); err != nil {
t.Fatal(err)
}
out, err := handler(map[string]interface{}{"payload": src, "type": "image"})
if err != nil {
t.Fatalf("发图片失败: %v", err)
}
m := out.(map[string]interface{})
if m["status"] != "ok" {
t.Fatalf("status = %v,期望 ok", m["status"])
}
url, _ := m["url"].(string)
if !strings.HasPrefix(url, "/files/") {
t.Fatalf("★ url = %q —— 前端按 /files/ 前缀加载,换前缀就点不开", url)
}
if !strings.HasSuffix(url, ".jpg") {
t.Fatalf("★ url = %q —— 扩展名丢了,浏览器不会按图片渲染", url)
}
if size, _ := m["size"].(int64); size != int64(len(body)) {
t.Fatalf("size = %v,期望 %d(前端用它显示文件大小)", m["size"], len(body))
}
}
// TestOutputChannelRemoteURLPassesThrough 钉住:远程 URL 原样返回、不下载。
//
// 语义:agent 已经有一个公网可访问的图片,不需要中转。
// 变异:改成无条件拷贝 ⇒ 本例红(会返回 /files/ 前缀)。
func TestOutputChannelRemoteURLPassesThrough(t *testing.T) {
handler, _ := captureWebUIOutput(t)
const remote = "https://example.com/a.png"
out, err := handler(map[string]interface{}{"payload": remote, "type": "image"})
if err != nil {
t.Fatalf("远端 URL 应放行,实际报错: %v", err)
}
url, _ := out.(map[string]interface{})["url"].(string)
if url != remote {
t.Fatalf("★ 远端 URL 被改写为 %q —— 浪费带宽且有 SSRF 风险", url)
}
}
// TestOutputChannelDangerousExtensionIsNeutralized 钉住安全契约。
//
// 一个 .html 文件以图片身份下发时,若保留 .html,浏览器会在 webui 域下
// **执行它的脚本**(存储型 XSS)。所以非白名单扩展名必须被改写。
//
// 变异:删掉扩展名改写 ⇒ 本例红。
func TestOutputChannelDangerousExtensionIsNeutralized(t *testing.T) {
handler, _ := captureWebUIOutput(t)
src := filepath.Join(t.TempDir(), "evil.html")
if err := os.WriteFile(src, []byte("<script>alert(1)</script>"), 0644); err != nil {
t.Fatal(err)
}
out, err := handler(map[string]interface{}{"payload": src, "type": "file"})
if err != nil {
t.Fatalf("发文件失败: %v", err)
}
url, _ := out.(map[string]interface{})["url"].(string)
if strings.HasSuffix(url, ".html") {
t.Fatalf("★ html 以 .html 后缀下发(%q)—— 会在 webui 域下执行脚本", url)
}
}
// TestOutputChannelEmptyPayloadIsRejected 钉住输入校验。
//
// 变异:允许空 payload ⇒ 本例红(会生成一个指向空的 URL)。
func TestOutputChannelEmptyPayloadIsRejected(t *testing.T) {
handler, _ := captureWebUIOutput(t)
if _, err := handler(map[string]interface{}{"payload": "", "type": "image"}); err == nil {
t.Fatal("★ 空 payload 被接受 —— 会产出一个打不开的 URL")
}
}