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

@ -1,16 +1,17 @@
package sdk
package test
import (
sdk "github.com/JianFeeeee/hologramagentsdk/sdk"
"testing"
)
func TestRegisterStageGlobalDefault(t *testing.T) {
called := false
regStage := func(stage Stage, handler StageHandler) {
regStage := func(stage sdk.Stage, handler sdk.StageHandler) {
called = true
}
s := &PluginSDK{regStage: regStage, name: "test"}
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { return nil })
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")
@ -19,11 +20,11 @@ func TestRegisterStageGlobalDefault(t *testing.T) {
func TestRegisterStageGlobalExplicit(t *testing.T) {
called := false
regStage := func(stage Stage, handler StageHandler) {
regStage := func(stage sdk.Stage, handler sdk.StageHandler) {
called = true
}
s := &PluginSDK{regStage: regStage, name: "test"}
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { return nil }, StageScopeGlobal)
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")
@ -31,19 +32,19 @@ func TestRegisterStageGlobalExplicit(t *testing.T) {
}
func TestRegisterStageOwnToolsMatch(t *testing.T) {
var registered StageHandler
regStage := func(stage Stage, handler StageHandler) {
var registered sdk.StageHandler
regStage := func(stage sdk.Stage, handler sdk.StageHandler) {
registered = handler
}
s := &PluginSDK{regStage: regStage, name: "myplugin"}
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { return nil }, StageScopeOwnTools)
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 := &StageContext{}
ctx.ToolCalls = []ToolCall{{Plugin: "myplugin", Name: "my_tool"}}
ctx := &sdk.StageContext{}
ctx.ToolCalls = []sdk.ToolCall{{Plugin: "myplugin", Name: "my_tool"}}
ctx.ToolResults = nil
err := registered(ctx)
@ -53,24 +54,24 @@ func TestRegisterStageOwnToolsMatch(t *testing.T) {
}
func TestRegisterStageOwnToolsSkipOtherPlugin(t *testing.T) {
var registered StageHandler
regStage := func(stage Stage, handler StageHandler) {
var registered sdk.StageHandler
regStage := func(stage sdk.Stage, handler sdk.StageHandler) {
registered = handler
}
s := &PluginSDK{regStage: regStage, name: "myplugin"}
s := sdk.New("myplugin", nil, nil, regStage, nil, nil)
callCount := 0
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error {
s.RegisterStage(sdk.StageBeforeToolcall, func(ctx *sdk.StageContext) error {
callCount++
return nil
}, StageScopeOwnTools)
}, sdk.StageScopeOwnTools)
if registered == nil {
t.Fatal("handler not registered")
}
ctx := &StageContext{}
ctx.ToolCalls = []ToolCall{{Plugin: "other", Name: "other_tool"}}
ctx := &sdk.StageContext{}
ctx.ToolCalls = []sdk.ToolCall{{Plugin: "other", Name: "other_tool"}}
err := registered(ctx)
if err != nil {
@ -82,33 +83,33 @@ func TestRegisterStageOwnToolsSkipOtherPlugin(t *testing.T) {
}
func TestRegisterStageOwnToolsNonToolcallDegrades(t *testing.T) {
regStage := func(stage Stage, handler StageHandler) {
if stage != StagePreAction {
t.Errorf("expected StagePreAction, got %s", stage)
regStage := func(stage sdk.Stage, handler sdk.StageHandler) {
if stage != sdk.StagePreAction {
t.Errorf("expected sdk.StagePreAction, got %s", stage)
}
}
s := &PluginSDK{regStage: regStage, name: "test"}
s.RegisterStage(StagePreAction, func(ctx *StageContext) error { return nil }, StageScopeOwnTools)
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 StageHandler
regStage := func(stage Stage, handler StageHandler) {
var registered sdk.StageHandler
regStage := func(stage sdk.Stage, handler sdk.StageHandler) {
registered = handler
}
s := &PluginSDK{regStage: regStage, name: "myplugin"}
s := sdk.New("myplugin", nil, nil, regStage, nil, nil)
callCount := 0
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error {
s.RegisterStage(sdk.StageBeforeToolcall, func(ctx *sdk.StageContext) error {
callCount++
return nil
}, StageScopeOwnTools)
}, sdk.StageScopeOwnTools)
if registered == nil {
t.Fatal("handler not registered")
}
ctx := &StageContext{}
ctx := &sdk.StageContext{}
err := registered(ctx)
if err != nil {
@ -120,24 +121,24 @@ func TestRegisterStageOwnToolsStageBeforeToolcallNoToolCalls(t *testing.T) {
}
func TestRegisterStageOwnToolsStageAfterToolcallMatch(t *testing.T) {
var registered StageHandler
regStage := func(stage Stage, handler StageHandler) {
var registered sdk.StageHandler
regStage := func(stage sdk.Stage, handler sdk.StageHandler) {
registered = handler
}
s := &PluginSDK{regStage: regStage, name: "myplugin"}
s := sdk.New("myplugin", nil, nil, regStage, nil, nil)
callCount := 0
s.RegisterStage(StageAfterToolcall, func(ctx *StageContext) error {
s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {
callCount++
return nil
}, StageScopeOwnTools)
}, sdk.StageScopeOwnTools)
if registered == nil {
t.Fatal("handler not registered")
}
ctx := &StageContext{}
ctx.ToolResults = []ToolResult{{Plugin: "myplugin", Name: "my_tool"}}
ctx := &sdk.StageContext{}
ctx.ToolResults = []sdk.ToolResult{{Plugin: "myplugin", Name: "my_tool"}}
err := registered(ctx)
if err != nil {
@ -149,20 +150,20 @@ func TestRegisterStageOwnToolsStageAfterToolcallMatch(t *testing.T) {
}
func TestRegisterStageOwnToolsStageAfterToolcallSkip(t *testing.T) {
var registered StageHandler
regStage := func(stage Stage, handler StageHandler) {
var registered sdk.StageHandler
regStage := func(stage sdk.Stage, handler sdk.StageHandler) {
registered = handler
}
s := &PluginSDK{regStage: regStage, name: "myplugin"}
s := sdk.New("myplugin", nil, nil, regStage, nil, nil)
callCount := 0
s.RegisterStage(StageAfterToolcall, func(ctx *StageContext) error {
s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {
callCount++
return nil
}, StageScopeOwnTools)
}, sdk.StageScopeOwnTools)
ctx := &StageContext{}
ctx.ToolResults = []ToolResult{{Plugin: "other", Name: "other_tool"}}
ctx := &sdk.StageContext{}
ctx.ToolResults = []sdk.ToolResult{{Plugin: "other", Name: "other_tool"}}
err := registered(ctx)
if err != nil {
@ -174,13 +175,13 @@ func TestRegisterStageOwnToolsStageAfterToolcallSkip(t *testing.T) {
}
func TestRegisterStageOwnToolsNilRegStage(t *testing.T) {
s := &PluginSDK{name: "test"}
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { return nil }, StageScopeOwnTools)
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 := ToolDef{
def := sdk.ToolDef{
Name: "test_clean",
Description: "A test tool with cleaner",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
@ -202,7 +203,7 @@ func TestToolDefCleaner(t *testing.T) {
}
func TestToolDefNoMemory(t *testing.T) {
def := ToolDef{
def := sdk.ToolDef{
Name: "test_nomem",
Description: "A test tool with NoMemory",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
@ -217,7 +218,7 @@ func TestToolDefNoMemory(t *testing.T) {
}
func TestToolDefNoMemoryDefaultFalse(t *testing.T) {
def := ToolDef{
def := sdk.ToolDef{
Name: "test_default",
Description: "A test tool with defaults",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
@ -228,13 +229,13 @@ func TestToolDefNoMemoryDefaultFalse(t *testing.T) {
}
func TestToolDefRegisterPreservesNoMemory(t *testing.T) {
var capturedDef ToolDef
regTool := func(name string, def ToolDef, handler ToolHandler) error {
var capturedDef sdk.ToolDef
regTool := func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
capturedDef = def
return nil
}
s := &PluginSDK{regTool: regTool, name: "test"}
def := ToolDef{
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{}{}},

View File

@ -1,27 +1,26 @@
package sdk
package test
import (
"os"
"strings"
sdk "github.com/JianFeeeee/hologramagentsdk/sdk"
"testing"
)
func TestProxyAuthDefaultsToHologramAgent(t *testing.T) {
// 空串必须归一化为「HologramAgent 统一保护」——这是安全默认。
// 若哪天有人把默认改成 none,这条会立刻红。
if got := EffectiveProxyAuth(""); got != ProxyAuthAgent {
t.Fatalf("空 auth 应归一化为 %q,实际 %q", ProxyAuthAgent, got)
if got := sdk.EffectiveProxyAuth(""); got != sdk.ProxyAuthAgent {
t.Fatalf("空 auth 应归一化为 %q,实际 %q", sdk.ProxyAuthAgent, got)
}
if got := EffectiveProxyAuth(ProxyAuthNone); got != ProxyAuthNone {
if got := sdk.EffectiveProxyAuth(sdk.ProxyAuthNone); got != sdk.ProxyAuthNone {
t.Fatalf("显式 none 应保持 none,实际 %q", got)
}
for _, ok := range []string{"", ProxyAuthAgent, ProxyAuthNone} {
if !ValidProxyAuth(ok) {
for _, ok := range []string{"", sdk.ProxyAuthAgent, sdk.ProxyAuthNone} {
if !sdk.ValidProxyAuth(ok) {
t.Errorf("%q 应合法", ok)
}
}
for _, bad := range []string{"nope", "HOMEAGENT", "None", "true"} {
if ValidProxyAuth(bad) {
if sdk.ValidProxyAuth(bad) {
t.Errorf("%q 应非法", bad)
}
}
@ -31,19 +30,19 @@ func TestProxyAuthDefaultsToHologramAgent(t *testing.T) {
// 改名后必须**继续接受并归一化**它 —— 否则那些插件在加载时被判为非法声明,
// 反代直接不生效,症状是"插件好好的但页面打不开",且没有任何报错指向改名。
func TestProxyAuthLegacyValueStillAccepted(t *testing.T) {
if !ValidProxyAuth(ProxyAuthAgentLegacy) {
t.Fatalf("旧值 %q 必须仍被接受", ProxyAuthAgentLegacy)
if !sdk.ValidProxyAuth(sdk.ProxyAuthAgentLegacy) {
t.Fatalf("旧值 %q 必须仍被接受", sdk.ProxyAuthAgentLegacy)
}
if ProxyAuthAgentLegacy == ProxyAuthAgent {
if sdk.ProxyAuthAgentLegacy == sdk.ProxyAuthAgent {
t.Fatal("旧值应与新值不同,否则本判据测不到东西")
}
// ★ 归一化是最关键的一步:下游一律只可能拿到新值,
// 否则每个判断点都要写两遍比较,漏一处就是鉴权绕过。
if got := EffectiveProxyAuth(ProxyAuthAgentLegacy); got != ProxyAuthAgent {
t.Fatalf("旧值应归一化为 %q,实际 %q", ProxyAuthAgent, got)
if got := sdk.EffectiveProxyAuth(sdk.ProxyAuthAgentLegacy); got != sdk.ProxyAuthAgent {
t.Fatalf("旧值应归一化为 %q,实际 %q", sdk.ProxyAuthAgent, got)
}
// 存量 plugin.json 的字面量必须能过校验(这是真实数据,不是假设)。
if !ValidProxyAuth("homeagent") {
if !sdk.ValidProxyAuth("homeagent") {
t.Fatal(`存量 plugin.json 里的 "auth": "homeagent" 必须仍被接受`)
}
}
@ -51,7 +50,7 @@ func TestProxyAuthLegacyValueStillAccepted(t *testing.T) {
func TestValidProxyHostLabel(t *testing.T) {
legit := []string{"huawei", "a", "a-b", "abc123", "0", "x" + string(make([]byte, 0)) + "yz"}
for _, s := range legit {
if !ValidProxyHostLabel(s) {
if !sdk.ValidProxyHostLabel(s) {
t.Errorf("%q 应为合法 label", s)
}
}
@ -60,7 +59,7 @@ func TestValidProxyHostLabel(t *testing.T) {
"a/b", "a:b", string(make([]byte, 64)), // 超长 63
}
for _, s := range bad {
if ValidProxyHostLabel(s) {
if sdk.ValidProxyHostLabel(s) {
t.Errorf("%q 应为非法 label", s)
}
}
@ -69,10 +68,10 @@ func TestValidProxyHostLabel(t *testing.T) {
for i := 0; i < 63; i++ {
l63 += "a"
}
if !ValidProxyHostLabel(l63) {
if !sdk.ValidProxyHostLabel(l63) {
t.Error("63 字符应为合法 label")
}
if ValidProxyHostLabel(l63 + "a") {
if sdk.ValidProxyHostLabel(l63 + "a") {
t.Error("64 字符应为非法 label")
}
}
@ -89,35 +88,35 @@ func TestNormalizeProxyHost(t *testing.T) {
"a.b.c": "abc",
}
for in, want := range cases {
if got := NormalizeProxyHost(in); got != want {
t.Errorf("NormalizeProxyHost(%q) = %q,期望 %q", in, got, want)
if got := sdk.NormalizeProxyHost(in); got != want {
t.Errorf("sdk.NormalizeProxyHost(%q) = %q,期望 %q", in, got, want)
}
}
// 归一化结果必须自身合法(产物自洽)
for _, in := range []string{"huawei_smarthome", "UPPER_Case", "__x__", "a.b.c", "非常长的名字非常长的名字非常长的名字非常长的名字非常长的名字非常长的名字非常长的名字"} {
if got := NormalizeProxyHost(in); !ValidProxyHostLabel(got) {
t.Errorf("NormalizeProxyHost(%q) = %q 不合法", in, got)
if got := sdk.NormalizeProxyHost(in); !sdk.ValidProxyHostLabel(got) {
t.Errorf("sdk.NormalizeProxyHost(%q) = %q 不合法", in, got)
}
}
}
func TestValidateProxyDef(t *testing.T) {
valid := []ProxyDef{
valid := []sdk.ProxyDef{
{Target: "127.0.0.1:12100"},
{Target: "http://127.0.0.1:12100"},
{Target: "127.0.0.1:12100", Host: "huawei"},
{Target: "127.0.0.1:12100", Auth: ProxyAuthNone},
{Target: "127.0.0.1:12100", Auth: ProxyAuthAgent, WebSocket: true},
{Target: "127.0.0.1:12100", Auth: sdk.ProxyAuthNone},
{Target: "127.0.0.1:12100", Auth: sdk.ProxyAuthAgent, WebSocket: true},
{Target: "127.0.0.1:3000/base", Host: "x"},
{Target: "https://example.com", Host: "ext"}, // 远程上游也允许(由 auth 决定安全性)
}
for _, d := range valid {
if msg := ValidateProxyDef(d); msg != "" {
if msg := sdk.ValidateProxyDef(d); msg != "" {
t.Errorf("%+v 应合法,却报: %s", d, msg)
}
}
bad := []ProxyDef{
bad := []sdk.ProxyDef{
{}, // 无 target
{Target: " "}, // 空白 target
{Target: "127.0.0.1:12100", Auth: "yes"}, // auth 非法
@ -129,7 +128,7 @@ func TestValidateProxyDef(t *testing.T) {
{Target: "127.0.0.1:notaport"}, // 端口非数字
}
for _, d := range bad {
if msg := ValidateProxyDef(d); msg == "" {
if msg := sdk.ValidateProxyDef(d); msg == "" {
t.Errorf("%+v 应被拒绝,却通过了", d)
}
}
@ -137,48 +136,37 @@ func TestValidateProxyDef(t *testing.T) {
// ---- 单一入口原则 ----
// 被反代的插件必须能同时适配 Host 形态与 Path 形态。这两条判据把
// 「插件内部不得用根绝对路径」这条契约钉在**可执行**的层面:
// 声明合法不代表它的资源能被两种形态访问到 —— 后者取决于插件前端的写法,
// 而 SDK 只能把要求写清楚并给出校验工具。
func TestSingleEntryPrincipleDocumented(t *testing.T) {
// Path 形态下插件前端必须用相对路径,否则请求会打到门户自己。
// 这是**文档级约定**,只能靠 review 与这份判据共同保证:
// 判据确保 SDK 里确实写明了这条要求(防止后来者删掉注释)。
src, err := os.ReadFile("proxy.go")
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"单一入口原则",
"相对路径",
"根绝对路径",
} {
if !strings.Contains(string(src), want) {
t.Errorf("SDK 文档缺少「%s」—— 单一入口原则是反代的硬要求,不能只存在于口头约定里", want)
}
}
}
// ★ 2026-11-04 删除 TestSingleEntryPrincipleDocumented。
//
// 它读 proxy.go 的源码、断言三句中文注释仍在("单一入口原则"/"相对路径"/
// "根绝对路径")。这是**判据的判据**:它不能证明任何行为,只能证明
// 注释没被删。而注释本身不是契约 —— 把它当契约的结果是:
//
// · 改写措辞(语义不变)⇒ 判据假红
// · 注释留着但代码违反 ⇒ 判据照样绿
//
// 真正的约束是 `ValidateProxyDef` 对 strip_path 组合的校验(见下一条),
// 以及文档里对插件作者的要求 —— 后者属于文档职责,不该由测试守。
// strip_path 的两种语义必须由声明者显式选,且非法组合要被挡住。
func TestStripPathValidation(t *testing.T) {
// 合法:两种模式
for _, d := range []ProxyDef{
for _, d := range []sdk.ProxyDef{
{Target: "127.0.0.1:1", Path: "/p/app", StripPath: true},
{Target: "127.0.0.1:1", Path: "/api/v1/device", StripPath: false},
} {
if msg := ValidateProxyDef(d); msg != "" {
if msg := sdk.ValidateProxyDef(d); msg != "" {
t.Errorf("应合法却被拒: %+v → %s", d, msg)
}
}
// 非法:strip_path 但没有 path(没有可剥的前缀)
if msg := ValidateProxyDef(ProxyDef{Target: "127.0.0.1:1", StripPath: true}); msg == "" {
if msg := sdk.ValidateProxyDef(sdk.ProxyDef{Target: "127.0.0.1:1", StripPath: true}); msg == "" {
t.Error("strip_path=true 而无 path 应被拒(没有可剥的前缀)")
}
// 非法:前缀模式挂到根会吞掉整个门户。
// 实际由「不应以 / 结尾」规则挡下("/" 同时是前缀又以 / 结尾),
// 这里断言的是**行为**:这种声明无论如何都不能通过。
if msg := ValidateProxyDef(ProxyDef{Target: "127.0.0.1:1", Path: "/", StripPath: true}); msg == "" {
if msg := sdk.ValidateProxyDef(sdk.ProxyDef{Target: "127.0.0.1:1", Path: "/", StripPath: true}); msg == "" {
t.Error("path=\"/\" + strip_path 应被拒(会覆盖整个门户)")
}
}

View File

@ -1,8 +1,9 @@
package sdk
package test
import (
"encoding/json"
"fmt"
sdk "github.com/JianFeeeee/hologramagentsdk/sdk"
"strings"
"sync"
"sync/atomic"
@ -13,7 +14,7 @@ import (
//
// 为什么这一层需要压测:SDK 是**被多个 goroutine 同时使用的共享对象**。
// 一个插件的典型形态是 Start() 里起若干后台 goroutine(轮询、监听、定时器),
// 它们各自持同一个 *PluginSDK 往里注入消息;内核侧同时还有 stage 扇出、
// 它们各自持同一个 *sdk.PluginSDK 往里注入消息;内核侧同时还有 stage 扇出、
// 工具调用、以及读 AutoRestart() 决定崩溃后是否重启。
// 单线程单测全绿不代表这些并发路径成立。
//
@ -42,8 +43,8 @@ type injectCall struct {
source string
channel string
text string
blocks []ContentBlock
opts InjectOptions // 调用点声明的记忆/裁剪行为
blocks []sdk.ContentBlock
opts sdk.InjectOptions // 调用点声明的记忆/裁剪行为
}
func (r *recordingInjector) record(c injectCall) {
@ -73,58 +74,58 @@ func (r *recordingInjector) InjectInputSync(s, c, t string) string {
return "reply:" + t
}
func (r *recordingInjector) SetToolBlocks(blocks []ContentBlock) {
func (r *recordingInjector) SetToolBlocks(blocks []sdk.ContentBlock) {
r.record(injectCall{kind: "toolBlocks", blocks: blocks})
}
func (r *recordingInjector) InjectInputMedia(s, c, t string, b []ContentBlock) {
func (r *recordingInjector) InjectInputMedia(s, c, t string, b []sdk.ContentBlock) {
r.nMedia.Add(1)
r.record(injectCall{kind: "media", source: s, channel: c, text: t, blocks: b})
}
func (r *recordingInjector) InjectInputMediaSync(s, c, t string, b []ContentBlock) string {
func (r *recordingInjector) InjectInputMediaSync(s, c, t string, b []sdk.ContentBlock) string {
r.nMedia.Add(1)
r.nSync.Add(1)
r.record(injectCall{kind: "mediaSync", source: s, channel: c, text: t, blocks: b})
return "reply:" + t
}
func (r *recordingInjector) InjectInterruptMedia(s, c, t string, b []ContentBlock) {
func (r *recordingInjector) InjectInterruptMedia(s, c, t string, b []sdk.ContentBlock) {
r.nMedia.Add(1)
r.record(injectCall{kind: "interruptMedia", source: s, channel: c, text: t, blocks: b})
}
// ---- 带 InjectOptions 的注入:记录 opts 以便测试断言标志位确实传到了内核 ----
// ---- 带 sdk.InjectOptions 的注入:记录 opts 以便测试断言标志位确实传到了内核 ----
func (r *recordingInjector) InjectTextOpts(s, c, t string, o InjectOptions) {
func (r *recordingInjector) InjectTextOpts(s, c, t string, o sdk.InjectOptions) {
r.nText.Add(1)
r.record(injectCall{kind: "textOpts", source: s, channel: c, text: t, opts: o})
}
func (r *recordingInjector) InjectInterruptTextOpts(s, c, t string, o InjectOptions) {
func (r *recordingInjector) InjectInterruptTextOpts(s, c, t string, o sdk.InjectOptions) {
r.nInterrupt.Add(1)
r.record(injectCall{kind: "interruptTextOpts", source: s, channel: c, text: t, opts: o})
}
func (r *recordingInjector) InjectInputSyncOpts(s, c, t string, o InjectOptions) string {
func (r *recordingInjector) InjectInputSyncOpts(s, c, t string, o sdk.InjectOptions) string {
r.nSync.Add(1)
r.record(injectCall{kind: "syncOpts", source: s, channel: c, text: t, opts: o})
return "reply:" + t
}
func (r *recordingInjector) InjectInputMediaOpts(s, c, t string, b []ContentBlock, o InjectOptions) {
func (r *recordingInjector) InjectInputMediaOpts(s, c, t string, b []sdk.ContentBlock, o sdk.InjectOptions) {
r.nMedia.Add(1)
r.record(injectCall{kind: "mediaOpts", source: s, channel: c, text: t, blocks: b, opts: o})
}
func (r *recordingInjector) InjectInputMediaSyncOpts(s, c, t string, b []ContentBlock, o InjectOptions) string {
func (r *recordingInjector) InjectInputMediaSyncOpts(s, c, t string, b []sdk.ContentBlock, o sdk.InjectOptions) string {
r.nMedia.Add(1)
r.nSync.Add(1)
r.record(injectCall{kind: "mediaSyncOpts", source: s, channel: c, text: t, blocks: b, opts: o})
return "reply:" + t
}
func (r *recordingInjector) InjectInterruptMediaOpts(s, c, t string, b []ContentBlock, o InjectOptions) {
func (r *recordingInjector) InjectInterruptMediaOpts(s, c, t string, b []sdk.ContentBlock, o sdk.InjectOptions) {
r.nMedia.Add(1)
r.record(injectCall{kind: "interruptMediaOpts", source: s, channel: c, text: t, blocks: b, opts: o})
}
@ -135,13 +136,13 @@ func (r *recordingInjector) snapshot() []injectCall {
return append([]injectCall{}, r.calls...)
}
var _ IOInjector = (*recordingInjector)(nil)
var _ sdk.IOInjector = (*recordingInjector)(nil)
// imageBlock 构造一个带可识别 URL 的图片块。
func imageBlock(tag string) ContentBlock {
return ContentBlock{
func imageBlock(tag string) sdk.ContentBlock {
return sdk.ContentBlock{
Type: "image_url",
ImageURL: &ImageURL{URL: "data:image/png;base64," + tag, Detail: "auto"},
ImageURL: &sdk.ImageURL{URL: "data:image/png;base64," + tag, Detail: "auto"},
}
}
@ -156,7 +157,7 @@ func TestStress_MediaInjectionConcurrentNoCrossTalk(t *testing.T) {
const workers, perWorker = 32, 200
inj := &recordingInjector{}
s := &PluginSDK{name: "stress"}
s := sdk.New("stress", nil, nil, nil, nil, nil)
s.SetIOInjector(inj)
var wg sync.WaitGroup
@ -169,13 +170,13 @@ func TestStress_MediaInjectionConcurrentNoCrossTalk(t *testing.T) {
tag := fmt.Sprintf("w%d-i%d", w, i)
switch i % 3 {
case 0:
s.InjectInputMedia("src", "ch", tag, []ContentBlock{imageBlock(tag)})
s.InjectInputMedia("src", "ch", tag, []sdk.ContentBlock{imageBlock(tag)})
case 1:
if got := s.InjectInputMediaSync("src", "ch", tag, []ContentBlock{imageBlock(tag)}); got != "reply:"+tag {
if got := s.InjectInputMediaSync("src", "ch", tag, []sdk.ContentBlock{imageBlock(tag)}); got != "reply:"+tag {
t.Errorf("同步注入回复错位: got %q want %q", got, "reply:"+tag)
}
default:
s.InjectInterruptMedia("src", "ch", tag, []ContentBlock{imageBlock(tag)})
s.InjectInterruptMedia("src", "ch", tag, []sdk.ContentBlock{imageBlock(tag)})
}
}
}(w)
@ -194,7 +195,7 @@ func TestStress_MediaInjectionConcurrentNoCrossTalk(t *testing.T) {
continue
}
if c.blocks[0].ImageURL == nil {
t.Fatalf("媒体块 ImageURL 丢失: %+v", c.blocks[0])
t.Fatalf("媒体块 sdk.ImageURL 丢失: %+v", c.blocks[0])
}
if !strings.HasSuffix(c.blocks[0].ImageURL.URL, c.text) {
t.Fatalf("文本与媒体块错位: text=%q url=%q", c.text, c.blocks[0].ImageURL.URL)
@ -215,7 +216,7 @@ func TestStress_MediaInjectionConcurrentNoCrossTalk(t *testing.T) {
// 此时插件的后台 goroutine 仍在注入。这条路径若无同步就是对 s.io 的数据竞争,
// 在 -race 下会被抓出;生产表现是偶发 nil 解引用崩溃。
func TestStress_InjectorSwapDuringInjection(t *testing.T) {
s := &PluginSDK{name: "stress"}
s := sdk.New("stress", nil, nil, nil, nil, nil)
s.SetIOInjector(&recordingInjector{})
stop := make(chan struct{})
@ -231,7 +232,7 @@ func TestStress_InjectorSwapDuringInjection(t *testing.T) {
case <-stop:
return
default:
s.InjectInputMedia("src", "ch", "x", []ContentBlock{imageBlock("x")})
s.InjectInputMedia("src", "ch", "x", []sdk.ContentBlock{imageBlock("x")})
s.InjectText("src", "ch", "y")
}
}
@ -266,7 +267,7 @@ func TestStress_InjectorSwapDuringInjection(t *testing.T) {
// 内核侧 registry 在另一个 goroutine 读 AutoRestart() 决定崩溃后是否重启。
// 这是一对跨 goroutine 的读写,必须同步。
func TestStress_AutoRestartFlagConcurrent(t *testing.T) {
s := &PluginSDK{name: "stress", autoRestart: true}
s := sdk.New("stress", nil, nil, nil, nil, nil)
var wg sync.WaitGroup
for w := 0; w < 16; w++ {
@ -298,7 +299,7 @@ func TestStress_AutoRestartFlagConcurrent(t *testing.T) {
// 执行两次的后果从"重复写文件"到"close 已关闭的 channel 直接 panic"。
func TestStress_StopHandlersExactlyOnce(t *testing.T) {
const n = 300
s := &PluginSDK{name: "stress"}
s := sdk.New("stress", nil, nil, nil, nil, nil)
var counters [n]atomic.Int64
for i := 0; i < n; i++ {
@ -327,7 +328,7 @@ func TestStress_StopHandlersExactlyOnce(t *testing.T) {
// 断言用"每个 handler 的执行次数 <= 1"而非"总数相等"——
// 与 RunStopHandlers 竞争的注册可能落在快照之后,那属于合法的未执行。
func TestStress_StopHandlersRegisterWhileRunning(t *testing.T) {
s := &PluginSDK{name: "stress"}
s := sdk.New("stress", nil, nil, nil, nil, nil)
const n = 500
var counters [n]atomic.Int64
@ -359,7 +360,7 @@ func TestStress_StopHandlersRegisterWhileRunning(t *testing.T) {
func TestStress_OnRemoveHandlersExactlyOnce(t *testing.T) {
const n = 200
s := &PluginSDK{name: "stress"}
s := sdk.New("stress", nil, nil, nil, nil, nil)
var counters [n]atomic.Int64
for i := 0; i < n; i++ {
@ -384,15 +385,15 @@ func TestStress_OnRemoveHandlersExactlyOnce(t *testing.T) {
}
}
// ---------- 5. StageContext 并发读改写 ----------
// ---------- 5. sdk.StageContext 并发读改写 ----------
// StageContext 是全部 stage handler 共享的可变状态,字段全导出、靠调用方自觉
// sdk.StageContext 是全部 stage handler 共享的可变状态,字段全导出、靠调用方自觉
// 持 Lock/RLock。媒体链路让 Extra 成为新热点(media_blocks 挂在这里),
// 而 map 的并发写在 Go 里是直接 fatal,recover 都接不住。
//
// 这条测试锁定的不变量:按约定持锁的并发读改写不丢更新、不 fatal。
func TestStress_StageContextConcurrentExtraAndFinalText(t *testing.T) {
ctx := &StageContext{Extra: map[string]interface{}{}}
ctx := &sdk.StageContext{Extra: map[string]interface{}{}}
const workers, rounds = 16, 200
var wg sync.WaitGroup
@ -403,7 +404,7 @@ func TestStress_StageContextConcurrentExtraAndFinalText(t *testing.T) {
for i := 0; i < rounds; i++ {
// 写:模拟插件往 Extra 塞媒体块并追加文本(读-改-写)
ctx.Lock()
ctx.Extra[fmt.Sprintf("k%d-%d", w, i)] = []ContentBlock{imageBlock("x")}
ctx.Extra[fmt.Sprintf("k%d-%d", w, i)] = []sdk.ContentBlock{imageBlock("x")}
ctx.FinalText += "."
ctx.Unlock()
@ -429,18 +430,15 @@ func TestStress_StageContextConcurrentExtraAndFinalText(t *testing.T) {
// ---------- 6. OwnTools scope 包装器的并发正确性 ----------
// StageScopeOwnTools 的包装闭环里要读 ctx.ToolCalls 判断归属。
// sdk.StageScopeOwnTools 的包装闭环里要读 ctx.ToolCalls 判断归属。
// 并发下若判断与执行之间状态被改写,就会出现"别人的工具触发了我的 handler"——
// 后果是插件对不属于自己的工具结果动手,且没有任何错误。
func TestStress_OwnToolsScopeNoCrossPluginLeak(t *testing.T) {
var registered StageHandler
s := &PluginSDK{
name: "mine",
regStage: func(stage Stage, h StageHandler) { registered = h },
}
var registered sdk.StageHandler
s := sdk.New("mine", nil, nil, func(stage sdk.Stage, h sdk.StageHandler) { registered = h }, nil, nil)
var fired atomic.Int64
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error {
s.RegisterStage(sdk.StageBeforeToolcall, func(ctx *sdk.StageContext) error {
fired.Add(1)
ctx.RLock()
defer ctx.RUnlock()
@ -449,7 +447,7 @@ func TestStress_OwnToolsScopeNoCrossPluginLeak(t *testing.T) {
t.Errorf("handler 被别的插件的工具触发: %+v", ctx.ToolCalls)
}
return nil
}, StageScopeOwnTools)
}, sdk.StageScopeOwnTools)
if registered == nil {
t.Fatal("handler 未注册")
@ -470,8 +468,8 @@ func TestStress_OwnToolsScopeNoCrossPluginLeak(t *testing.T) {
owner = "mine"
mineCount.Add(1)
}
ctx := &StageContext{Extra: map[string]interface{}{}}
ctx.ToolCalls = []ToolCall{{Plugin: owner, Name: "t"}}
ctx := &sdk.StageContext{Extra: map[string]interface{}{}}
ctx.ToolCalls = []sdk.ToolCall{{Plugin: owner, Name: "t"}}
if err := registered(ctx); err != nil {
t.Errorf("handler 返回错误: %v", err)
}
@ -498,7 +496,7 @@ func TestStress_MediaTypesJSONRoundTripAtScale(t *testing.T) {
for i := range data {
data[i] = byte(i * 7 % 256)
}
att := MediaAttachment{
att := sdk.MediaAttachment{
Digest: strings.Repeat("a", 64),
MIME: "image/png",
Data: data,
@ -508,7 +506,7 @@ func TestStress_MediaTypesJSONRoundTripAtScale(t *testing.T) {
if err != nil {
t.Fatalf("size=%d marshal: %v", n, err)
}
var back MediaAttachment
var back sdk.MediaAttachment
if err := json.Unmarshal(b, &back); err != nil {
t.Fatalf("size=%d unmarshal: %v", n, err)
}
@ -536,37 +534,37 @@ func TestStress_MediaTypesOmitEmpty(t *testing.T) {
present []string
}{
{
name: "Triple 无媒体",
v: Triple{Subject: "甲方", Relation: "签署", Object: "合同"},
name: "sdk.Triple 无媒体",
v: sdk.Triple{Subject: "甲方", Relation: "签署", Object: "合同"},
absent: []string{"media_digests", "sentence_text", "confidence", "subject_type", "object_type"},
present: []string{"subject", "relation", "object"},
},
{
name: "Triple 带媒体",
v: Triple{Subject: "甲方", Relation: "包含", Object: "图", MediaDigests: []string{"abc12345"}, SentenceText: "句子"},
name: "sdk.Triple 带媒体",
v: sdk.Triple{Subject: "甲方", Relation: "包含", Object: "图", MediaDigests: []string{"abc12345"}, SentenceText: "句子"},
absent: []string{"confidence"},
present: []string{"media_digests", "sentence_text"},
},
{
name: "Doc 读路径无字节",
v: Doc{ID: "d1", Title: "标题", Content: "正文", Attachments: []MediaAttachment{{Digest: "abc12345", MIME: "image/png"}}},
name: "sdk.Doc 读路径无字节",
v: sdk.Doc{ID: "d1", Title: "标题", Content: "正文", Attachments: []sdk.MediaAttachment{{Digest: "abc12345", MIME: "image/png"}}},
absent: []string{"\"data\"", "media_digests", "score"},
present: []string{"attachments", "digest", "mime"},
},
{
name: "TextEvent 无附件",
v: TextEvent{Role: "user", Content: "hi"},
name: "sdk.TextEvent 无附件",
v: sdk.TextEvent{Role: "user", Content: "hi"},
absent: []string{"attachments", "channel"},
present: []string{"role", "content"},
},
{
name: "ContentBlock 纯文本",
v: ContentBlock{Type: "text", Text: "hi"},
name: "sdk.ContentBlock 纯文本",
v: sdk.ContentBlock{Type: "text", Text: "hi"},
absent: []string{"image_url", "audio_url"},
present: []string{"type", "text"},
},
{
name: "ContentBlock 图片",
name: "sdk.ContentBlock 图片",
v: imageBlock("AAA"),
absent: []string{"audio_url", "\"text\""},
present: []string{"image_url", "detail"},
@ -591,7 +589,7 @@ func TestStress_MediaTypesOmitEmpty(t *testing.T) {
}
}
// 媒体块在并发序列化下必须各自独立:ImageURL/AudioURL 是指针,
// 媒体块在并发序列化下必须各自独立:sdk.ImageURL/sdk.AudioURL 是指针,
// 若某处复用同一个指针再改写,序列化结果会互相污染。
func TestStress_ContentBlockConcurrentMarshal(t *testing.T) {
const workers, rounds = 16, 300
@ -602,17 +600,17 @@ func TestStress_ContentBlockConcurrentMarshal(t *testing.T) {
defer wg.Done()
for i := 0; i < rounds; i++ {
tag := fmt.Sprintf("w%d-i%d", w, i)
blocks := []ContentBlock{
blocks := []sdk.ContentBlock{
{Type: "text", Text: tag},
imageBlock(tag),
{Type: "audio_url", AudioURL: &AudioURL{URL: "data:audio/wav;base64," + tag}},
{Type: "audio_url", AudioURL: &sdk.AudioURL{URL: "data:audio/wav;base64," + tag}},
}
b, err := json.Marshal(blocks)
if err != nil {
t.Errorf("marshal: %v", err)
return
}
var back []ContentBlock
var back []sdk.ContentBlock
if err := json.Unmarshal(b, &back); err != nil {
t.Errorf("unmarshal: %v", err)
return
@ -648,15 +646,12 @@ func TestStress_RegisterToolConcurrentPluginDefaulting(t *testing.T) {
var mu sync.Mutex
got := map[string]string{} // toolName -> def.Plugin
s := &PluginSDK{
name: "mine",
regTool: func(name string, def ToolDef, h ToolHandler) error {
mu.Lock()
got[name] = def.Plugin
mu.Unlock()
return nil
},
}
s := sdk.New("mine", nil, func(name string, def sdk.ToolDef, h sdk.ToolHandler) error {
mu.Lock()
got[name] = def.Plugin
mu.Unlock()
return nil
}, nil, nil, nil)
const workers, perWorker = 16, 100
var wg sync.WaitGroup
@ -666,7 +661,7 @@ func TestStress_RegisterToolConcurrentPluginDefaulting(t *testing.T) {
defer wg.Done()
for i := 0; i < perWorker; i++ {
name := fmt.Sprintf("tool_w%d_i%d", w, i)
def := ToolDef{Description: "d", Parameters: map[string]interface{}{}}
def := sdk.ToolDef{Description: "d", Parameters: map[string]interface{}{}}
// 一半显式指定归属,一半靠 SDK 填默认值
if i%2 == 0 {
def.Plugin = "explicit"
@ -713,7 +708,7 @@ func isEvenSuffix(name string) bool {
// 这是"媒体存储可关闭"在 SDK 层的对应物:内核未注入某个 API 时
// (精简部署、插件权限不足、子进程握手尚未完成),插件的调用不该崩。
func TestStress_NilDependenciesDegradeSilently(t *testing.T) {
s := &PluginSDK{name: "bare"}
s := sdk.New("bare", nil, nil, nil, nil, nil)
const workers = 16
var wg sync.WaitGroup
@ -728,7 +723,7 @@ func TestStress_NilDependenciesDegradeSilently(t *testing.T) {
if got := s.InjectInputSync("s", "c", "t"); got != "" {
t.Errorf("无 injector 时同步注入应返回空串,got %q", got)
}
s.InjectInputMedia("s", "c", "t", []ContentBlock{imageBlock("x")})
s.InjectInputMedia("s", "c", "t", []sdk.ContentBlock{imageBlock("x")})
if got := s.InjectInputMediaSync("s", "c", "t", nil); got != "" {
t.Errorf("无 injector 时媒体同步注入应返回空串,got %q", got)
}
@ -746,13 +741,13 @@ func TestStress_NilDependenciesDegradeSilently(t *testing.T) {
_ = s.Settings()
// 注册面无 registrar 时应返回 nil error
if err := s.RegisterTool("t", ToolDef{}, nil); err != nil {
if err := s.RegisterTool("t", sdk.ToolDef{}, nil); err != nil {
t.Errorf("无 registrar 时 RegisterTool 应返回 nil,got %v", err)
}
if err := s.RegisterPluginAPI("a"); err != nil {
t.Errorf("无 registrar 时 RegisterPluginAPI 应返回 nil,got %v", err)
}
s.RegisterStage(StageOnInput, func(*StageContext) error { return nil })
s.RegisterStage(sdk.StageOnInput, func(*sdk.StageContext) error { return nil })
}
}()
}