Files
HomeAgent/internal/plugins/healthcheck/plugin_test.go
2026-08-14 16:15:24 +08:00

563 lines
16 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 healthcheck
import (
"encoding/json"
"os"
"strings"
"testing"
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
doc "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
type toolCapture struct {
handlers map[string]sdk.ToolHandler
defs map[string]sdk.ToolDef
}
func newToolCapture() *toolCapture {
return &toolCapture{
handlers: make(map[string]sdk.ToolHandler),
defs: make(map[string]sdk.ToolDef),
}
}
func (tc *toolCapture) RegisterTool(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
tc.handlers[name] = handler
tc.defs[name] = def
return nil
}
func (tc *toolCapture) RegisterStage(stage sdk.Stage, handler sdk.StageHandler) {}
func (tc *toolCapture) RegisterAPI(name string) error { return nil }
func newTestSDK(cfg sdk.SDKConfig) *sdk.PluginSDK {
if cfg.Settings == nil {
cfg.Settings = sdk.NewSettings("healthcheck", nil)
}
if cfg.Tool == nil {
cfg.Tool = sdk.NewTool(agentCore.NewStageHost(), agentIO.NewIOManager())
}
return sdk.New("healthcheck", cfg)
}
func setupPlugin() (*Plugin, *toolCapture, error) {
return setupPluginWith(sdk.SDKConfig{})
}
func setupPluginWith(cfg sdk.SDKConfig) (*Plugin, *toolCapture, error) {
tc := newToolCapture()
cfg.RegTool = tc.RegisterTool
cfg.RegStage = tc.RegisterStage
cfg.RegAPI = tc.RegisterAPI
p := New("healthcheck")
s := newTestSDK(cfg)
if err := p.Start(s); err != nil {
return nil, nil, err
}
return p, tc, nil
}
func TestToolsRegistered(t *testing.T) {
_, tc, err := setupPlugin()
if err != nil {
t.Fatal(err)
}
expected := []string{
"healthcheck",
"healthcheck_plugins",
"healthcheck_tools",
"healthcheck_memory",
"healthcheck_report",
"healthcheck_perf",
}
for _, name := range expected {
if _, ok := tc.handlers[name]; !ok {
t.Errorf("tool %q not registered", name)
}
}
}
func TestHealthcheckFull(t *testing.T) {
_, tc, err := setupPlugin()
if err != nil {
t.Fatal(err)
}
handler := tc.handlers["healthcheck"]
result, err := handler(map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
data, _ := json.Marshal(result)
var resp map[string]interface{}
json.Unmarshal(data, &resp)
if resp["status"] != "ok" {
t.Fatalf("expected status ok, got %v", resp["status"])
}
checks := resp["checks"].([]interface{})
if len(checks) == 0 {
t.Fatal("expected at least some checks")
}
for _, c := range checks {
cr := c.(map[string]interface{})
name := cr["name"].(string)
pass := cr["pass"].(bool)
if !pass && cr["status"] != "skip" {
t.Errorf("check %q failed: %v (detail: %v)", name, cr["status"], cr["detail"])
}
}
}
func TestHealthcheckPlugins(t *testing.T) {
_, tc, err := setupPlugin()
if err != nil {
t.Fatal(err)
}
handler := tc.handlers["healthcheck_plugins"]
result, err := handler(map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
data, _ := json.Marshal(result)
var resp map[string]interface{}
json.Unmarshal(data, &resp)
if resp["status"] != "ok" {
t.Fatalf("expected status ok, got %v", resp["status"])
}
}
func TestHealthcheckToolsList(t *testing.T) {
_, tc, err := setupPlugin()
if err != nil {
t.Fatal(err)
}
handler := tc.handlers["healthcheck_tools"]
result, err := handler(map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
data, _ := json.Marshal(result)
var resp map[string]interface{}
json.Unmarshal(data, &resp)
if resp["status"] != "ok" {
t.Fatalf("expected status ok, got %v", resp["status"])
}
}
func TestHealthcheckMemoryNotAvailable(t *testing.T) {
_, tc, err := setupPlugin()
if err != nil {
t.Fatal(err)
}
handler := tc.handlers["healthcheck_memory"]
result, err := handler(map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
data, _ := json.Marshal(result)
var resp map[string]interface{}
json.Unmarshal(data, &resp)
// Memory is nil in this setup, so it should skip gracefully
if _, ok := resp["pass"]; ok {
pass := resp["pass"].(bool)
if !pass {
t.Fatalf("expected pass=true when memory is nil, got false: %v", resp)
}
}
}
func TestHealthcheckWithMemory(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "hc_test_*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
memDB, err := memory.NewGraphDB(tmpDir + "/test.db")
if err != nil {
t.Fatal(err)
}
defer memDB.Close()
// 预置一条生产数据,验证 healthcheck 自检后不触碰它
_, _, err = memDB.Commit([]memory.Triple{{Subject: "用户", Relation: "喜欢", Object: "咖啡"}}, "test", 0)
if err != nil {
t.Fatal(err)
}
memSnapshot := func() (int, int) {
m, _ := memDB.Introspect()
ents, _ := m["entity_count"].(int)
rels, _ := m["relation_count"].(int)
return ents, rels
}
be, br := memSnapshot()
_, tc, err := setupPluginWith(sdk.SDKConfig{Memory: sdk.NewGraphMemory(memDB)})
if err != nil {
t.Fatal(err)
}
handler := tc.handlers["healthcheck_memory"]
result, err := handler(map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
data, _ := json.Marshal(result)
var resp map[string]interface{}
json.Unmarshal(data, &resp)
if resp["status"] != "ok" {
t.Fatalf("expected status ok, got %v (detail=%v)", resp["status"], resp["detail"])
}
if pass, ok := resp["pass"].(bool); !ok || !pass {
t.Fatalf("expected pass=true, got pass=%v status=%v detail=%v", pass, resp["status"], resp["detail"])
}
// 关键:生产记忆内容必须保持不变(未被 healthcheck 污染)
ae, ar := memSnapshot()
if ae != be || ar != br {
t.Fatalf("production memory polluted by healthcheck self-test: before=(%d,%d) after=(%d,%d)", be, br, ae, ar)
}
// 再次确认:注入实例中不应出现 _hc_ 测试实体
relResult, _ := memDB.Recall([]string{"_hc_"}, nil, 1, "")
for _, e := range relResult.Entities {
if len(e.Name) >= 4 && e.Name[:4] == "_hc_" {
t.Fatalf("healthcheck left _hc_ entity in production memory: %q", e.Name)
}
}
}
func TestHealthcheckWithKnowledge(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "hc_know_test_*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
ks := knowledge.NewStore(tmpDir)
if err := ks.Start(); err != nil {
t.Fatal(err)
}
defer ks.Stop()
// 预置一条真实知识,验证 healthcheck 自检后不触碰它
if err := ks.Add("生产知识点", "这是生产知识,不应被健康检查破坏"); err != nil {
t.Fatal(err)
}
before := len(ks.List())
_, tc, err := setupPluginWith(sdk.SDKConfig{Knowledge: sdk.NewKnowledge(ks)})
if err != nil {
t.Fatal(err)
}
handler := tc.handlers["healthcheck"]
result, err := handler(map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
data, _ := json.Marshal(result)
var resp map[string]interface{}
json.Unmarshal(data, &resp)
if resp["status"] != "ok" {
t.Fatalf("expected status ok, got %v", resp["status"])
}
checks := resp["checks"].([]interface{})
var knowledgeCheck map[string]interface{}
for _, c := range checks {
cr := c.(map[string]interface{})
if cr["name"] == "knowledge" {
knowledgeCheck = cr
break
}
}
if knowledgeCheck == nil {
t.Fatal("expected knowledge check in results")
}
if knowledgeCheck["status"] != "ok" {
t.Fatalf("expected knowledge check ok, got %v (detail=%v)", knowledgeCheck["status"], knowledgeCheck["detail"])
}
// 生产知识库内容必须保持不变(未被 healthcheck 污染)
after := len(ks.List())
if after != before {
t.Fatalf("production knowledge polluted: before=%d after=%d", before, after)
}
for _, name := range ks.List() {
if strings.HasPrefix(name, "_hc_knowledge_test_") {
t.Fatalf("healthcheck left test knowledge in production: %q", name)
}
}
}
func TestHealthcheckWithDocStore(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "hc_doc_test_*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
ds := doc.NewStore(tmpDir)
if err := ds.Start(); err != nil {
t.Fatal(err)
}
defer ds.Stop()
// 预置一篇生产文档,验证 healthcheck 自检后不触碰它
if err := ds.Insert(&doc.Doc{ID: "prod_doc", Summary: "生产文档", Content: "这是生产文档,不应被健康检查破坏"}); err != nil {
t.Fatal(err)
}
before := len(ds.Query("生产文档", 10))
_, tc, err := setupPluginWith(sdk.SDKConfig{DocMemory: sdk.NewDocMemory(ds)})
if err != nil {
t.Fatal(err)
}
handler := tc.handlers["healthcheck"]
result, err := handler(map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
data, _ := json.Marshal(result)
var resp map[string]interface{}
json.Unmarshal(data, &resp)
if resp["status"] != "ok" {
t.Fatalf("expected status ok, got %v", resp["status"])
}
checks := resp["checks"].([]interface{})
var docCheck map[string]interface{}
for _, c := range checks {
cr := c.(map[string]interface{})
if cr["name"] == "documents" {
docCheck = cr
break
}
}
if docCheck == nil {
t.Fatal("expected documents check in results")
}
if docCheck["status"] != "ok" {
t.Fatalf("expected documents check ok, got %v (detail=%v)", docCheck["status"], docCheck["detail"])
}
// 生产文档必须保持不变(未被 healthcheck 污染)
after := len(ds.Query("生产文档", 10))
if after != before {
t.Fatalf("production doc store polluted: before=%d after=%d", before, after)
}
}
func TestLLMReportCollection(t *testing.T) {
p := &Plugin{name: "healthcheck"}
if len(p.reports) != 0 {
t.Fatal("expected empty reports")
}
p.mu.Lock()
p.reports = append(p.reports, llmReport{ToolName: "test_tool", Status: "ok", Detail: "test passed"})
count := len(p.reports)
p.mu.Unlock()
if count != 1 {
t.Fatalf("expected 1 report, got %d", count)
}
if p.reports[0].ToolName != "test_tool" {
t.Fatalf("expected tool_name=test_tool, got %s", p.reports[0].ToolName)
}
}
// TestSafeReadonlyTool 验证 LLM 自检工具过滤:写类/副作用工具被拒绝,只读工具被放行。
func TestSafeReadonlyTool(t *testing.T) {
// 只读:应放行
readonly := []string{
"memory_recall", "memory_introspect", "doc_query",
"knowledge_search", "knowledge_list", "person_query",
"person_network", "llm_list_sources", "output_list_channels",
"terminal_list", "files_list",
}
for _, name := range readonly {
if !isSafeReadonlyTool(name) {
t.Errorf("expected readonly tool %q to be safe, but rejected", name)
}
}
// 写/删/改/副作用:应被拒绝
mutating := []string{
"memory_commit", "memory_edit", "memory_purge", "memory_delete_entity",
"memory_merge", "memory_block_merge",
"knowledge_create", "knowledge_delete",
"doc_commit", "doc_delete",
"person_set_trait", "person_relate",
"output_send__qq", "output_send__cli",
"llm_set_source", "config_set",
"timer_set", "plgreload", "plugin_disable",
"cmd_run", "files_write", "files_delete", "terminal_create", "terminal_write",
"terminal_close", "spawn_child",
}
for _, name := range mutating {
if isSafeReadonlyTool(name) {
t.Errorf("expected mutating tool %q to be rejected, but allowed", name)
}
}
}
// TestCollectToolDefsForLLMNoMutating 验证 collectToolDefsForLLM 不会把写类工具交给 LLM 自检。
func TestCollectToolDefsForLLMNoMutating(t *testing.T) {
p := &Plugin{name: "healthcheck", selfToolNames: map[string]bool{"healthcheck": true, "healthcheck_report": true}}
// 构造一个包含写类工具的 ToolDef 集合,注入工具注册表
stage := agentCore.NewStageHost()
defs := []sdk.ToolDef{
{Name: "memory_recall", Plugin: "memory", Description: "recall"},
{Name: "memory_commit", Plugin: "memory", Description: "commit"},
{Name: "doc_query", Plugin: "doc", Description: "query"},
{Name: "doc_commit", Plugin: "doc", Description: "commit doc"},
{Name: "knowledge_search", Plugin: "knowledge", Description: "search"},
{Name: "knowledge_create", Plugin: "knowledge", Description: "create"},
{Name: "cmd_run", Plugin: "cmd", Description: "run cmd"},
{Name: "files_list", Plugin: "files", Description: "list"},
}
for _, d := range defs {
d := d
stage.RegisterTool(d.Name, d, func(map[string]interface{}) (interface{}, error) { return nil, nil })
}
tc := newToolCapture()
s := newTestSDK(sdk.SDKConfig{
RegTool: tc.RegisterTool,
RegStage: tc.RegisterStage,
RegAPI: tc.RegisterAPI,
Tool: sdk.NewTool(stage, agentIO.NewIOManager()),
})
got := p.collectToolDefsForLLM(s, "")
allowed := map[string]bool{}
for _, d := range got {
allowed[d.Name] = true
}
// 只读工具应被包含
for _, name := range []string{"memory_recall", "doc_query", "knowledge_search", "files_list"} {
if !allowed[name] {
t.Errorf("expected readonly tool %q in LLM selftest set, missing", name)
}
}
// 写类/副作用工具绝不能被交给 LLM
for _, name := range []string{"memory_commit", "doc_commit", "knowledge_create", "cmd_run"} {
if allowed[name] {
t.Errorf("mutating tool %q must NOT be in LLM selftest set", name)
}
}
}
// TestCollectToolDefsForLLMPluginFilter 验证指定插件时只收集该插件的只读工具。
func TestCollectToolDefsForLLMPluginFilter(t *testing.T) {
p := &Plugin{name: "healthcheck", selfToolNames: map[string]bool{"healthcheck": true, "healthcheck_report": true}}
stage := agentCore.NewStageHost()
defs := []sdk.ToolDef{
{Name: "memory_recall", Plugin: "memory", Description: "recall"},
{Name: "memory_commit", Plugin: "memory", Description: "commit"},
{Name: "doc_query", Plugin: "doc", Description: "query"},
{Name: "doc_commit", Plugin: "doc", Description: "commit doc"},
{Name: "knowledge_search", Plugin: "knowledge", Description: "search"},
{Name: "files_list", Plugin: "files", Description: "list"},
{Name: "files_write", Plugin: "files", Description: "write"},
{Name: "cmd_run", Plugin: "cmd", Description: "run cmd"},
}
for _, d := range defs {
d := d
stage.RegisterTool(d.Name, d, func(map[string]interface{}) (interface{}, error) { return nil, nil })
}
tc := newToolCapture()
s := newTestSDK(sdk.SDKConfig{
RegTool: tc.RegisterTool,
RegStage: tc.RegisterStage,
RegAPI: tc.RegisterAPI,
Tool: sdk.NewTool(stage, agentIO.NewIOManager()),
})
got := p.collectToolDefsForLLM(s, "files")
allowed := map[string]bool{}
for _, d := range got {
allowed[d.Name] = true
}
if len(got) != 1 || !allowed["files_list"] {
t.Fatalf("expected only files_list for files plugin, got %v", allowed)
}
for _, name := range []string{"memory_recall", "doc_query", "knowledge_search", "files_write"} {
if allowed[name] {
t.Errorf("tool %q must NOT be in files-filtered set", name)
}
}
gotMemory := p.collectToolDefsForLLM(s, "memory")
if len(gotMemory) != 1 || gotMemory[0].Name != "memory_recall" {
t.Fatalf("expected only memory_recall for memory plugin, got %v", gotMemory)
}
// 全量时不丢任何只读工具
all := p.collectToolDefsForLLM(s, "")
if len(all) != 4 {
t.Fatalf("expected 4 readonly tools unfiltered, got %d", len(all))
}
}
// TestReportsToChecks 验证 LLM 上报明细转为细粒度检查项ok/skip 通过, fail 失败)。
func TestReportsToChecks(t *testing.T) {
p := &Plugin{}
p.reports = []llmReport{
{ToolName: "qq_get_message", Status: "ok", Detail: "正常"},
{ToolName: "doc_query", Status: "skip", Detail: "无可测数据"},
{ToolName: "knowledge_search", Status: "fail", Detail: "搜索超时"},
}
checks := p.reportsToChecks()
if len(checks) != 3 {
t.Fatalf("expected 3 checks, got %d", len(checks))
}
byName := map[string]checkResult{}
for _, c := range checks {
byName[c.Name] = c
}
if !byName["llm_tool/qq_get_message"].Pass {
t.Error("ok report must pass")
}
if !byName["llm_tool/doc_query"].Pass {
t.Error("skip report must pass")
}
if byName["llm_tool/knowledge_search"].Pass {
t.Error("fail report must NOT pass")
}
}