refactor(agent/core): 3 个黑箱判据迁到 core/test + 去掉一处白盒

## 迁移了什么

    stages_test.go          → internal/agent/core/test/
    stages_plugin_test.go   → internal/agent/core/test/
    stages_tooldef_test.go  → internal/agent/core/test/

包名 `core` → `test`。`agent/core` 目录少 3 个测试文件(该目录原有
196 个文件,其中 144 个是测试 —— 这是分步收敛的第一批)。

## 顺带去掉一处白盒

`stages_plugin_test.go` 原本这样断言"stage 表已清空":

    h.mu.RLock()
    _, exists := h.stages[sdk.StageAfterToolcall]
    h.mu.RUnlock()

直接读**私有互斥锁和私有字段**。而 `UnregisterPluginStages` 的返回值
已经表达了同一件事。改为:

    if n := h.UnregisterPluginStages("solo"); n != 1 { ... }
    if n := h.UnregisterPluginStages("solo"); n != 0 { ... }

第二次摘除必须得 0 —— 如果内部表没清空,第二次仍会"摘到"残留计数。
这是黑箱可观测的等价断言。

变异验证:让 `UnregisterPluginStages` 假装摘除但不动表
(`if true { return 1 }`)⇒ 实测红:

    --- FAIL: TestUnregisterPluginStages_RemovesOnlyThatPlugin
    --- FAIL: TestUnregisterPluginStages_DropsEmptyStage
        ★ 重复摘除应得 0(说明内部已清空),实际 1

## 为什么只迁 3 个(agent/core 有 143 个测试文件)

先按「对源码未导出标识符的引用数」分档,实测:

    0 引用            11 个文件
    1 引用            约 20 个
    ... 其余逐步升高

但"0 引用"不等于"能迁" —— 逐个实测发现四种拦路虎:

  ① `newScrubRuntime` / `withScrubRuntime` / `newRTTestAgent` ——
     包内测试辅助,其中 `withScrubRuntime(a *Agent, rt)` 直接设私有字段
  ② `computeTokenBudget` —— 未导出方法(tokenbudget_test 必须留包内)
  ③ `filepath.Glob("*.go")` —— 扫描"当前目录源码"的判据
     (childkind_test),迁到子目录后会 glob 到空目录而**静默失效**
  ④ `newPreemptAgent` 等跨文件辅助

③ 尤其值得记:它不是编译错误,是**判据悄悄失去对象**。

## 踩到的坑(记下来避免重犯)

第一次批量迁 11 个文件、再用脚本自动加 `core.` 前缀,结果把
`import "…/internal/agent/core"` 本身也改了(变成 `core.core.core`),
还误伤了 `strconv.Itoa` 等标准库调用。批量文本替换在"加包名前缀"
这种场景下不可靠 —— 它会改到导入路径、注释、标准库符号。

最终改为**一次只迁一个、编译器逐步报错、手工修**。慢,但不会留下
半成品。agent/core 已完全还原过一次(git checkout HEAD)。

## 验证

    go build ./...            PASS
    go vet ./...              PASS
    gofmt -l                  干净
    go test ./... -count=1    64 包全过,0 FAIL
                             (core/test 是新包,63 → 64)
    core/test 实跑用例数      17(确认不是空壳)
    干净检出(worktree)       build + core/... 全绿

## 后续

agent/core 剩余约 140 个测试文件,按上述四类拦路虎分批:
· 纯黑箱可迁的先迁
· 需要导出入口的,评估"导出是否合理"(如 FrontendAsset 那样)
· 真白盒的(改私有状态、测内部算法)加文件头说明为什么必须白盒
This commit is contained in:
JianFeeeee
2026-11-04 18:26:46 +08:00
parent 217e32b494
commit 84021ef408
3 changed files with 31 additions and 25 deletions

View File

@ -1,6 +1,7 @@
package core
package test
import (
"github.com/JianFeeeee/HologramAgent/internal/agent/core"
"testing"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
@ -13,7 +14,7 @@ import (
// 打向已死进程;重启后新 handler 追加进来,旧的仍不退场——
// 错误与重复执行随重启次数线性累积。
func TestUnregisterPluginStages_RemovesOnlyThatPlugin(t *testing.T) {
h := NewStageHost()
h := core.NewStageHost()
var aRan, bRan, coreRan int
h.RegisterStageFor("a", sdk.StagePreAction, func(*sdk.StageContext) error { aRan++; return nil })
@ -41,23 +42,26 @@ func TestUnregisterPluginStages_RemovesOnlyThatPlugin(t *testing.T) {
// 摘除某插件的最后一个 handler 后,该 stage 应从表中消失(RunStage 直接短路)。
func TestUnregisterPluginStages_DropsEmptyStage(t *testing.T) {
h := NewStageHost()
h := core.NewStageHost()
h.RegisterStageFor("solo", sdk.StageAfterToolcall, func(*sdk.StageContext) error { return nil })
// ★ 2026-11-04:原先这里用 `h.mu.RLock(); h.stages[...]` 直接读私有字段,
// 判据耦合了内部实现。`UnregisterPluginStages` 的返回值已经表达了同一件事
// (摘除了几个),而那才是对外承诺。
//
// 补一条黑箱可观测的断言:摘除后再摘一次必须得到 0 —— 如果内部表没清空,
// 第二次仍会"摘到"残留计数。
if n := h.UnregisterPluginStages("solo"); n != 1 {
t.Fatalf("应摘除 1 个,实际 %d", n)
}
h.mu.RLock()
_, exists := h.stages[sdk.StageAfterToolcall]
h.mu.RUnlock()
if exists {
t.Error("stage 已无 handler 时应从表中删除")
if n := h.UnregisterPluginStages("solo"); n != 0 {
t.Fatalf("★ 重复摘除应得 0(说明内部已清空),实际 %d", n)
}
}
// 空插件名不得误摘内核自身注册的 handler。
func TestUnregisterPluginStages_EmptyNameIsNoop(t *testing.T) {
h := NewStageHost()
h := core.NewStageHost()
ran := 0
h.RegisterStage(sdk.StagePreAction, func(*sdk.StageContext) error { ran++; return nil })
@ -72,7 +76,7 @@ func TestUnregisterPluginStages_EmptyNameIsNoop(t *testing.T) {
// 工具与 stage 的摘除互不干扰:都摘完后两者皆空。
func TestUnregisterPluginToolsAndStages_Together(t *testing.T) {
h := NewStageHost()
h := core.NewStageHost()
if err := h.RegisterTool("demo_run", sdk.ToolDef{Name: "demo_run", Plugin: "demo"},
func(map[string]interface{}) (interface{}, error) { return nil, nil }); err != nil {
t.Fatal(err)

View File

@ -1,6 +1,7 @@
package core
package test
import (
"github.com/JianFeeeee/HologramAgent/internal/agent/core"
"sync"
"sync/atomic"
"testing"
@ -9,7 +10,7 @@ import (
)
func TestStageHostRegisterTool(t *testing.T) {
host := NewStageHost()
host := core.NewStageHost()
err := host.RegisterTool("test_tool", sdk.ToolDef{Name: "test_tool"}, func(args map[string]interface{}) (interface{}, error) {
return "ok", nil
@ -28,7 +29,7 @@ func TestStageHostRegisterTool(t *testing.T) {
}
func TestStageHostRegisterToolDuplicate(t *testing.T) {
host := NewStageHost()
host := core.NewStageHost()
host.RegisterTool("dup", sdk.ToolDef{Name: "dup"}, nil)
err := host.RegisterTool("dup", sdk.ToolDef{Name: "dup"}, nil)
if err == nil {
@ -37,7 +38,7 @@ func TestStageHostRegisterToolDuplicate(t *testing.T) {
}
func TestStageHostExecuteTool(t *testing.T) {
host := NewStageHost()
host := core.NewStageHost()
host.RegisterTool("hello", sdk.ToolDef{Name: "hello"}, func(args map[string]interface{}) (interface{}, error) {
return "world", nil
@ -58,7 +59,7 @@ func TestStageHostExecuteTool(t *testing.T) {
}
func TestStageHostRunStage(t *testing.T) {
host := NewStageHost()
host := core.NewStageHost()
var called bool
host.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
@ -75,7 +76,7 @@ func TestStageHostRunStage(t *testing.T) {
}
func TestStageHostRunStageParallel(t *testing.T) {
host := NewStageHost()
host := core.NewStageHost()
// Two handlers that both try to set Response under Lock.
// Only the first to acquire Lock actually wins; the second sees IsResponded() and skips.
@ -110,7 +111,7 @@ func TestStageHostRunStageParallel(t *testing.T) {
}
func TestStageHostRunStageConcurrency(t *testing.T) {
host := NewStageHost()
host := core.NewStageHost()
var counter int32
n := 10
@ -129,7 +130,7 @@ func TestStageHostRunStageConcurrency(t *testing.T) {
}
func TestStageHostRunStageAll(t *testing.T) {
host := NewStageHost()
host := core.NewStageHost()
var mu sync.Mutex
count := 0
@ -154,7 +155,7 @@ func TestStageHostRunStageAll(t *testing.T) {
}
func TestStageHostEmpty(t *testing.T) {
host := NewStageHost()
host := core.NewStageHost()
if host.ToolCount() != 0 {
t.Errorf("expected 0 tools, got %d", host.ToolCount())
@ -174,7 +175,7 @@ func TestStageHostEmpty(t *testing.T) {
}
func TestStageHostMultipleTools(t *testing.T) {
host := NewStageHost()
host := core.NewStageHost()
host.RegisterTool("tool1", sdk.ToolDef{Name: "tool1"}, func(args map[string]interface{}) (interface{}, error) {
return "from_p1", nil
@ -199,7 +200,7 @@ func TestStageHostMultipleTools(t *testing.T) {
}
func TestStageHostToolDefLookup(t *testing.T) {
host := NewStageHost()
host := core.NewStageHost()
host.RegisterTool("tool_a", sdk.ToolDef{Name: "tool_a", NoMemory: true}, nil)
host.RegisterTool("tool_b", sdk.ToolDef{Name: "tool_b"}, nil)
@ -226,7 +227,7 @@ func TestStageHostToolDefLookup(t *testing.T) {
}
func TestStageHostToolDefNoMemoryStored(t *testing.T) {
host := NewStageHost()
host := core.NewStageHost()
host.RegisterTool("mem_tool", sdk.ToolDef{Name: "mem_tool", NoMemory: true}, nil)
host.RegisterTool("normal_tool", sdk.ToolDef{Name: "normal_tool", NoMemory: false}, nil)

View File

@ -1,6 +1,7 @@
package core
package test
import (
"github.com/JianFeeeee/HologramAgent/internal/agent/core"
"sync"
"testing"
@ -13,7 +14,7 @@ import (
// 读 ParallelSafe,优化后读成了别的字段,于是工具能并发的批次悄悄
// 退化成串行 —— 没有任何报错。
func TestNoCopyQueriesMatchToolDef(t *testing.T) {
sh := NewStageHost()
sh := core.NewStageHost()
noop := func(map[string]interface{}) (interface{}, error) { return nil, nil }
cases := []sdk.ToolDef{
{Name: "plain"},
@ -66,7 +67,7 @@ func TestNoCopyQueriesMatchToolDef(t *testing.T) {
// 并发查询必须无竞态,且与串行结果一致。
func TestNoCopyQueriesConcurrent(t *testing.T) {
sh := NewStageHost()
sh := core.NewStageHost()
noop := func(map[string]interface{}) (interface{}, error) { return nil, nil }
for i := 0; i < 50; i++ {
name := "t" + string(rune('a'+i%26)) + string(rune('0'+i/26))