Files
HomeAgent/internal/plugins/seq/skill_test.go
JianFeeeee d65b742bd7 fix(plugin): 修两处持锁自死锁(CI 双红真因)+ tools_ready 事件声明
## 死锁(这是 CI 连续两次红的真因)

`StopAll` 与 `StopPlugin` 都在持有 `r.mu` **写锁**时调用
`RemoveBuiltinSkill` → `skillDirOf` → `r.mu.RLock()` ⇒ **自死锁**。

后果不是报错而是**卡死**:
- internal/plugins 卡 300s 超时(TestCapability_RestrictedPluginStillLoads)
- internal/plugin  卡 840s 超时(TestStopPluginDoesNotTouchPersistentState)
- CI 的 `go test ./...` 与 `go test -race` 双双红

修法:拆出**不加锁**的内部版本,持锁路径只走它:
- `skillDirFrom(dataDir, name)`(纯函数)
- `removeBuiltinSkillLocked(pluginName, p, dataDir)`(显式传 dataDir,
  避免"看起来没取锁、一改就取锁"的隐患)
- `announceSkillRemovedLocked`(持锁下直读 r.evBus)

修后:internal/plugins 300s → 12s,internal/plugin 840s → 0.7s。

## 防回归判据

`TestStopAllAndStopPluginDoNotDeadlock`:死锁**不返回**,普通断言抓不到,
所以用**超时**当判据(5 秒未返回即失败),并指明应改用 Locked 版本。

## tools_ready 事件声明

新增的 tools_ready 触发 `TestEveryPublishedEventHasAnOutwardPath`
(该判据防"事件发了、客户端收不到"的静默失效)。

tools_ready 是**内核内部**就绪信号(plugin-host → seq 等订阅者),
不是对话流程事件,前端无需感知 ⇒ 在 allowed 里显式声明理由。

## 验证

go build ./... / go vet ./... / go test -p 1 ./... / -race 全通过。
2026-10-25 18:25:37 +08:00

87 lines
2.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package seq
import (
"strings"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/plugin"
)
// 编译期断言:seq 必须实现 plugin.SkillProvider,
// 否则内核的 InstallBuiltinSkill 会因类型断言失败而静默跳过。
var _ plugin.SkillProvider = (*Plugin)(nil)
func TestSkillProviderContract(t *testing.T) {
p := New("seq")
skills := p.BuiltinSkills()
if len(skills) == 0 {
t.Fatal("BuiltinSkills 返回空 —— model 永远看不到 seq")
}
body, ok := skills[builtinSkillName]
if !ok {
t.Fatalf("缺 %q 条目", builtinSkillName)
}
if len(body) < 200 {
t.Fatalf("SKILL 正文过短(%d 字节),不足以让模型知道怎么用", len(body))
}
}
// ── 使用边界必须写清楚(B 方案的判据)──
//
// 决策背景:曾试图"让模型多用 seq"(加系统提示词块、加推销式措辞),
// e2e 实测证明那是错的方向 —— 对一次性的多文件读,模型并行调
// files_read 就是最优解,包成序列纯亏。模型不用它是对的。
//
// 所以文档改为**直说边界**:多数时候不该用,只在"要反复跑同一套流程"时才划算。
// 这条判据守的就是这个立场不被后续"优化"悄悄推回推销腔。
func TestSkillStatesNonUseCases(t *testing.T) {
body := New("seq").BuiltinSkills()[builtinSkillName]
// 必须明确写"多数时候不该用"这个立场
for _, want := range []string{"多数时候不该用", "不划算", "只做一次"} {
if !strings.Contains(body, want) {
t.Fatalf("SKILL 必须写清不适用情形(缺 %q)——否则会重新变成推销", want)
}
}
// 必须点出"并行调工具已经最优"这个真实反例(e2e 实测结论)
if !strings.Contains(body, "并行") {
t.Fatal("SKILL 应点明「几个工具可同批并行时,包进序列只是多一层壳」这个反例")
}
// 适用判据必须是「要反复跑」而不是「操作多」
if !strings.Contains(body, "反复") {
t.Fatal("SKILL 的核心判据应是「这套流程要反复跑」,不是「操作数量多」")
}
}
// 索引行(模型每轮唯一能看到的一行)也必须带边界,不能只有卖点。
func TestSkillIndexLineStatesBoundary(t *testing.T) {
body := New("seq").BuiltinSkills()[builtinSkillName]
// 取正文首段(内核 extractDescription 的取值方式)
lines := strings.Split(body, "\n")
inFM, started := false, false
desc := ""
for i, l := range lines {
t := strings.TrimSpace(l)
if i == 0 && t == "---" {
inFM = true
continue
}
if inFM {
if t == "---" {
inFM, started = false, true
}
continue
}
if started && t != "" && !strings.HasPrefix(t, "#") {
desc = t
break
}
}
if desc == "" {
t.Fatal("取不到索引行(正文首段为空)")
}
if !strings.Contains(desc, "反复") || !strings.Contains(desc, "一次性") {
t.Fatalf("索引行必须同时说清适用与不适用,实际:%q", desc)
}
}