diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fefd3838..99a78cac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,60 @@ jobs: - name: go vet ./... run: go vet ./... + # ── gofmt 门 ── + # + # ## 为什么必须有这一步(2026-11-04 补) + # + # 提交纪律第 3 条要求 `gofmt -l` 干净,但此前三个 workflow 里 + # `gofmt` 出现 **0 次** —— 这条纪律只靠人自觉,CI 不兜底。 + # 纪律没有 CI 兜底就只是建议:一次疏忽就让未格式化的代码进主干, + # 而 reviewer 在 diff 里看不出(gofmt 差异会淹没真实改动)。 + # + # ★ 只查本仓自管的目录:third_party/hologramagentsdk 是**独立仓**, + # 格式由它自己的 CI 管;主仓只 vendor 其中 22 个文件, + # 在这里报红等于要求主仓为别人的仓负责。 + - name: gofmt(格式门) + run: | + set -euo pipefail + bad=$(gofmt -l internal/ cmd/ pkg/ third_party/hologramagentsdk/sdk/ third_party/hologramagentsdk/meta/ || true) + if [ -n "$bad" ]; then + echo "★ 以下文件未按 gofmt 格式化:" + echo "$bad" + echo + echo "本地修复:gofmt -w <文件>" + exit 1 + fi + echo "gofmt: 全部干净" + + # ── SDK 判据(独立 module)── + # + # ## 为什么主仓 CI 要跑 SDK 的测试(2026-11-04 补) + # + # `third_party/hologramagentsdk/` 有**自己独立的 go.mod** + # (模块 github.com/JianFeeeee/hologramagentsdk),因此: + # + # go list ./... | grep third_party → 0 个 + # + # 主仓的 `go test ./...` **永远不包含** SDK 的任何测试。 + # 实测 SDK 有 30 个判据文件、6 个包,主仓 CI 一次都没跑过。 + # + # ## 这不是"重复跑 SDK 自己的 CI" + # + # SDK 仓的 CI 测的是"SDK 仓自己一致"。这里测的是 + # **"主仓 vendor 的这份 SDK 副本"是否还自洽** —— 主仓的 + # go.mod 用 `replace => ./third_party/hologramagentsdk`, + # 实际编译、实际跑判据用的都是这份副本。 + # + # 关键约束:**只跑主仓 git 跟踪的文件**。SDK 目录在工作机上是 + # 完整的独立仓(187 个文件 + 独立 .git),CI 上只有 22 个 + # vendored 文件 —— 若这里依赖完整树,CI 会因缺文件而假红。 + # 实测(git archive 出 22 个文件后):`go test ./sdk/ ./meta/` 全绿。 + - name: SDK 判据(独立 module,go test ./... 覆盖不到) + working-directory: third_party/hologramagentsdk + run: | + set -euo pipefail + go test ./sdk/ ./meta/ -count=1 -timeout 5m + # ── CI 配置门禁自检(.github/scripts/check-ci-gates.py)── # # ## 为什么 CI 要检查自己的配置文件 diff --git a/docs/zh/engineering-lessons.md b/docs/zh/engineering-lessons.md index da649d08..e08713c7 100644 --- a/docs/zh/engineering-lessons.md +++ b/docs/zh/engineering-lessons.md @@ -160,6 +160,187 @@ - **未做登记**(`future-plan.md`):还剩什么没做 - **操作手册**(`*-runbook.md`):怎么做 +--- + +## 五、判据形态类(2026-11-04 立) + +> 这一节回答一个具体问题:**判据应该写成什么形状?** +> +> 前面四节讲"判据会骗人"的各种形态。这一节讲**判据本身的形状错了**会怎样 —— +> 那比"判据写得不够严"更贵,因为形状错的判据会把**实现细节固化成契约**, +> 于是重构内部就必然破坏判据,作者只能二选一:不改代码,或者删判据。 + +### 5.1 判据必须把被测模块当黑箱 + +**模块化的目的就是"改内部实现不破坏外部接口"。判据如果不按这个层次写, +就等于取消了模块化的收益。** + +白盒判据把内部状态变成契约。实测代价: + +| 白盒判据 | 它固化了什么 | 后果 | +| --- | --- | --- | +| `if sf.Step != StepLLM` | 状态机停在哪一步 | 把 `StepLLM` 改名/拆步骤就假红,而行为完全正确 | +| `if f.ToolIdx == 3` | 循环推进方式 | 改成"整批提交"就假红,而结果一样 | +| `strings.Contains(src, "kstate.Mount(ks)")` | 源码字面量 | 改写成 `kstate.Mount(state)` 就假红(那是对的) | +| `os.ReadFile("../../events/bus.go")` 正则取常量 | 常量定义在哪个文件 | 把常量挪到 `types.go` 就假红 | + +**判据要问**:**这条判据约束的是"对外承诺",还是"当前怎么实现的"?** + +如果是后者,它就不该存在 —— 或者必须降级为"实现注释"(代码里的注释说明 +为什么这样做),而不是判据。 + +#### 允许白盒的三种例外(其余一律黑箱) + +1. **纯算法**:输入输出就是全部语义(裁剪算法、版本比较、分页计算)。 + 这类即使白盒也不固化"流程",因为它本来就只有"输入→输出"。 +2. **并发/生命周期不变量**:无法从外部观测的时序约束,如"Dispose 期间 + emit 不能读到已释放的指针"。这类**必须**用 `-race` + 并发压力, + 而不是断言内部字段。 +3. **熔断/重试状态机**:内部状态本身就是被需求规定的("连续 3 次崩溃 + 进入熔断")。这类要断言**状态转移的可观测效果**(还健康吗、 + 进重载队列了吗),不是断言状态字段的值。 + +#### 一个可操作的判据 + +写完一条判据,问自己: + +> **"如果我把这个模块的内部实现完全重写、公开接口不变, +> 这条判据还会绿吗?"** + +- 会绿 → 形状对了 +- 会红 → 它钉的是实现,不是契约 + +### 5.2 禁止"读源码做断言" + +`os.ReadFile("*.go")` + `strings.Contains` / `regexp` 是**反模式**, +即使动机正确(如"防止有人删掉装配的那一行")。 + +**为什么它看起来有吸引力**:当行为判据需要很重的 fixture 时,读源码很便宜。 + +**为什么它必须禁用**: + +- 它测的是**文本**,不是**语义**。加空格、换变量名、等价重构都会让判断失效 + (假红或假绿) +- 它把"文件布局"变成契约。移动代码到新文件就假红 +- 它无法区分"代码存在"与"代码被执行" + +**正确的替代**(按优先级): + +| 你想保证的 | 错误做法 | 正确做法 | +| --- | --- | --- | +| 生产装配接了某根线 | 读源码找 `kstate.Mount(ks)` | 从零启动真实装配,断言**可观测结果**(如 `kstate.Get() != nil`) | +| 协议两端字段一致 | 正则提取两边的字符串字面量 | 起 handler,发真实事件,断言**收到的 JSON 字段** | +| 某段保护代码没被删 | 读源码找字符串 | 构造触发条件,断言**保护真的生效**(如请求被拒绝) | +| 某人没删掉某条判据 | 读测试文件找函数名 | CI 层守卫:`git diff --name-status` 检测删除 | + +**唯一的例外**:CI 配置自检(如"排除表不许静默退化")。 +那类检查的**对象就是配置文件本身**,读它是正当的 —— 因为它没有别的 +可观测行为。但即便如此,也要在注释里写明"这是配置自检,不是行为判据"。 + +### 5.2.1 判据不得以文档为依据 + +**"测试引用文档"本身就是错的** —— 文档可以被改、被删、被改写, +而判据必须锚在**代码的当前行为**上。 + +实测反例(`internal/agent/core/interrupt_invariant_test.go:336`): + +```go +// 事实清单(来自探测,见 docs/zh/interrupt-subsystem-split-plan.md §1.5)。 +// 这里只断言"这些字段在 scheduler 上存在",作为改写时的锚点; +``` + +问题有三层: + +1. **它引用的是一份"未实施计划"** —— 被引用方自己写着 + 「状态:计划(未实施)」。判据锚在一个**不存在的事实**上。 +2. **它没测任何契约**。实际断言只有"入队后队列里 1 条", + 而那是中断功能最基本的正确性 —— 跟它声称的"反向依赖现状快照"毫无关系。 +3. **它把"未做的工作"变成了通过的判据**。改任何东西都不会让它红, + 除非入队函数彻底坏掉;而那时别的判据早就红了。 + +⇒ 出现这种形态时,正确处置是**删掉它**(或改写成真判据), +然后把这个待办登记进 `future-plan.md`。 +**不存在"用一条永远绿的判据占位待办"这种合法形态。** + +**判据要问**:这条判据的依据是代码行为,还是一份可能会变的文档? +如果是后者,它保护的不是系统,是文字。 + +### 5.3 测试文件与业务代码分离 + +**现状(2026-11-04 实测)**: + +```text +全仓测试文件 521 个 +测试代码行数 112,068 行 +internal/agent/core/ 196 个文件里 144 个是测试(73%) +internal/plugins/webui/ 29 个测试文件,全部 package webui(白盒) +全仓 package X_test 仅 3 个 +``` + +问题不是"测试多",而是: + +1. `ls internal/agent/core/` 看不出这个模块由什么组成 +2. 100% 白盒 ⇒ 100% 的判据都能碰内部 +3. 想改成黑箱必须先移动文件,而移动文件又会被源码扫描类判据挡住 + +**约定**(新代码必须遵守,存量逐步迁): + +- **黑箱判据用 `package _test`**,放 `*_contract_test.go` +- **白盒判据**(仅 §5.1 的三个例外)放 `*_internal_test.go`, + 并在文件头注明"为什么必须白盒" +- 新增判据**默认黑箱**;要写白盒必须在文件头回答: + 这条判据能用黑箱表达吗?为什么不能? + +**这不是风格问题**:一个包里如果 90% 判据是白盒,这个包的模块边界 +事实上不存在 —— 谁都不敢改内部实现。 + +### 5.4 CI 必须覆盖每个独立 module + +**实测缺口(2026-11-04)**:主仓有 `third_party/hologramagentsdk/`, +它有自己独立的 `go.mod`(模块 `github.com/JianFeeeee/hologramagentsdk`) +**和独立的 `.git`**(remote: `github.com/JianFeeeee/hologramagentsdk`)。 + +后果: + +```text +go list ./... | grep third_party → 0 个 +``` + +主仓的 `go test ./...` **永远不包含** SDK 的任何测试。 + +- SDK 仓自己的 CI 会跑(已核实它的 `.github/workflows/ci.yml` 有 + `go test ./...` 且单独处理了嵌套 module) +- 但**主仓 CI 只验证"能编译"**,不验证"SDK 的判据还绿" + +⇒ 主仓改动可能依赖 SDK 的内部行为,而 SDK 判据红了主仓不知道。 + +**规则**: + +1. 子目录里出现 `go.mod` ⇒ 它是独立 module,`go test ./...` 覆盖不到 +2. 每个独立 module 必须在 CI 里有**自己的**测试步骤 +3. 有独立 `.git` 的目录**不由主仓管理**,主仓只 vendor 必需文件; + `go.mod` 的 `replace` 指向它时,CI 必须确保这些文件存在且与 + 上游 HEAD 一致 + +### 5.5 CI 与本地必须跑同一套门 + +**实测缺口**:提交纪律要求 `gofmt -l` 干净,但三个 workflow 里 +`gofmt` 出现 **0 次**。纪律只靠人遵守,CI 不兜底。 + +同理缺 `staticcheck` / `govulncheck` / coverage。 + +**规则**:写进提交纪律的每一条,都必须在 CI 里有对应步骤。 +否则它不是纪律,是**建议**。 + +| 纪律条目 | CI 有没有兜底 | +| --- | --- | +| `go build ./...` | ✅ | +| `go vet ./...` | ✅ | +| `gofmt -l` 干净 | ❌ **必须补** | +| `go test -race` 全绿 | ✅ | +| 干净 worktree 能编译 | ❌ 靠人 | + + --- ## 附:这些教训的共同点 @@ -174,6 +355,9 @@ - 单数路径对 ≠ 复数路径也对 - 名字相同 ≠ 是同一个东西 - 计划写完 ≠ 事情做完 +- **判据通过 ≠ 契约被保护**(它可能钉的是实现,见 §5.1) +- **`go test ./...` 绿 ≠ 全部测试跑过**(独立 module 不在里面,见 §5.4) +- **纪律写着 ≠ 有人执行**(CI 没兜底的纪律只是建议,见 §5.5) **所以写判据、写文档、写代码时,都要问一句:我现在拿到的这个信号, 在什么情况下会是假的?** diff --git a/internal/agent/core/interrupt_invariant_test.go b/internal/agent/core/interrupt_invariant_test.go index e699595b..adea4efa 100644 --- a/internal/agent/core/interrupt_invariant_test.go +++ b/internal/agent/core/interrupt_invariant_test.go @@ -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) - } -} diff --git a/internal/agent/core/tool_timeout_test.go b/internal/agent/core/tool_timeout_test.go index 699c102a..ec2e610a 100644 --- a/internal/agent/core/tool_timeout_test.go +++ b/internal/agent/core/tool_timeout_test.go @@ -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 }) } diff --git a/internal/agent/core/turn_hint_test.go b/internal/agent/core/turn_hint_test.go index c54756a9..a247396a 100644 --- a/internal/agent/core/turn_hint_test.go +++ b/internal/agent/core/turn_hint_test.go @@ -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) } } } diff --git a/internal/agent/core/zz_timeout_helper_test.go b/internal/agent/core/zz_timeout_helper_test.go deleted file mode 100644 index 7148c59b..00000000 --- a/internal/agent/core/zz_timeout_helper_test.go +++ /dev/null @@ -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} -} diff --git a/internal/plugins/webui/handler_files_contract_test.go b/internal/plugins/webui/handler_files_contract_test.go new file mode 100644 index 00000000..a21b3fc2 --- /dev/null +++ b/internal/plugins/webui/handler_files_contract_test.go @@ -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/<名字>.", 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(""), 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") + } +} diff --git a/third_party/hologramagentsdk/sdk/plugin_test.go b/third_party/hologramagentsdk/sdk/test/plugin_test.go similarity index 58% rename from third_party/hologramagentsdk/sdk/plugin_test.go rename to third_party/hologramagentsdk/sdk/test/plugin_test.go index 0ad42632..cf08fb4d 100644 --- a/third_party/hologramagentsdk/sdk/plugin_test.go +++ b/third_party/hologramagentsdk/sdk/test/plugin_test.go @@ -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{}{}}, diff --git a/third_party/hologramagentsdk/sdk/proxy_test.go b/third_party/hologramagentsdk/sdk/test/proxy_test.go similarity index 62% rename from third_party/hologramagentsdk/sdk/proxy_test.go rename to third_party/hologramagentsdk/sdk/test/proxy_test.go index db978f60..3e436316 100644 --- a/third_party/hologramagentsdk/sdk/proxy_test.go +++ b/third_party/hologramagentsdk/sdk/test/proxy_test.go @@ -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 应被拒(会覆盖整个门户)") } } diff --git a/third_party/hologramagentsdk/sdk/stress_test.go b/third_party/hologramagentsdk/sdk/test/stress_test.go similarity index 83% rename from third_party/hologramagentsdk/sdk/stress_test.go rename to third_party/hologramagentsdk/sdk/test/stress_test.go index f22227f4..cb77b9e6 100644 --- a/third_party/hologramagentsdk/sdk/stress_test.go +++ b/third_party/hologramagentsdk/sdk/test/stress_test.go @@ -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 }) } }() }