Files
HomeAgent/third_party/hologramagentsdk/sdk/test/plugin_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

255 lines
6.9 KiB
Go

package test
import (
sdk "github.com/JianFeeeee/hologramagentsdk/sdk"
"testing"
)
func TestRegisterStageGlobalDefault(t *testing.T) {
called := false
regStage := func(stage sdk.Stage, handler sdk.StageHandler) {
called = true
}
s := sdk.New("test", nil, nil, regStage, nil, nil)
s.RegisterStage(sdk.StageBeforeToolcall, func(ctx *sdk.StageContext) error { return nil })
if !called {
t.Error("global scope: handler not registered")
}
}
func TestRegisterStageGlobalExplicit(t *testing.T) {
called := false
regStage := func(stage sdk.Stage, handler sdk.StageHandler) {
called = true
}
s := sdk.New("test", nil, nil, regStage, nil, nil)
s.RegisterStage(sdk.StageBeforeToolcall, func(ctx *sdk.StageContext) error { return nil }, sdk.StageScopeGlobal)
if !called {
t.Error("global scope: handler not registered")
}
}
func TestRegisterStageOwnToolsMatch(t *testing.T) {
var registered sdk.StageHandler
regStage := func(stage sdk.Stage, handler sdk.StageHandler) {
registered = handler
}
s := sdk.New("myplugin", nil, nil, regStage, nil, nil)
s.RegisterStage(sdk.StageBeforeToolcall, func(ctx *sdk.StageContext) error { return nil }, sdk.StageScopeOwnTools)
if registered == nil {
t.Fatal("handler not registered")
}
ctx := &sdk.StageContext{}
ctx.ToolCalls = []sdk.ToolCall{{Plugin: "myplugin", Name: "my_tool"}}
ctx.ToolResults = nil
err := registered(ctx)
if err != nil {
t.Errorf("expected nil, got %v", err)
}
}
func TestRegisterStageOwnToolsSkipOtherPlugin(t *testing.T) {
var registered sdk.StageHandler
regStage := func(stage sdk.Stage, handler sdk.StageHandler) {
registered = handler
}
s := sdk.New("myplugin", nil, nil, regStage, nil, nil)
callCount := 0
s.RegisterStage(sdk.StageBeforeToolcall, func(ctx *sdk.StageContext) error {
callCount++
return nil
}, sdk.StageScopeOwnTools)
if registered == nil {
t.Fatal("handler not registered")
}
ctx := &sdk.StageContext{}
ctx.ToolCalls = []sdk.ToolCall{{Plugin: "other", Name: "other_tool"}}
err := registered(ctx)
if err != nil {
t.Errorf("expected nil, got %v", err)
}
if callCount != 0 {
t.Error("handler should not be called for other plugin's tool")
}
}
func TestRegisterStageOwnToolsNonToolcallDegrades(t *testing.T) {
regStage := func(stage sdk.Stage, handler sdk.StageHandler) {
if stage != sdk.StagePreAction {
t.Errorf("expected sdk.StagePreAction, got %s", stage)
}
}
s := sdk.New("test", nil, nil, regStage, nil, nil)
s.RegisterStage(sdk.StagePreAction, func(ctx *sdk.StageContext) error { return nil }, sdk.StageScopeOwnTools)
}
func TestRegisterStageOwnToolsStageBeforeToolcallNoToolCalls(t *testing.T) {
var registered sdk.StageHandler
regStage := func(stage sdk.Stage, handler sdk.StageHandler) {
registered = handler
}
s := sdk.New("myplugin", nil, nil, regStage, nil, nil)
callCount := 0
s.RegisterStage(sdk.StageBeforeToolcall, func(ctx *sdk.StageContext) error {
callCount++
return nil
}, sdk.StageScopeOwnTools)
if registered == nil {
t.Fatal("handler not registered")
}
ctx := &sdk.StageContext{}
err := registered(ctx)
if err != nil {
t.Errorf("expected nil, got %v", err)
}
if callCount != 0 {
t.Error("handler should not be called when ToolCalls is empty")
}
}
func TestRegisterStageOwnToolsStageAfterToolcallMatch(t *testing.T) {
var registered sdk.StageHandler
regStage := func(stage sdk.Stage, handler sdk.StageHandler) {
registered = handler
}
s := sdk.New("myplugin", nil, nil, regStage, nil, nil)
callCount := 0
s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {
callCount++
return nil
}, sdk.StageScopeOwnTools)
if registered == nil {
t.Fatal("handler not registered")
}
ctx := &sdk.StageContext{}
ctx.ToolResults = []sdk.ToolResult{{Plugin: "myplugin", Name: "my_tool"}}
err := registered(ctx)
if err != nil {
t.Errorf("expected nil, got %v", err)
}
if callCount != 1 {
t.Error("handler should be called for own plugin's tool result")
}
}
func TestRegisterStageOwnToolsStageAfterToolcallSkip(t *testing.T) {
var registered sdk.StageHandler
regStage := func(stage sdk.Stage, handler sdk.StageHandler) {
registered = handler
}
s := sdk.New("myplugin", nil, nil, regStage, nil, nil)
callCount := 0
s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {
callCount++
return nil
}, sdk.StageScopeOwnTools)
ctx := &sdk.StageContext{}
ctx.ToolResults = []sdk.ToolResult{{Plugin: "other", Name: "other_tool"}}
err := registered(ctx)
if err != nil {
t.Errorf("expected nil, got %v", err)
}
if callCount != 0 {
t.Error("handler should not be called for other plugin's tool result")
}
}
func TestRegisterStageOwnToolsNilRegStage(t *testing.T) {
s := sdk.New("test", nil, nil, nil, nil, nil)
s.RegisterStage(sdk.StageBeforeToolcall, func(ctx *sdk.StageContext) error { return nil }, sdk.StageScopeOwnTools)
}
func TestToolDefCleaner(t *testing.T) {
called := false
def := sdk.ToolDef{
Name: "test_clean",
Description: "A test tool with cleaner",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
Cleaner: func(output string) string {
called = true
return "cleaned:" + output
},
}
if def.Cleaner == nil {
t.Fatal("Cleaner should not be nil")
}
result := def.Cleaner("raw output")
if !called {
t.Error("Cleaner was not called")
}
if result != "cleaned:raw output" {
t.Errorf("expected 'cleaned:raw output', got '%s'", result)
}
}
func TestToolDefNoMemory(t *testing.T) {
def := sdk.ToolDef{
Name: "test_nomem",
Description: "A test tool with NoMemory",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
NoMemory: true,
}
if !def.NoMemory {
t.Error("NoMemory should be true")
}
if def.Cleaner != nil {
t.Error("Cleaner should be nil when not set")
}
}
func TestToolDefNoMemoryDefaultFalse(t *testing.T) {
def := sdk.ToolDef{
Name: "test_default",
Description: "A test tool with defaults",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
}
if def.NoMemory {
t.Error("NoMemory should default to false")
}
}
func TestToolDefRegisterPreservesNoMemory(t *testing.T) {
var capturedDef sdk.ToolDef
regTool := func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
capturedDef = def
return nil
}
s := sdk.New("test", nil, regTool, nil, nil, nil)
def := sdk.ToolDef{
Name: "test_tool",
Description: "test desc",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
NoMemory: true,
Cleaner: func(s string) string { return s },
}
s.RegisterTool("test_tool", def, func(args map[string]interface{}) (interface{}, error) {
return nil, nil
})
if !capturedDef.NoMemory {
t.Error("NoMemory should be preserved through RegisterTool")
}
if capturedDef.Cleaner == nil {
t.Error("Cleaner should be preserved through RegisterTool")
}
}