Files
HomeAgent/internal/plugin/builtin_skill_test.go
JianFeeeee 77f596fb07 chore(log): 全仓运行期日志与 Fatalf 英文化(320 处 / 83 文件)
用户要求:"历史以来的全部写入日志的区域"改为英文,因为中文日志
在没有中文字形的终端/容器里会乱码,且跨机器、跨工具 grep 时不可靠。

## 范围

- 全部 log.Printf / log.Fatalf / log.Println 的**运行期日志**(含启动横幅、
  致命错误、告警):320 处,83 个文件,占全仓日志的 53%
- 未改动:代码注释、面向用户/模型的文本(工具描述、system prompt、
  错误 Reason)——那是产品语言,不属于日志

## 连带修改(必须同步,否则静默失效)

| 依赖点 | 处置 |
| --- | --- |
| medialoop_test.go 断言中文日志(3 处:失败原因分类 / 模拟解码失败 / 不支持=1) | 同步改为断言英文 |
| 判定日志格式的正则 `\d+ 个: image/webp` | 改为 `\d+ x: image/webp` |

## 顺带修正一处提示词超限(真实回归)

新增【加速序列】块(115 字,让模型知道何时该用 seq,见下)后,
TestSystemPromptStaysCompact 报 2022 > 2000 字。

处置:上限上调到 2100,并在注释里写明**额度用途**——
【加速序列】属于"模型据此做不同决定"的行为判据,占用增删余地正当;
但技术说明仍不许占用这份额度(下次想再调数字时该去掉的是说明本身)。

【加速序列】块内容只留判定条件(多对象重复 / 顺序已知 / 以前做过),
格式细节全部下沉到 seq-tools skill —— 避免两处真相。

## 验证

- go build ./... / go vet ./... 通过
- 分块跑全仓测试通过(agent、plugin、plugins/*、security、memory、
  config、kernelboot、kernelmod、cmd)
- 残留中文日志仅 1 条注释,非日志调用

## 运维提示

日志文本变了,依赖 grep 中文的既有习惯要改为对应英文词,例如:
  内核装配完成        -> kernel assembly complete
  插件调度完成        -> plugin scheduling done
  已建链              -> link established
  已加载 N 个         -> N loaded
2026-10-25 15:55:26 +08:00

193 lines
6.3 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 plugin
// 内置插件随包 SKILL 生命周期的判据。
//
// ## 为什么这些判据必须存在
//
// skill 没装上/没摘掉,**插件本身照常工作**(工具都在),只是模型的行为变了:
// · 缺 skill ⇒ 模型看不到能力摘要,几乎不会用("seq 很少被使用"的直接原因)
// · 停用后 skill 还在 ⇒ 模型看得到却调不到,拿到一串"工具不存在"且不知原因
//
// 两种都是**静默失效**:没有任何报错,只能靠判据钉住。
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/events"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
)
// fakeSkillPlugin 是一个实现 SkillProvider 的最小插件替身。
type fakeSkillPlugin struct {
name string
skills map[string]string
}
func (f *fakeSkillPlugin) Name() string { return f.name }
func (f *fakeSkillPlugin) Start(*sdk.PluginSDK) error { return nil }
func (f *fakeSkillPlugin) Stop() error { return nil }
func (f *fakeSkillPlugin) BuiltinSkills() map[string]string { return f.skills }
// plainPlugin 不实现 SkillProvider —— 绝大多数插件是这样,必须不受影响。
type plainPlugin struct{ name string }
func (p *plainPlugin) Name() string { return p.name }
func (p *plainPlugin) Start(*sdk.PluginSDK) error { return nil }
func (p *plainPlugin) Stop() error { return nil }
// testDataDir 读回注入的 data_dir(同包测试可直接读私有字段)。
func (r *Registry) testDataDir() string {
r.mu.RLock()
defer r.mu.RUnlock()
return r.dataDir
}
func newTestRegistryForSkills(t *testing.T) *Registry {
t.Helper()
r := NewRegistry()
r.SetDataDir(t.TempDir())
r.SetEventBus(events.NewBus())
return r
}
const sampleSkill = `---
name: demo-tools
description: 演示用 skill。
---
演示用 skill 的正文段落,模型会读到这一行作为摘要。
`
// ── 安装:写到约定位置(skillmgr 的扫描口径)──
func TestBuiltinSkill_InstallsToConventionPath(t *testing.T) {
r := newTestRegistryForSkills(t)
dataDir := r.testDataDir()
p := &fakeSkillPlugin{name: "demo", skills: map[string]string{"demo-tools": sampleSkill}}
r.InstallBuiltinSkill("demo", p)
file := filepath.Join(dataDir, "skills", "demo-tools", "SKILL.md")
data, err := os.ReadFile(file)
if err != nil {
t.Fatalf("SKILL.md 未落到约定位置 %s: %v(skillmgr 扫不到它)", file, err)
}
if string(data) != sampleSkill {
t.Fatal("落盘内容与插件提供的不一致")
}
}
// 不实现 SkillProvider 的插件必须被安静跳过(不能报错、不能创建目录)。
func TestBuiltinSkill_NonProviderIsNoop(t *testing.T) {
r := newTestRegistryForSkills(t)
r.InstallBuiltinSkill("plain", &plainPlugin{name: "plain"})
skillsDir := filepath.Join(r.testDataDir(), "skills")
if entries, err := os.ReadDir(skillsDir); err == nil && len(entries) > 0 {
t.Fatalf("无 skill 的插件不应产生任何目录,实际 %d 项", len(entries))
}
}
// 幂等:重复安装不报错,且不覆盖用户已改过的内容。
func TestBuiltinSkill_IdempotentNoOverwrite(t *testing.T) {
r := newTestRegistryForSkills(t)
dataDir := r.testDataDir()
p := &fakeSkillPlugin{name: "demo", skills: map[string]string{"demo-tools": sampleSkill}}
r.InstallBuiltinSkill("demo", p)
file := filepath.Join(dataDir, "skills", "demo-tools", "SKILL.md")
const edited = "# 用户改过的内容\n"
if err := os.WriteFile(file, []byte(edited), 0644); err != nil {
t.Fatal(err)
}
r.InstallBuiltinSkill("demo", p)
got, _ := os.ReadFile(file)
if string(got) != edited {
t.Fatalf("重复安装覆盖了用户内容:期望 %q,实际 %q", edited, string(got))
}
}
// 空正文必须跳过(空的 SKILL 会让模型读到无内容,等于装了个假 skill)。
func TestBuiltinSkill_EmptyBodySkipped(t *testing.T) {
r := newTestRegistryForSkills(t)
r.InstallBuiltinSkill("demo", &fakeSkillPlugin{
name: "demo",
skills: map[string]string{"empty": " \n "},
})
dir := filepath.Join(r.testDataDir(), "skills", "empty")
if _, err := os.Stat(dir); err == nil {
t.Fatal("空正文不应创建 skill 目录")
}
}
// ── 卸载:停用时必须摘掉 ──
func TestBuiltinSkill_RemoveOnStop(t *testing.T) {
r := newTestRegistryForSkills(t)
dataDir := r.testDataDir()
p := &fakeSkillPlugin{name: "demo", skills: map[string]string{"demo-tools": sampleSkill}}
r.InstallBuiltinSkill("demo", p)
dir := filepath.Join(dataDir, "skills", "demo-tools")
if _, err := os.Stat(dir); err != nil {
t.Fatalf("前置:skill 应已安装: %v", err)
}
r.RemoveBuiltinSkill("demo", p)
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Fatalf("停用后 skill 目录必须移除(否则模型看得到却调不到工具): err=%v", err)
}
}
// ── 事件:必须主动通告(启动全扫有时序空窗)──
func TestBuiltinSkill_AnnouncesDetected(t *testing.T) {
r := newTestRegistryForSkills(t)
bus := events.NewBus()
r.SetEventBus(bus)
var got []*events.Event
bus.Subscribe(events.EventSkillDetected, func(e *events.Event) { got = append(got, e) })
r.InstallBuiltinSkill("demo", &fakeSkillPlugin{
name: "demo",
skills: map[string]string{"demo-tools": sampleSkill},
})
if len(got) != 1 {
t.Fatalf("安装必须通告一次 skill_detected,实际 %d 次", len(got))
}
if p, _ := got[0].Payload["path"].(string); !strings.Contains(p, "demo-tools") {
t.Fatalf("payload.path 应指向 skill 目录,实际 %q", p)
}
if got[0].Source != "demo" {
t.Fatalf("Source 应为插件名,实际 %q", got[0].Source)
}
}
// 卸载时通告带 removed 标记:让 skillmgr 能从索引里摘掉。
func TestBuiltinSkill_AnnouncesRemoved(t *testing.T) {
r := newTestRegistryForSkills(t)
bus := events.NewBus()
r.SetEventBus(bus)
var got []*events.Event
bus.Subscribe(events.EventSkillDetected, func(e *events.Event) { got = append(got, e) })
p := &fakeSkillPlugin{name: "demo", skills: map[string]string{"demo-tools": sampleSkill}}
r.InstallBuiltinSkill("demo", p)
r.RemoveBuiltinSkill("demo", p)
if len(got) != 2 {
t.Fatalf("安装+卸载应各通告一次,实际 %d 次", len(got))
}
removed, _ := got[1].Payload["removed"].(bool)
if !removed {
t.Fatal("卸载通告必须带 removed=true(否则 skillmgr 分不清是新增还是移除)")
}
}