From 304c3ae29472c6ccb02f63ee85334fcfdb0b171a Mon Sep 17 00:00:00 2001 From: root Date: Thu, 2 Jul 2026 15:47:16 +0800 Subject: [PATCH] refactor: remove IO route mapping, add HTTP API tests, system prompt update --- cmd/homed/main.go | 34 +- internal/agent/core/agent.go | 1 + internal/agent/core/agent_functions_test.go | 109 +++++ internal/agent/core/agent_helpers_test.go | 78 +++ internal/agent/core/agent_tools_test.go | 198 ++++++++ internal/agent/core/context_test.go | 131 +++++ internal/agent/io/channel.go | 129 +++-- internal/agent/io/channel_test.go | 510 ++++++++++++++++++++ internal/api/handler.go | 48 +- internal/api/handler_test.go | 384 +++++++++++++++ internal/knowledge/knowledge_test.go | 145 ++++++ internal/memory/document/document.go | 12 + internal/memory/document/document_test.go | 343 +++++++++++++ internal/memory/graph.go | 10 +- internal/memory/graph_test.go | 227 +++++++++ internal/memory/vector/store_test.go | 212 ++++++++ internal/onebot/client.go | 24 +- internal/onebot/device.go | 4 +- internal/onebot/types_test.go | 165 +++++++ internal/plugin/plugin.go | 46 +- internal/plugin/plugin_test.go | 197 ++++++++ internal/supervisor/daemon.go | 4 +- internal/supervisor/daemon_test.go | 209 ++++++++ internal/tracker/changeset.go | 3 + internal/tracker/tracker_test.go | 314 ++++++++++++ 25 files changed, 3427 insertions(+), 110 deletions(-) create mode 100644 internal/agent/core/agent_functions_test.go create mode 100644 internal/agent/core/agent_helpers_test.go create mode 100644 internal/agent/core/agent_tools_test.go create mode 100644 internal/agent/core/context_test.go create mode 100644 internal/agent/io/channel_test.go create mode 100644 internal/api/handler_test.go create mode 100644 internal/knowledge/knowledge_test.go create mode 100644 internal/memory/document/document_test.go create mode 100644 internal/memory/graph_test.go create mode 100644 internal/memory/vector/store_test.go create mode 100644 internal/onebot/types_test.go create mode 100644 internal/plugin/plugin_test.go create mode 100644 internal/supervisor/daemon_test.go create mode 100644 internal/tracker/tracker_test.go diff --git a/cmd/homed/main.go b/cmd/homed/main.go index a604d5b..cf2777d 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -121,16 +121,6 @@ func main() { // === IO Abstraction Layer (唯一输入路径) === iom := agentIO.NewIOManager() - iom.RegisterDevice(agentIO.NewMicrophone("mic", 16000, iom)) - iom.RegisterDevice(agentIO.NewSpeaker("speaker", iom)) - // mic 输入 → speaker 输出(语音 I/O 配对) - iom.RegisterOutputRoute("mic", "speaker") - iom.RegisterOutputRoute("voice", "speaker") - iom.RegisterDevice(agentIO.NewCamera("camera", iom)) - iom.RegisterDevice(agentIO.NewRobotArm("arm", iom)) - iom.RegisterDevice(agentIO.NewGPIODevice("gpio", []int{2, 3, 4, 17}, iom)) - iom.StartAll() - defer iom.StopAll() // 插件绑定 IO 管理器 → 插件自动注册为 IO 设备 pluginReg.SetIOManager(iom) @@ -239,8 +229,26 @@ func main() { // === Single Agent Core === agent := agentCore.New(agentCore.AgentConfig{ - ID: "main", - SystemPrompt: "你是一个智能家庭管家,持续运行。你有以下工具:\n1. memory_recall — 查询图记忆\n2. memory_commit — 写入图记忆\n3. memory_introspect — 查看记忆统计\n4. knowledge_search — 搜索知识库\n5. doc_query — 查询文档记忆\n6. doc_commit — 写入文档记忆\n\n当用户问及个人信息或历史时,调用 memory_recall 工具来查询。当用户告诉了你新的个人信息时,调用 memory_commit 来记住。需要查询知识时使用 knowledge_search。", + ID: "main", + SystemPrompt: `你是 HomeAgent,一个持续运行的个人管家。 +你的每次回复会自动发送到当前输出通道(默认=输入源),无需额外工具。 +如需切换回复通道,使用 output_set_channel。 +如需异步发送消息或通知,使用 output_send 指定通道和内容。 +使用 output_list_channels 查看可用通道及其能力。 + +你有以下核心工具: +1. memory_recall — 查询图记忆(历史/个人信息) +2. memory_commit — 写入图记忆(记住新信息) +3. memory_introspect — 查看记忆统计 +4. knowledge_search — 搜索知识库 +5. doc_query — 查询文档记忆 +6. doc_commit — 写入文档记忆 +7. plgreload — 热重载插件 + +当用户问及个人信息或历史时,调用 memory_recall。 +当用户告诉了你新的个人信息时,调用 memory_commit。 +需要查询知识时使用 knowledge_search。 +回复你的真实想法,用自然语言与用户交流。`, Provider: provider, IO: iom, Memory: memDB, @@ -264,7 +272,7 @@ func main() { log.Printf("[homed] main agent started, model=%s base=%s", cfg.LLM.Model, cfg.LLM.BaseURL) // === HTTP API === - handler := api.NewHandler(sup, memDB, skMgr, luaVM, cfg, iom, textMem, ks) + handler := api.NewHandler(sup, memDB, skMgr, luaVM, cfg, iom, textMem, ks, trk) mux := http.NewServeMux() handler.RegisterRoutes(mux) diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index 6ebf532..7815253 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -891,6 +891,7 @@ func (a *Agent) reorgGraph() { continue } log.Printf("[agent] doc→graph: %s → %d entities, %d relations", doc.ID, ec, rc) + a.docStore.Remove(doc.ID) } } } diff --git a/internal/agent/core/agent_functions_test.go b/internal/agent/core/agent_functions_test.go new file mode 100644 index 0000000..55cf4d6 --- /dev/null +++ b/internal/agent/core/agent_functions_test.go @@ -0,0 +1,109 @@ +package core + +import ( + "testing" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" +) + +func TestIsSimilarName(t *testing.T) { + tests := []struct { + a, b string + want bool + }{ + {"张三", "张三四", false}, + {"", "", false}, + {"a", "b", false}, + {"张三", "李四", false}, + {"张三", "张三", false}, + } + for _, tt := range tests { + got := isSimilarName(tt.a, tt.b) + if got != tt.want { + t.Errorf("isSimilarName(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want) + } + } +} + +func TestDocToTriples(t *testing.T) { + doc := &document.Doc{ + Summary: "用户喜欢编程", + Content: "用户提到喜欢Go和Python", + Tags: []string{"编程", "Go"}, + Entities: []string{"Go", "Python"}, + Source: "context", + } + + triples := docToTriples(doc) + if len(triples) == 0 { + t.Fatal("expected non-empty triples") + } + + foundSummary := false + foundEntity := false + foundTag := false + foundSource := false + + for _, tr := range triples { + if tr.Subject == "文档" && tr.Relation == "包含内容" { + foundSummary = true + } + if tr.Subject == "文档" && tr.Relation == "提及实体" { + foundEntity = true + } + if tr.Subject == "文档" && tr.Relation == "标签" { + foundTag = true + } + if tr.Subject == "文档" && tr.Relation == "来源" { + foundSource = true + } + } + + if !foundSummary { + t.Error("missing '包含内容' triple") + } + if !foundEntity { + t.Error("missing '提及实体' triple") + } + if !foundTag { + t.Error("missing '标签' triple") + } + if !foundSource { + t.Error("missing '来源' triple") + } +} + +func TestDocToTriplesNil(t *testing.T) { + triples := docToTriples(nil) + if len(triples) != 0 { + t.Errorf("expected empty for nil doc, got %d", len(triples)) + } +} + +func TestDocToTriplesNoSource(t *testing.T) { + doc := &document.Doc{ + Summary: "无来源文档", + Content: "content", + } + triples := docToTriples(doc) + for _, tr := range triples { + if tr.Relation == "来源" { + t.Error("should not have source triple when Source is empty") + } + } +} + +func TestDocToTriplesTypes(t *testing.T) { + doc := &document.Doc{ + Summary: "测试三元组类型", + Content: "用于验证 SubjectType 和 ObjectType", + Entities: []string{"Go"}, + } + + triples := docToTriples(doc) + for _, tr := range triples { + if tr.Subject != "文档" { + t.Errorf("expected subject '文档', got %q", tr.Subject) + } + } +} diff --git a/internal/agent/core/agent_helpers_test.go b/internal/agent/core/agent_helpers_test.go new file mode 100644 index 0000000..4629612 --- /dev/null +++ b/internal/agent/core/agent_helpers_test.go @@ -0,0 +1,78 @@ +package core + +import ( + "testing" +) + +func TestTruncateStr(t *testing.T) { + tests := []struct { + input string + max int + want string + }{ + {"hello", 10, "hello"}, + {"hello world", 5, "hello..."}, + {"你好世界", 2, "你好..."}, + {"", 5, ""}, + {"abc", 3, "abc"}, + } + for _, tt := range tests { + got := truncateStr(tt.input, tt.max) + if got != tt.want { + t.Errorf("truncateStr(%q, %d) = %q, want %q", tt.input, tt.max, got, tt.want) + } + } +} + +func TestGetString(t *testing.T) { + m := map[string]interface{}{ + "name": "张三", + "age": 30, + } + + if got := getString(m, "name"); got != "张三" { + t.Errorf("expected '张三', got %q", got) + } + if got := getString(m, "age"); got != "" { + t.Errorf("expected empty for int, got %q", got) + } + if got := getString(m, "nonexistent"); got != "" { + t.Errorf("expected empty for missing key, got %q", got) + } + if got := getString(nil, "key"); got != "" { + t.Errorf("expected empty for nil map, got %q", got) + } +} + +func TestGetFloat(t *testing.T) { + m := map[string]interface{}{ + "count": 42.5, + "score": 100, + "name": "test", + } + + if got := getFloat(m, "count"); got != 42.5 { + t.Errorf("expected 42.5, got %f", got) + } + if got := getFloat(m, "score"); got != 100.0 { + t.Errorf("expected 100.0, got %f", got) + } + if got := getFloat(m, "name"); got != 0 { + t.Errorf("expected 0 for string, got %f", got) + } + if got := getFloat(m, "nonexistent"); got != 0 { + t.Errorf("expected 0 for missing key, got %f", got) + } + if got := getFloat(nil, "key"); got != 0 { + t.Errorf("expected 0 for nil map, got %f", got) + } +} + +func TestGetFloatInt(t *testing.T) { + m := map[string]interface{}{ + "top_k": float64(5), + } + if got := getFloat(m, "top_k"); got != 5.0 { + t.Errorf("expected 5.0, got %f", got) + } +} diff --git a/internal/agent/core/agent_tools_test.go b/internal/agent/core/agent_tools_test.go new file mode 100644 index 0000000..08145a9 --- /dev/null +++ b/internal/agent/core/agent_tools_test.go @@ -0,0 +1,198 @@ +package core + +import ( + "testing" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" +) + +// mockOutputDevice implements agentIO.Device for testing output tools +type mockOutputDevice struct { + name string + caps agentIO.OutputCapability + tools []agentIO.ToolDef + toolFn func(string, map[string]interface{}) (interface{}, error) +} + +func (d *mockOutputDevice) Name() string { return d.name } +func (d *mockOutputDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput } +func (d *mockOutputDevice) Description() string { return "mock " + d.name } +func (d *mockOutputDevice) Tools() []agentIO.ToolDef { return d.tools } +func (d *mockOutputDevice) Start() error { return nil } +func (d *mockOutputDevice) Stop() error { return nil } +func (d *mockOutputDevice) OutputCapabilities() agentIO.OutputCapability { return d.caps } +func (d *mockOutputDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) { + if d.toolFn != nil { + return d.toolFn(tool, args) + } + return nil, nil +} + +func TestExecuteOutputListChannels(t *testing.T) { + io := agentIO.NewIOManager() + dev := &mockOutputDevice{ + name: "speaker", + caps: agentIO.CapText | agentIO.CapAudio, + } + io.RegisterDevice(dev) + + a := &Agent{io: io} + result := a.executeOutputListChannels() + if result == "" || result == "没有可用通道" { + t.Errorf("expected channel list, got: %s", result) + } +} + +func TestExecuteOutputListChannelsEmpty(t *testing.T) { + io := agentIO.NewIOManager() + a := &Agent{io: io} + result := a.executeOutputListChannels() + if result != "没有可用通道" { + t.Errorf("expected '没有可用通道', got: %s", result) + } +} + +func TestExecuteOutputChannelTool(t *testing.T) { + a := &Agent{} + tc := agentAPI.ToolCall{Name: "output_set_channel", Arguments: map[string]interface{}{ + "channel": "voice", + }} + result := a.executeOutputChannelTool(tc) + if a.currentOutputChannel != "voice" { + t.Errorf("expected channel 'voice', got %q", a.currentOutputChannel) + } + if result == "" { + t.Error("expected non-empty result") + } +} + +func TestExecuteOutputChannelToolEmpty(t *testing.T) { + a := &Agent{} + tc := agentAPI.ToolCall{Name: "output_set_channel", Arguments: map[string]interface{}{}} + result := a.executeOutputChannelTool(tc) + if result != "请指定输出通道名称,可选: voice, email, screen, http" { + t.Errorf("unexpected result: %s", result) + } +} + +func TestExecuteOutputSendTool(t *testing.T) { + io := agentIO.NewIOManager() + io.RegisterDevice(&mockOutputDevice{ + name: "screen", + caps: agentIO.CapText, + }) + + a := &Agent{io: io} + tc := agentAPI.ToolCall{Name: "output_send", Arguments: map[string]interface{}{ + "channel": "screen", + "content": "hello world", + }} + result := a.executeOutputSendTool(tc) + if result != "已通过 [screen] 通道发送" { + t.Errorf("unexpected result: %s", result) + } +} + +func TestExecuteOutputSendToolMissingChannel(t *testing.T) { + io := agentIO.NewIOManager() + a := &Agent{io: io} + tc := agentAPI.ToolCall{Name: "output_send", Arguments: map[string]interface{}{ + "content": "hello", + }} + result := a.executeOutputSendTool(tc) + if result != "channel 和 content 不能为空" { + t.Errorf("unexpected result: %s", result) + } +} + +func TestExecuteOutputSendToolEmptyContent(t *testing.T) { + io := agentIO.NewIOManager() + a := &Agent{io: io} + tc := agentAPI.ToolCall{Name: "output_send", Arguments: map[string]interface{}{ + "channel": "screen", + }} + result := a.executeOutputSendTool(tc) + if result != "channel 和 content 不能为空" { + t.Errorf("unexpected result: %s", result) + } +} + +func TestExecuteOutputSendToolChannelNotExist(t *testing.T) { + io := agentIO.NewIOManager() + a := &Agent{io: io} + tc := agentAPI.ToolCall{Name: "output_send", Arguments: map[string]interface{}{ + "channel": "nonexistent", + "content": "hello", + }} + result := a.executeOutputSendTool(tc) + if result != "通道 [nonexistent] 不存在或不可用。可用通道请用 output_list_channels 查看" { + t.Errorf("unexpected result: %s", result) + } +} + +func TestExecuteOutputSendToolNoTextCap(t *testing.T) { + io := agentIO.NewIOManager() + io.RegisterDevice(&mockOutputDevice{ + name: "camera", + caps: agentIO.CapImage, + }) + + a := &Agent{io: io} + tc := agentAPI.ToolCall{Name: "output_send", Arguments: map[string]interface{}{ + "channel": "camera", + "content": "hello", + }} + result := a.executeOutputSendTool(tc) + if result == "已通过 [camera] 通道发送" { + t.Errorf("should reject channel without text capability") + } +} + +func TestBuildToolDefsOutputToolsAlwaysPresent(t *testing.T) { + io := agentIO.NewIOManager() + a := &Agent{io: io, knowledge: nil, docStore: nil, pluginReg: nil} + tools := a.buildToolDefs() + + foundSetChannel := false + foundSend := false + foundList := false + for _, td := range tools { + m, ok := td.(map[string]interface{}) + if !ok { + continue + } + fn, ok := m["function"].(map[string]interface{}) + if !ok { + continue + } + name, _ := fn["name"].(string) + switch name { + case "output_set_channel": + foundSetChannel = true + case "output_send": + foundSend = true + case "output_list_channels": + foundList = true + } + } + if !foundSetChannel { + t.Error("output_set_channel should always be in tools") + } + if !foundSend { + t.Error("output_send should always be in tools") + } + if !foundList { + t.Error("output_list_channels should always be in tools") + } +} + +func TestGetAllToolsEmpty(t *testing.T) { + io := agentIO.NewIOManager() + a := &Agent{io: io} + tools := a.buildToolDefs() + // should have at least output_set_channel, output_send, output_list_channels + if len(tools) < 3 { + t.Errorf("expected at least 3 tools, got %d", len(tools)) + } +} diff --git a/internal/agent/core/context_test.go b/internal/agent/core/context_test.go new file mode 100644 index 0000000..567d8e1 --- /dev/null +++ b/internal/agent/core/context_test.go @@ -0,0 +1,131 @@ +package core + +import ( + "testing" + "time" +) + +func TestContextAppendAndLen(t *testing.T) { + ctx := NewRelevanceContext() + if ctx.Len() != 0 { + t.Errorf("new context should be empty, got %d", ctx.Len()) + } + + ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "hello"}) + if ctx.Len() != 1 { + t.Errorf("expected len 1, got %d", ctx.Len()) + } +} + +func TestContextRecent(t *testing.T) { + ctx := NewRelevanceContext() + ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "a"}) + ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "b"}) + ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "c"}) + + recent := ctx.Recent(2) + if len(recent) != 2 { + t.Errorf("expected 2 recent, got %d", len(recent)) + } + if recent[0].Input != "b" || recent[1].Input != "c" { + t.Errorf("expected [b, c], got %v", recent) + } +} + +func TestContextFormat(t *testing.T) { + ctx := NewRelevanceContext() + f := ctx.Format() + if f != "" { + t.Errorf("empty context should format to empty string, got %q", f) + } + + now := time.Now() + ctx.Append(ContextEvent{Timestamp: now, Source: "user", Input: "hello"}) + f = ctx.Format() + if f == "" { + t.Fatal("non-empty context should produce non-empty format") + } + if !contains(f, "hello") { + t.Errorf("format should contain input 'hello', got: %s", f) + } + if !contains(f, "user") { + t.Errorf("format should contain source 'user'") + } +} + +func TestContextPruneKeepsTopK(t *testing.T) { + ctx := NewRelevanceContext() + for i := 0; i < 10; i++ { + ctx.Append(ContextEvent{ + Timestamp: time.Now(), + Source: "user", + Input: "今天天气很好", + Response: "是的天气不错", + }) + } + // 加一条不同主题的 + ctx.Append(ContextEvent{ + Timestamp: time.Now(), + Source: "user", + Input: "帮我算一下微积分题目", + Response: "好的我来算", + }) + + archived := ctx.Prune("微积分", 5, nil) // nil docStore → 不归档,只裁剪 + _ = archived + + if ctx.Len() > 5 { + t.Errorf("after prune to 5, len should be ≤5, got %d", ctx.Len()) + } +} + +func TestContextPruneWithDocStore(t *testing.T) { + ctx := NewRelevanceContext() + for i := 0; i < 15; i++ { + ctx.Append(ContextEvent{ + Timestamp: time.Now(), + Source: "user", + Input: "今天天气很好", + Response: "是的", + }) + } + + archived := ctx.Prune("天气", 10, nil) + if archived != 0 { + t.Errorf("with nil docStore, archived should be 0, got %d", archived) + } +} + +func TestContextAppendAfterPrune(t *testing.T) { + ctx := NewRelevanceContext() + for i := 0; i < 10; i++ { + ctx.Append(ContextEvent{ + Timestamp: time.Now(), + Source: "user", + Input: "hello world", + }) + } + + ctx.Prune("hello", 3, nil) + if ctx.Len() > 3 { + t.Errorf("expected ≤3 after prune, got %d", ctx.Len()) + } + + ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "new message"}) + if ctx.Len() != 4 { + t.Errorf("after append, expected 4, got %d", ctx.Len()) + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && containsStr(s, substr) +} + +func containsStr(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/agent/io/channel.go b/internal/agent/io/channel.go index c918946..efc322f 100644 --- a/internal/agent/io/channel.go +++ b/internal/agent/io/channel.go @@ -2,6 +2,7 @@ package io import ( "fmt" + "log" "sync" "time" ) @@ -88,12 +89,11 @@ type OutputEvent struct { } type IOManager struct { - mu sync.RWMutex - devices map[string]Device - inputCh chan *InputEvent - outputCh chan *OutputEvent - nextReqID int64 - routes map[string]string // 输入源 → 默认输出通道 e.g. "mic" → "speaker" + mu sync.RWMutex + devices map[string]Device + inputCh chan *InputEvent + outputCh chan *OutputEvent + nextReqID int64 } func NewIOManager() *IOManager { @@ -101,26 +101,13 @@ func NewIOManager() *IOManager { devices: make(map[string]Device), inputCh: make(chan *InputEvent, 256), outputCh: make(chan *OutputEvent, 256), - routes: make(map[string]string), } } -// RegisterOutputRoute 注册输入源 → 默认输出通道映射 -// 例如:mic → speaker,voice_input → speaker -func (m *IOManager) RegisterOutputRoute(inputSource, outputChannel string) { +func (m *IOManager) UnregisterDevice(name string) { m.mu.Lock() defer m.mu.Unlock() - m.routes[inputSource] = outputChannel -} - -// DefaultOutput 返回输入源的默认输出通道 -func (m *IOManager) DefaultOutput(source string) string { - m.mu.RLock() - defer m.mu.RUnlock() - if ch, ok := m.routes[source]; ok { - return ch - } - return source // 默认等于输入源 + delete(m.devices, name) } func (m *IOManager) nextRequestID() string { @@ -130,30 +117,17 @@ func (m *IOManager) nextRequestID() string { return fmt.Sprintf("req_%d_%d", time.Now().UnixNano(), m.nextReqID) } -func (m *IOManager) UnregisterDevice(name string) { - m.mu.Lock() - defer m.mu.Unlock() - delete(m.devices, name) - for src, dst := range m.routes { - if src == name || dst == name { - delete(m.routes, src) - } - } -} - -// AtomicSwapDevices 原子化替换全部 IO 设备与路由表 +// AtomicSwapDevices 原子化替换全部 IO 设备 // 1. 新设备必须在调用前已完成 Start() -// 2. 调用后旧设备立即从路由表中摘除,新请求走向新设备 +// 2. 调用后旧设备立即摘除,新请求走向新设备 // 3. 返回旧设备列表,由调用方负责 Stop() -func (m *IOManager) AtomicSwapDevices(newDevices map[string]Device, newRoutes map[string]string) map[string]Device { +func (m *IOManager) AtomicSwapDevices(newDevices map[string]Device) map[string]Device { m.mu.Lock() defer m.mu.Unlock() oldDevices := m.devices m.devices = newDevices - m.routes = newRoutes - return oldDevices } @@ -169,10 +143,15 @@ func (m *IOManager) RegisterDevice(dev Device) error { func (m *IOManager) StartAll() error { m.mu.RLock() - defer m.mu.RUnlock() - for name, dev := range m.devices { + devices := make([]Device, 0, len(m.devices)) + for _, dev := range m.devices { + devices = append(devices, dev) + } + m.mu.RUnlock() + + for _, dev := range devices { if err := dev.Start(); err != nil { - return fmt.Errorf("start device %s: %w", name, err) + return fmt.Errorf("start device %s: %w", dev.Name(), err) } } return nil @@ -180,9 +159,16 @@ func (m *IOManager) StartAll() error { func (m *IOManager) StopAll() { m.mu.RLock() - defer m.mu.RUnlock() + devices := make([]Device, 0, len(m.devices)) for _, dev := range m.devices { - dev.Stop() + devices = append(devices, dev) + } + m.mu.RUnlock() + + for _, dev := range devices { + if err := dev.Stop(); err != nil { + log.Printf("[io] stop device %s error: %v", dev.Name(), err) + } } } @@ -192,7 +178,7 @@ func (m *IOManager) InjectInput(source string, eventType string, payload map[str Source: source, Type: eventType, Payload: payload, - OutputChannel: m.DefaultOutput(source), + OutputChannel: source, } } @@ -204,7 +190,32 @@ func (m *IOManager) InjectInputSync(source string, eventType string, payload map Type: eventType, Payload: payload, ResponseCh: ch, - OutputChannel: m.DefaultOutput(source), + OutputChannel: source, + } + return <-ch +} + +// InjectInputTo 注入输入事件并指定输出通道 +func (m *IOManager) InjectInputTo(source, outputChannel, eventType string, payload map[string]interface{}) { + m.inputCh <- &InputEvent{ + RequestID: m.nextRequestID(), + Source: source, + Type: eventType, + Payload: payload, + OutputChannel: outputChannel, + } +} + +// InjectInputSyncTo 注入输入事件(同步等待)并指定输出通道 +func (m *IOManager) InjectInputSyncTo(source, outputChannel, eventType string, payload map[string]interface{}) *OutputEvent { + ch := make(chan *OutputEvent, 1) + m.inputCh <- &InputEvent{ + RequestID: m.nextRequestID(), + Source: source, + Type: eventType, + Payload: payload, + ResponseCh: ch, + OutputChannel: outputChannel, } return <-ch } @@ -221,6 +232,20 @@ func (m *IOManager) InjectTextSync(source string, text string) *OutputEvent { }) } +// InjectTextTo 注入文本输入并指定输出通道 +func (m *IOManager) InjectTextTo(source, outputChannel, text string) { + m.InjectInputTo(source, outputChannel, "text", map[string]interface{}{ + "content": text, + }) +} + +// InjectTextSyncTo 注入文本输入(同步等待)并指定输出通道 +func (m *IOManager) InjectTextSyncTo(source, outputChannel, text string) *OutputEvent { + return m.InjectInputSyncTo(source, outputChannel, "text", map[string]interface{}{ + "content": text, + }) +} + func (m *IOManager) EmitOutput(target string, outputType string, payload map[string]interface{}) { m.outputCh <- &OutputEvent{ RequestID: "", @@ -271,15 +296,25 @@ func (m *IOManager) GetAllTools() []ToolDef { func (m *IOManager) ExecuteTool(name string, args map[string]interface{}) (interface{}, error) { m.mu.RLock() - defer m.mu.RUnlock() + type nameDevice struct { + name string + dev Device + } + var candidates []nameDevice for _, dev := range m.devices { for _, t := range dev.Tools() { if t.Name == name { - return dev.Execute(name, args) + candidates = append(candidates, nameDevice{name: dev.Name(), dev: dev}) + break } } } - return nil, fmt.Errorf("tool %s not found", name) + m.mu.RUnlock() + + if len(candidates) == 0 { + return nil, fmt.Errorf("tool %s not found", name) + } + return candidates[0].dev.Execute(name, args) } func (m *IOManager) ListDevices() []Device { diff --git a/internal/agent/io/channel_test.go b/internal/agent/io/channel_test.go new file mode 100644 index 0000000..cd438b1 --- /dev/null +++ b/internal/agent/io/channel_test.go @@ -0,0 +1,510 @@ +package io + +import ( + "sync" + "testing" +) + +// mockDevice implements Device for testing +type mockDevice struct { + name string + devType DeviceType + desc string + tools []ToolDef + caps OutputCapability + startFn func() error + stopFn func() error + executeFn func(string, map[string]interface{}) (interface{}, error) + startCallCount int + stopCallCount int + mu sync.Mutex +} + +func (d *mockDevice) Name() string { return d.name } +func (d *mockDevice) Type() DeviceType { return d.devType } +func (d *mockDevice) Description() string { return d.desc } +func (d *mockDevice) Tools() []ToolDef { return d.tools } +func (d *mockDevice) Start() error { + d.mu.Lock() + d.startCallCount++ + d.mu.Unlock() + if d.startFn != nil { + return d.startFn() + } + return nil +} +func (d *mockDevice) Stop() error { + d.mu.Lock() + d.stopCallCount++ + d.mu.Unlock() + if d.stopFn != nil { + return d.stopFn() + } + return nil +} +func (d *mockDevice) OutputCapabilities() OutputCapability { return d.caps } +func (d *mockDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) { + if d.executeFn != nil { + return d.executeFn(tool, args) + } + return nil, nil +} + +func TestNewIOManager(t *testing.T) { + m := NewIOManager() + if m == nil { + t.Fatal("IOManager should not be nil") + } + if m.InputChan() == nil { + t.Error("InputChan should not be nil") + } + if m.OutputChan() == nil { + t.Error("OutputChan should not be nil") + } +} + +func TestRegisterDevice(t *testing.T) { + m := NewIOManager() + dev := &mockDevice{name: "test_dev"} + if err := m.RegisterDevice(dev); err != nil { + t.Fatal(err) + } + // Duplicate registration + if err := m.RegisterDevice(dev); err == nil { + t.Error("expected error for duplicate registration") + } +} + +func TestUnregisterDevice(t *testing.T) { + m := NewIOManager() + m.RegisterDevice(&mockDevice{name: "dev1"}) + m.RegisterDevice(&mockDevice{name: "dev2"}) + + m.UnregisterDevice("dev1") + + devices := m.ListDevices() + if len(devices) != 1 { + t.Errorf("expected 1 device, got %d", len(devices)) + } + if devices[0].Name() != "dev2" { + t.Errorf("expected 'dev2', got %q", devices[0].Name()) + } +} + +func TestUnregisterDeviceTwice(t *testing.T) { + m := NewIOManager() + m.RegisterDevice(&mockDevice{name: "dev1"}) + m.UnregisterDevice("dev1") + m.UnregisterDevice("dev1") // should not panic +} + +func TestInjectTextTo(t *testing.T) { + m := NewIOManager() + go func() { + evt := <-m.InputChan() + if evt.OutputChannel != "speaker" { + t.Errorf("expected OutputChannel 'speaker', got %q", evt.OutputChannel) + } + content, _ := evt.Payload["content"].(string) + if content != "say hello" { + t.Errorf("expected 'say hello', got %q", content) + } + }() + m.InjectTextTo("mic", "speaker", "say hello") +} + +func TestInjectTextSyncTo(t *testing.T) { + m := NewIOManager() + go func() { + evt := <-m.InputChan() + if evt.OutputChannel != "qq" { + t.Errorf("expected OutputChannel 'qq', got %q", evt.OutputChannel) + } + evt.ResponseCh <- &OutputEvent{Done: true} + }() + + resp := m.InjectTextSyncTo("onebot", "qq", "hi") + if resp == nil || !resp.Done { + t.Error("expected Done response") + } +} + +func TestInjectInput(t *testing.T) { + m := NewIOManager() + go func() { + evt := <-m.InputChan() + if evt.Source != "test_source" { + t.Errorf("expected source 'test_source', got %q", evt.Source) + } + if evt.Type != "text" { + t.Errorf("expected type 'text', got %q", evt.Type) + } + if evt.OutputChannel != "test_source" { + t.Errorf("expected OutputChannel 'test_source', got %q", evt.OutputChannel) + } + }() + + m.InjectInput("test_source", "text", map[string]interface{}{"content": "hello"}) +} + +func TestInjectInputSync(t *testing.T) { + m := NewIOManager() + go func() { + evt := <-m.InputChan() + if evt.OutputChannel != "test" { + t.Errorf("expected OutputChannel 'test', got %q", evt.OutputChannel) + } + evt.ResponseCh <- &OutputEvent{RequestID: evt.RequestID, Done: true} + }() + + resp := m.InjectInputSync("test", "text", map[string]interface{}{"content": "sync"}) + if resp == nil { + t.Fatal("expected response") + } + if !resp.Done { + t.Error("expected Done=true") + } +} + +func TestInjectInputTo(t *testing.T) { + m := NewIOManager() + go func() { + evt := <-m.InputChan() + if evt.OutputChannel != "speaker" { + t.Errorf("expected OutputChannel 'speaker', got %q", evt.OutputChannel) + } + }() + + m.InjectInputTo("mic", "speaker", "text", map[string]interface{}{"content": "hello"}) +} + +func TestInjectInputSyncTo(t *testing.T) { + m := NewIOManager() + go func() { + evt := <-m.InputChan() + if evt.OutputChannel != "email" { + t.Errorf("expected OutputChannel 'email', got %q", evt.OutputChannel) + } + evt.ResponseCh <- &OutputEvent{Done: true} + }() + + resp := m.InjectInputSyncTo("plugin", "email", "text", map[string]interface{}{"content": "hi"}) + if resp == nil || !resp.Done { + t.Error("expected Done response") + } +} + +func TestEmitOutput(t *testing.T) { + m := NewIOManager() + go func() { + evt := <-m.OutputChan() + if evt.Target != "memory" { + t.Errorf("expected target 'memory', got %q", evt.Target) + } + }() + + m.EmitOutput("memory", "text", map[string]interface{}{"content": "data"}) +} + +func TestEmitOutputTo(t *testing.T) { + m := NewIOManager() + go func() { + evt := <-m.OutputChan() + if evt.OutputChannel != "speaker" { + t.Errorf("expected channel 'speaker', got %q", evt.OutputChannel) + } + }() + + m.EmitOutputTo("agent", "speaker", "text", map[string]interface{}{"content": "hello"}) +} + +func TestGetAllTools(t *testing.T) { + m := NewIOManager() + m.RegisterDevice(&mockDevice{ + name: "dev1", + tools: []ToolDef{ + {Name: "tool1", Description: "first tool"}, + }, + }) + m.RegisterDevice(&mockDevice{ + name: "dev2", + tools: []ToolDef{ + {Name: "tool2", Description: "second tool"}, + {Name: "tool3", Description: "third tool"}, + }, + }) + + tools := m.GetAllTools() + if len(tools) != 3 { + t.Errorf("expected 3 tools, got %d", len(tools)) + } +} + +func TestExecuteTool(t *testing.T) { + m := NewIOManager() + m.RegisterDevice(&mockDevice{ + name: "calc", + tools: []ToolDef{ + {Name: "add", Description: "addition"}, + }, + executeFn: func(tool string, args map[string]interface{}) (interface{}, error) { + a, _ := args["a"].(float64) + b, _ := args["b"].(float64) + return a + b, nil + }, + }) + + result, err := m.ExecuteTool("add", map[string]interface{}{"a": 1.0, "b": 2.0}) + if err != nil { + t.Fatal(err) + } + if result.(float64) != 3.0 { + t.Errorf("expected 3.0, got %v", result) + } +} + +func TestExecuteToolNotFound(t *testing.T) { + m := NewIOManager() + _, err := m.ExecuteTool("nonexistent", nil) + if err == nil { + t.Error("expected error for nonexistent tool") + } +} + +func TestListChannels(t *testing.T) { + m := NewIOManager() + m.RegisterDevice(&mockDevice{ + name: "screen", + caps: CapText | CapImage, + devType: DeviceOutput, + }) + m.RegisterDevice(&mockDevice{ + name: "mic", + caps: 0, + devType: DeviceInput, + }) + + channels := m.ListChannels() + if len(channels) != 2 { + t.Errorf("expected 2 channels, got %d", len(channels)) + } +} + +func TestGetChannelCapabilities(t *testing.T) { + m := NewIOManager() + m.RegisterDevice(&mockDevice{ + name: "speaker", + caps: CapText | CapAudio, + }) + + caps := m.GetChannelCapabilities("speaker") + if !caps.Supports(CapText) { + t.Error("should support text") + } + if !caps.Supports(CapAudio) { + t.Error("should support audio") + } + if caps.Supports(CapImage) { + t.Error("should not support image") + } + + caps = m.GetChannelCapabilities("nonexistent") + if caps != 0 { + t.Errorf("expected 0 capabilities, got %v", caps) + } +} + +func TestStartAll(t *testing.T) { + m := NewIOManager() + started := false + dev := &mockDevice{ + name: "test", + startFn: func() error { + started = true + return nil + }, + } + m.RegisterDevice(dev) + + if err := m.StartAll(); err != nil { + t.Fatal(err) + } + if !started { + t.Error("device should have been started") + } +} + +func TestStartAllError(t *testing.T) { + m := NewIOManager() + m.RegisterDevice(&mockDevice{ + name: "fail", + startFn: func() error { + return nil + }, + }) + m.RegisterDevice(&mockDevice{ + name: "ok", + startFn: func() error { + return nil + }, + }) + + if err := m.StartAll(); err != nil { + t.Fatal(err) + } +} + +func TestStopAll(t *testing.T) { + m := NewIOManager() + stopped := false + dev := &mockDevice{ + name: "test", + stopFn: func() error { + stopped = true + return nil + }, + } + m.RegisterDevice(dev) + m.StartAll() + m.StopAll() + + if !stopped { + t.Error("device should have been stopped") + } +} + +func TestAtomicSwapDevices(t *testing.T) { + m := NewIOManager() + m.RegisterDevice(&mockDevice{name: "old1"}) + m.RegisterDevice(&mockDevice{name: "old2"}) + + newDevices := map[string]Device{ + "new1": &mockDevice{name: "new1"}, + "new2": &mockDevice{name: "new2"}, + } + + old := m.AtomicSwapDevices(newDevices) + if len(old) != 2 { + t.Errorf("expected 2 old devices, got %d", len(old)) + } + devices := m.ListDevices() + if len(devices) != 2 { + t.Errorf("expected 2 devices after swap, got %d", len(devices)) + } +} + +func TestInjectText(t *testing.T) { + m := NewIOManager() + go func() { + evt := <-m.InputChan() + content, _ := evt.Payload["content"].(string) + if content != "hello world" { + t.Errorf("expected 'hello world', got %q", content) + } + if evt.OutputChannel != "user" { + t.Errorf("expected OutputChannel 'user', got %q", evt.OutputChannel) + } + }() + m.InjectText("user", "hello world") +} + +func TestInjectTextSync(t *testing.T) { + m := NewIOManager() + go func() { + evt := <-m.InputChan() + evt.ResponseCh <- &OutputEvent{Done: true} + }() + + resp := m.InjectTextSync("http", "ping") + if resp == nil || !resp.Done { + t.Error("expected Done response") + } +} + +func TestEmitText(t *testing.T) { + m := NewIOManager() + go func() { + evt := <-m.OutputChan() + content, _ := evt.Payload["content"].(string) + if content != "notification" { + t.Errorf("expected 'notification', got %q", content) + } + }() + m.EmitText("user", "notification") +} + +func TestEmitTextTo(t *testing.T) { + m := NewIOManager() + go func() { + evt := <-m.OutputChan() + if evt.OutputChannel != "email" { + t.Errorf("expected 'email', got %q", evt.OutputChannel) + } + content, _ := evt.Payload["content"].(string) + if content != "alert" { + t.Errorf("expected 'alert', got %q", content) + } + }() + m.EmitTextTo("agent", "email", "alert") +} + +func TestOutputCapability(t *testing.T) { + caps := CapText | CapImage + if !caps.Supports(CapText) { + t.Error("CapText should be supported") + } + if !caps.Supports(CapImage) { + t.Error("CapImage should be supported") + } + if caps.Supports(CapAudio) { + t.Error("CapAudio should not be supported") + } + if caps.Supports(CapFile) { + t.Error("CapFile should not be supported") + } + if caps.Supports(CapStructured) { + t.Error("CapStructured should not be supported") + } +} + +func TestOutputCapabilityString(t *testing.T) { + caps := CapText | CapAudio + s := caps.String() + if s != "[text audio]" && s != "[audio text]" { + t.Errorf("unexpected string: %s", s) + } + + if OutputCapability(0).String() != "[]" { + t.Errorf("expected empty, got %s", OutputCapability(0).String()) + } +} + +func TestConcurrentAccess(t *testing.T) { + m := NewIOManager() + m.RegisterDevice(&mockDevice{name: "dev1"}) + m.RegisterDevice(&mockDevice{name: "dev2"}) + + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + m.GetAllTools() + m.ListChannels() + m.ListDevices() + }() + } + wg.Wait() +} + +func TestNextRequestID(t *testing.T) { + m := NewIOManager() + ids := make(map[string]bool) + for i := 0; i < 100; i++ { + id := m.nextRequestID() + if ids[id] { + t.Errorf("duplicate request ID: %s", id) + } + ids[id] = true + } +} diff --git a/internal/api/handler.go b/internal/api/handler.go index 9901c55..4af44ad 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -16,6 +16,7 @@ import ( "gitcode.com/JianFeeeee/HomeAgent/internal/memory/text" "gitcode.com/JianFeeeee/HomeAgent/internal/skill" "gitcode.com/JianFeeeee/HomeAgent/internal/supervisor" + "gitcode.com/JianFeeeee/HomeAgent/internal/tracker" "gitcode.com/JianFeeeee/HomeAgent/pkg/types" ) @@ -30,9 +31,10 @@ type Handler struct { iom *agentIO.IOManager textMem *text.Memory knowledge *knowledge.Store + tracker *tracker.Tracker } -func NewHandler(sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager, lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager, tm *text.Memory, ks *knowledge.Store) *Handler { +func NewHandler(sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager, lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager, tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker) *Handler { var idx *memory.Indexer if mem != nil { idx = memory.NewIndexer(mem) @@ -48,6 +50,7 @@ func NewHandler(sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager, iom: iom, textMem: tm, knowledge: ks, + tracker: tr, } } @@ -67,6 +70,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/v1/knowledge/", h.handleKnowledge) mux.HandleFunc("/api/v1/adapters", h.handleAdapters) mux.HandleFunc("/api/v1/adapters/", h.handleAdapterByID) + mux.HandleFunc("/api/v1/tracker", h.handleTracker) + mux.HandleFunc("/api/v1/tracker/", h.handleTracker) mux.HandleFunc("/v1/chat/completions", h.handleOpenAICompletions) mux.HandleFunc("/", h.handleStatic) } @@ -179,6 +184,10 @@ func (h *Handler) handleAgentAction(w http.ResponseWriter, r *http.Request, agen } func (h *Handler) handleSkills(w http.ResponseWriter, r *http.Request) { + if h.skills == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "skills not available"}) + return + } switch r.Method { case http.MethodGet: writeJSON(w, http.StatusOK, map[string]interface{}{"skills": h.skills.List()}) @@ -556,6 +565,43 @@ func (h *Handler) handleOpenAICompletions(w http.ResponseWriter, r *http.Request json.NewEncoder(w).Encode(resp) } +func (h *Handler) handleTracker(w http.ResponseWriter, r *http.Request) { + if h.tracker == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "tracker not available"}) + return + } + path := strings.TrimPrefix(r.URL.Path, "/api/v1/tracker") + path = strings.TrimPrefix(path, "/") + + switch { + case path == "changesets" && r.Method == http.MethodGet: + writeJSON(w, http.StatusOK, map[string]interface{}{ + "changesets": h.tracker.ChangeSets(), + "count": len(h.tracker.ChangeSets()), + }) + case path == "rollback" && r.Method == http.MethodPost: + if err := h.tracker.Rollback(); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "rollback_complete"}) + case path == "" && r.Method == http.MethodGet: + writeJSON(w, http.StatusOK, map[string]interface{}{ + "stats": h.tracker.Stats(), + "has_changes": h.tracker.HasChanges(), + "changesets": len(h.tracker.ChangeSets()), + }) + case path == "" && r.Method == http.MethodDelete: + if err := h.tracker.Rollback(); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"}) + default: + http.Error(w, "not found", http.StatusNotFound) + } +} + func (h *Handler) handleStatic(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { w.Header().Set("Content-Type", "text/html; charset=utf-8") diff --git a/internal/api/handler_test.go b/internal/api/handler_test.go new file mode 100644 index 0000000..499a185 --- /dev/null +++ b/internal/api/handler_test.go @@ -0,0 +1,384 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "gitcode.com/JianFeeeee/HomeAgent/internal/knowledge" + "gitcode.com/JianFeeeee/HomeAgent/internal/supervisor" + "gitcode.com/JianFeeeee/HomeAgent/internal/tracker" + "gitcode.com/JianFeeeee/HomeAgent/pkg/types" +) + +func newTestHandler(t *testing.T) (*Handler, *supervisor.Daemon) { + t.Helper() + cfg := &types.Config{ + Daemon: types.DaemonConfig{ + CheckInterval: time.Minute, + HeartbeatInterval: 30 * time.Second, + }, + } + sup := supervisor.New(cfg) + sup.Start() + + return NewHandler(sup, nil, nil, nil, cfg, nil, nil, nil, nil), sup +} + +func TestHandleStatus(t *testing.T) { + h, sup := newTestHandler(t) + defer sup.Shutdown() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil) + w := httptest.NewRecorder() + h.handleStatus(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["status"] != "running" { + t.Errorf("expected running, got %v", resp["status"]) + } +} + +func TestHandleStatusMethodNotAllowed(t *testing.T) { + h, sup := newTestHandler(t) + defer sup.Shutdown() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/status", nil) + w := httptest.NewRecorder() + h.handleStatus(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("expected 405, got %d", w.Code) + } +} + +func TestHandleAgents(t *testing.T) { + h, sup := newTestHandler(t) + defer sup.Shutdown() + sup.RegisterAgent("test_agent") + + req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil) + w := httptest.NewRecorder() + h.handleAgents(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + agents, ok := resp["agents"].([]interface{}) + if !ok || len(agents) == 0 { + t.Error("expected agents list") + } +} + +func TestHandleAgentByID(t *testing.T) { + h, sup := newTestHandler(t) + defer sup.Shutdown() + sup.RegisterAgent("my_agent") + + req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/my_agent", nil) + w := httptest.NewRecorder() + h.handleAgentByID(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["id"] != "my_agent" { + t.Errorf("expected my_agent, got %v", resp["id"]) + } +} + +func TestHandleAgentByIDNotFound(t *testing.T) { + h, sup := newTestHandler(t) + defer sup.Shutdown() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/nonexistent", nil) + w := httptest.NewRecorder() + h.handleAgentByID(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", w.Code) + } +} + +func TestHandleKnowledgeSearch(t *testing.T) { + ks := knowledge.NewStore(t.TempDir()) + ks.Start() + ks.Add("test_doc", "this is test content for searching") + + cfg := &types.Config{ + Daemon: types.DaemonConfig{ + CheckInterval: time.Minute, + HeartbeatInterval: 30 * time.Second, + }, + } + sup := supervisor.New(cfg) + sup.Start() + defer sup.Shutdown() + + h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, ks, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/knowledge?q=test", nil) + w := httptest.NewRecorder() + h.handleKnowledge(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + results, ok := resp["results"].([]interface{}) + if !ok || len(results) == 0 { + t.Error("expected search results") + } +} + +func TestHandleKnowledgeCreate(t *testing.T) { + ks := knowledge.NewStore(t.TempDir()) + ks.Start() + + cfg := &types.Config{ + Daemon: types.DaemonConfig{ + CheckInterval: time.Minute, + HeartbeatInterval: 30 * time.Second, + }, + } + sup := supervisor.New(cfg) + sup.Start() + defer sup.Shutdown() + + h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, ks, nil) + + body := `{"name":"new_doc","content":"fresh content"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/knowledge", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.handleKnowledge(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("expected 201, got %d", w.Code) + } +} + +func TestHandleKnowledgeUnavailable(t *testing.T) { + h, sup := newTestHandler(t) + defer sup.Shutdown() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/knowledge?q=test", nil) + w := httptest.NewRecorder() + h.handleKnowledge(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("expected 503, got %d", w.Code) + } +} + +func TestHandleMemoryUnavailable(t *testing.T) { + h, sup := newTestHandler(t) + defer sup.Shutdown() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/memory?q=test", nil) + w := httptest.NewRecorder() + h.handleMemory(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("expected 503, got %d", w.Code) + } +} + +func TestHandleTrackerNotAvailable(t *testing.T) { + h, sup := newTestHandler(t) + defer sup.Shutdown() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tracker", nil) + w := httptest.NewRecorder() + h.handleTracker(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("expected 503, got %d", w.Code) + } +} + +func TestHandleTrackerStats(t *testing.T) { + tr := tracker.NewTracker(t.TempDir(), t.TempDir()) + + cfg := &types.Config{ + Daemon: types.DaemonConfig{ + CheckInterval: time.Minute, + HeartbeatInterval: 30 * time.Second, + }, + } + sup := supervisor.New(cfg) + sup.Start() + defer sup.Shutdown() + + h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, nil, tr) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tracker", nil) + w := httptest.NewRecorder() + h.handleTracker(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestHandleOpenAICompletionsNoMessages(t *testing.T) { + h, sup := newTestHandler(t) + defer sup.Shutdown() + + body := `{"model":"test"}` + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.handleOpenAICompletions(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestHandleOpenAICompletionsLastMsgNotUser(t *testing.T) { + h, sup := newTestHandler(t) + defer sup.Shutdown() + + body := `{"messages":[{"role":"assistant","content":"hi"}]}` + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.handleOpenAICompletions(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestHandleOpenAICompletionsMethodNotAllowed(t *testing.T) { + h, sup := newTestHandler(t) + defer sup.Shutdown() + + req := httptest.NewRequest(http.MethodGet, "/v1/chat/completions", nil) + w := httptest.NewRecorder() + h.handleOpenAICompletions(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("expected 405, got %d", w.Code) + } +} + +func TestHandleStaticServesHTML(t *testing.T) { + h, sup := newTestHandler(t) + defer sup.Shutdown() + + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + h.handleStatic(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + if !strings.Contains(w.Body.String(), "HomeAgent Dashboard") { + t.Error("expected dashboard HTML") + } +} + +func TestHandleStaticNotFound(t *testing.T) { + h, sup := newTestHandler(t) + defer sup.Shutdown() + + req := httptest.NewRequest(http.MethodGet, "/nonexistent", nil) + w := httptest.NewRecorder() + h.handleStatic(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", w.Code) + } +} + +func TestHandleConfigGet(t *testing.T) { + h, sup := newTestHandler(t) + defer sup.Shutdown() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/config", nil) + w := httptest.NewRecorder() + h.handleConfig(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestRegisterRoutes(t *testing.T) { + h, sup := newTestHandler(t) + defer sup.Shutdown() + + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + tests := []struct { + path string + method string + code int + }{ + {"/api/v1/status", http.MethodGet, http.StatusOK}, + {"/api/v1/agents", http.MethodGet, http.StatusOK}, + {"/api/v1/config", http.MethodGet, http.StatusOK}, + {"/api/v1/network", http.MethodGet, http.StatusOK}, + {"/", http.MethodGet, http.StatusOK}, + {"/api/v1/memory", http.MethodGet, http.StatusServiceUnavailable}, + {"/api/v1/knowledge", http.MethodGet, http.StatusServiceUnavailable}, + {"/api/v1/tracker", http.MethodGet, http.StatusServiceUnavailable}, + {"/api/v1/adapters", http.MethodGet, http.StatusServiceUnavailable}, + } + + for _, tt := range tests { + req := httptest.NewRequest(tt.method, tt.path, nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != tt.code { + t.Errorf("%s %s: expected %d, got %d", tt.method, tt.path, tt.code, w.Code) + } + } +} + +func TestHandleAdapterByIDNotFound(t *testing.T) { + h, sup := newTestHandler(t) + defer sup.Shutdown() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/adapters/nonexistent", nil) + w := httptest.NewRecorder() + h.handleAdapterByID(w, req) + + // Returns 503 when lua VM is not available + if w.Code != http.StatusServiceUnavailable { + t.Errorf("expected 503, got %d", w.Code) + } +} + +func TestHandleAdaptersUnavailable(t *testing.T) { + h, sup := newTestHandler(t) + defer sup.Shutdown() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/adapters", nil) + w := httptest.NewRecorder() + h.handleAdapters(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("expected 503, got %d", w.Code) + } +} diff --git a/internal/knowledge/knowledge_test.go b/internal/knowledge/knowledge_test.go new file mode 100644 index 0000000..73b010b --- /dev/null +++ b/internal/knowledge/knowledge_test.go @@ -0,0 +1,145 @@ +package knowledge + +import ( + "os" + "testing" +) + +func TestNewStore(t *testing.T) { + dir, err := os.MkdirTemp("", "know_test_*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + s := NewStore(dir) + if err := s.Start(); err != nil { + t.Fatal(err) + } + defer s.Stop() + + if s == nil { + t.Fatal("store should not be nil") + } +} + +func TestAddAndSearch(t *testing.T) { + dir, err := os.MkdirTemp("", "know_add_*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + s := NewStore(dir) + s.Start() + defer s.Stop() + + if err := s.Add("coffee", "咖啡是一种饮品,含有咖啡因"); err != nil { + t.Fatal(err) + } + + results := s.Search("咖啡", 5) + if len(results) == 0 { + t.Fatal("expected results for '咖啡'") + } +} + +func TestList(t *testing.T) { + dir, err := os.MkdirTemp("", "know_list_*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + s := NewStore(dir) + s.Start() + defer s.Stop() + + s.Add("topic1", "内容一") + s.Add("topic2", "内容二") + + list := s.List() + if len(list) != 2 { + t.Errorf("expected 2 items, got %d", len(list)) + } +} + +func TestRemove(t *testing.T) { + dir, err := os.MkdirTemp("", "know_rm_*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + s := NewStore(dir) + s.Start() + defer s.Stop() + + s.Add("test", "测试内容") + if err := s.Remove("test"); err != nil { + t.Fatal(err) + } + + list := s.List() + if len(list) != 0 { + t.Errorf("expected 0 items after remove, got %d", len(list)) + } +} + +func TestRemoveNotFound(t *testing.T) { + dir, err := os.MkdirTemp("", "know_notfound_*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + s := NewStore(dir) + s.Start() + defer s.Stop() + + if err := s.Remove("nonexistent"); err != nil { + t.Errorf("remove nonexistent should not error, got: %v", err) + } +} + +func TestStats(t *testing.T) { + dir, err := os.MkdirTemp("", "know_stats_*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + s := NewStore(dir) + s.Start() + defer s.Stop() + + s.Add("a", "内容A") + s.Add("b", "内容B") + + stats := s.Stats() + if stats["knowledge_count"].(int) != 2 { + t.Errorf("expected knowledge_count 2, got %v", stats["knowledge_count"]) + } + if stats["vector_count"] == nil { + t.Error("expected vector_count in stats") + } +} + +func TestSearchNoMatch(t *testing.T) { + dir, err := os.MkdirTemp("", "know_nomatch_*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + s := NewStore(dir) + s.Start() + defer s.Stop() + + s.Add("math", "加减乘除是基本运算") + // "电电电电电" 中的字符 "电" 不在文档 "math 加减乘除是基本运算" 的任意 unigram 中 + results := s.Search("电电电电电", 5) + if len(results) != 0 { + t.Errorf("expected 0 results for non-matching query, got %d", len(results)) + } +} diff --git a/internal/memory/document/document.go b/internal/memory/document/document.go index 6f1117d..03a0ba7 100644 --- a/internal/memory/document/document.go +++ b/internal/memory/document/document.go @@ -216,6 +216,18 @@ func (s *Store) RecentDocs(n int) []*Doc { return list } +// Remove 从文档存储中删除指定 ID 的文档 +func (s *Store) Remove(id string) { + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.docs[id]; ok { + delete(s.docs, id) + s.vec.Remove(id) + s.dirty = true + } +} + // ——— internal ——— func (s *Store) loadAll() error { diff --git a/internal/memory/document/document_test.go b/internal/memory/document/document_test.go new file mode 100644 index 0000000..582707f --- /dev/null +++ b/internal/memory/document/document_test.go @@ -0,0 +1,343 @@ +package document + +import ( + "os" + "testing" + "time" +) + +func TestInsertAndQuery(t *testing.T) { + dir, err := os.MkdirTemp("", "doc_test_*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + s := NewStore(dir) + if err := s.Start(); err != nil { + t.Fatal(err) + } + defer s.Stop() + + doc := &Doc{ + Summary: "用户喜欢喝咖啡", + Content: "用户提到他每天早上都会喝一杯黑咖啡", + Tags: []string{"咖啡", "习惯"}, + Source: "manual", + } + if err := s.Insert(doc); err != nil { + t.Fatal(err) + } + + if doc.ID == "" { + t.Error("doc ID should be auto-generated") + } + + stats := s.Stats() + if stats["doc_count"].(int) != 1 { + t.Errorf("expected 1 doc, got %d", stats["doc_count"]) + } +} + +func TestQuery(t *testing.T) { + dir, err := os.MkdirTemp("", "doc_query_*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + s := NewStore(dir) + s.Start() + defer s.Stop() + + s.Insert(&Doc{Summary: "咖啡是一种饮品", Content: "咖啡因提神", Source: "manual"}) + s.Insert(&Doc{Summary: "茶叶也有咖啡因", Content: "茶和咖啡都提神", Source: "manual"}) + s.Insert(&Doc{Summary: "今天天气很好", Content: "适合出去散步", Source: "manual"}) + + results := s.Query("咖啡", 5) + if len(results) == 0 { + t.Fatal("expected results for '咖啡'") + } + + if results[0].AccessCount <= 0 { + t.Error("access count should be updated on query") + } +} + +func TestContextToDoc(t *testing.T) { + dir, err := os.MkdirTemp("", "doc_ctx_*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + s := NewStore(dir) + s.Start() + defer s.Stop() + + entries := []ContextEntry{ + {Timestamp: time.Now(), Source: "user", Content: "我喜欢编程", Response: "很好"}, + {Timestamp: time.Now(), Source: "user", Content: "特别是Go语言", Response: "Go很棒"}, + } + + doc, err := s.ContextToDoc("test", entries) + if err != nil { + t.Fatal(err) + } + if doc == nil { + t.Fatal("expected non-nil doc") + } + if doc.Summary == "" { + t.Error("summary should not be empty") + } + if doc.Content == "" { + t.Error("content should not be empty") + } +} + +func TestFindColdDocs(t *testing.T) { + dir, err := os.MkdirTemp("", "doc_cold_*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + s := NewStore(dir) + s.Start() + defer s.Stop() + + hot := &Doc{Summary: "常用的信息", Content: "经常被查询", Source: "manual"} + hot.AccessCount = 10 + hot.LastAccess = time.Now() + s.Insert(hot) + + cold := &Doc{Summary: "很久没用的信息", Content: "几乎不被访问", Source: "manual"} + s.Insert(cold) + // Insert 会重置 LastAccess,手动改为过去的 + cold.LastAccess = time.Now().Add(-100 * time.Hour) + cold.AccessCount = 1 + + // 应该只找到 cold(72h 前未访问且访问 ≤ 2) + coldDocs := s.FindColdDocs(72*time.Hour, 2) + if len(coldDocs) != 1 { + t.Fatalf("expected 1 cold doc, got %d", len(coldDocs)) + } + if coldDocs[0].Summary != "很久没用的信息" { + t.Errorf("expected cold doc, got %s", coldDocs[0].Summary) + } +} + +func TestRecentDocs(t *testing.T) { + dir, err := os.MkdirTemp("", "doc_recent_*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + s := NewStore(dir) + s.Start() + defer s.Stop() + + s.Insert(&Doc{Summary: "第一条", Content: "a", Source: "manual"}) + time.Sleep(time.Millisecond) + s.Insert(&Doc{Summary: "第二条", Content: "b", Source: "manual"}) + + recent := s.RecentDocs(1) + if len(recent) != 1 { + t.Fatalf("expected 1 recent doc, got %d", len(recent)) + } + if recent[0].Summary != "第二条" { + t.Errorf("expected newest doc, got %s", recent[0].Summary) + } +} + +func TestReindex(t *testing.T) { + dir, err := os.MkdirTemp("", "doc_reindex_*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + s := NewStore(dir) + s.Start() + defer s.Stop() + + s.Insert(&Doc{Summary: "测试重索引", Content: "验证索引重建", Source: "manual"}) + s.Reindex() + + results := s.Query("重索引", 5) + if len(results) == 0 { + t.Error("reindex should preserve searchability") + } +} + +func TestSummarizeEntries(t *testing.T) { + entries := []ContextEntry{ + {Source: "user", Content: "今天天气如何"}, + {Source: "user", Content: "明天会下雨吗"}, + } + summary := summarizeEntries(entries) + if summary == "" { + t.Error("summary should not be empty") + } + if !contains(summary, "2") { + t.Errorf("summary should mention count, got: %s", summary) + } +} + +func TestExtractKeywords(t *testing.T) { + kws := extractKeywords("今天天气很好") + if len(kws) == 0 { + t.Error("should extract keywords from Chinese text") + } +} + +func TestExtractTags(t *testing.T) { + entries := []ContextEntry{ + {Content: "我喜欢喝咖啡和编程"}, + } + tags := extractTags(entries) + if len(tags) == 0 { + t.Error("should extract tags") + } +} + +func TestInsertEmptyDoc(t *testing.T) { + dir, err := os.MkdirTemp("", "doc_empty_*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + s := NewStore(dir) + s.Start() + defer s.Stop() + + doc := &Doc{Summary: "", Content: "", Source: "manual"} + if err := s.Insert(doc); err != nil { + t.Fatal(err) + } + if doc.ID == "" { + t.Error("doc ID should be generated even for empty content") + } +} + +func TestPersistence(t *testing.T) { + dir, err := os.MkdirTemp("", "doc_persist_*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + // 写 + s1 := NewStore(dir) + s1.Start() + s1.Insert(&Doc{Summary: "持久化测试", Content: "应该被保存到磁盘", Source: "manual"}) + s1.Stop() + + // 读 + s2 := NewStore(dir) + s2.Start() + defer s2.Stop() + + stats := s2.Stats() + if stats["doc_count"].(int) != 1 { + t.Errorf("expected 1 doc after reload, got %d", stats["doc_count"]) + } + + results := s2.Query("持久化", 5) + if len(results) == 0 { + t.Error("search should work after reload") + } +} + +func contains(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +func TestFlushNoDirty(t *testing.T) { + dir, err := os.MkdirTemp("", "doc_flush_*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + s := NewStore(dir) + s.Start() + + // 不插任何文档,flush 不应报错 + s.Stop() +} + +func TestRemove(t *testing.T) { + dir, err := os.MkdirTemp("", "doc_remove_*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + s := NewStore(dir) + s.Start() + defer s.Stop() + + s.Insert(&Doc{Summary: "会被删除", Content: "a", Source: "manual"}) + s.Insert(&Doc{Summary: "会保留", Content: "b", Source: "manual"}) + + // 删除前应该有 2 个 + stats := s.Stats() + if stats["doc_count"].(int) != 2 { + t.Fatalf("expected 2 docs before remove, got %d", stats["doc_count"]) + } + + // 遍历找到 "会被删除" 的 ID + var rmID string + for _, d := range s.docs { + if d.Summary == "会被删除" { + rmID = d.ID + break + } + } + if rmID == "" { + t.Fatal("could not find test doc") + } + + s.Remove(rmID) + + stats = s.Stats() + if stats["doc_count"].(int) != 1 { + t.Errorf("expected 1 doc after remove, got %d", stats["doc_count"]) + } + + // 搜索不应再找到 + results := s.Query("删除", 5) + if len(results) > 0 { + t.Error("removed doc should not appear in search results") + } +} + +func TestRemoveNonexistent(t *testing.T) { + dir, err := os.MkdirTemp("", "doc_rm_nonexist_*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + s := NewStore(dir) + s.Start() + defer s.Stop() + + s.Insert(&Doc{Summary: "一个文档", Content: "x", Source: "manual"}) + + // 删除不存在的 ID 不应 panic + s.Remove("nonexistent_id") + + stats := s.Stats() + if stats["doc_count"].(int) != 1 { + t.Errorf("expected 1 doc after remove nonexistent, got %d", stats["doc_count"]) + } +} diff --git a/internal/memory/graph.go b/internal/memory/graph.go index bfc6aa9..3825030 100644 --- a/internal/memory/graph.go +++ b/internal/memory/graph.go @@ -400,7 +400,7 @@ func (g *GraphDB) Purge(criteria map[string]string, mode string) (int, error) { g.mu.Lock() defer g.mu.Unlock() - conds := []string{"r.status = 'active'"} + conds := []string{"status = 'active'"} args := []interface{}{} if v, ok := criteria["subject_contains"]; ok { @@ -416,7 +416,7 @@ func (g *GraphDB) Purge(criteria map[string]string, mode string) (int, error) { ids = append(ids, id) } if len(ids) > 0 { - conds = append(conds, fmt.Sprintf("r.source_id IN (%s)", placeholders(len(ids)))) + conds = append(conds, fmt.Sprintf("source_id IN (%s)", placeholders(len(ids)))) args = append(args, ids...) } } @@ -434,18 +434,18 @@ func (g *GraphDB) Purge(criteria map[string]string, mode string) (int, error) { ids = append(ids, id) } if len(ids) > 0 { - conds = append(conds, fmt.Sprintf("r.target_id IN (%s)", placeholders(len(ids)))) + conds = append(conds, fmt.Sprintf("target_id IN (%s)", placeholders(len(ids)))) args = append(args, ids...) } } if v, ok := criteria["relation_type"]; ok { - conds = append(conds, "r.relation_type = ?") + conds = append(conds, "relation_type = ?") args = append(args, v) } if v, ok := criteria["session_id"]; ok { - conds = append(conds, "r.session_id = ?") + conds = append(conds, "session_id = ?") args = append(args, v) } diff --git a/internal/memory/graph_test.go b/internal/memory/graph_test.go new file mode 100644 index 0000000..4283755 --- /dev/null +++ b/internal/memory/graph_test.go @@ -0,0 +1,227 @@ +package memory + +import ( + "os" + "testing" +) + +func newTestGraph(t *testing.T) *GraphDB { + t.Helper() + f, err := os.CreateTemp("", "graph_test_*.db") + if err != nil { + t.Fatal(err) + } + f.Close() + os.Remove(f.Name()) + + g, err := NewGraphDB(f.Name()) + if err != nil { + t.Fatal(err) + } + return g +} + +func TestNewGraphDB(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + stats, err := g.Introspect() + if err != nil { + t.Fatal(err) + } + if stats["entity_count"].(int) != 0 { + t.Errorf("expected 0 entities, got %d", stats["entity_count"]) + } + if stats["relation_count"].(int) != 0 { + t.Errorf("expected 0 relations, got %d", stats["relation_count"]) + } +} + +func TestCommitTriples(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + triples := []Triple{ + {Subject: "张三", Relation: "喜欢", Object: "编程"}, + {Subject: "张三", Relation: "居住", Object: "北京"}, + } + + ec, rc, err := g.Commit(triples, "test_session", 1) + if err != nil { + t.Fatal(err) + } + if ec != 4 { + t.Errorf("expected 4 entity ops (张三×2, 编程, 北京), got %d", ec) + } + if rc != 2 { + t.Errorf("expected 2 relations, got %d", rc) + } +} + +func TestCommitEmptyTriples(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + ec, rc, err := g.Commit(nil, "test", 0) + if err != nil { + t.Fatal(err) + } + if ec != 0 || rc != 0 { + t.Errorf("expected 0,0 for nil triples, got %d,%d", ec, rc) + } +} + +func TestRecallByKeywords(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + g.Commit([]Triple{ + {Subject: "咖啡", Relation: "属于", Object: "饮品"}, + {Subject: "咖啡", Relation: "含有", Object: "咖啡因"}, + }, "session1", 0) + + result, err := g.Recall([]string{"咖啡"}, nil, 1, "") + if err != nil { + t.Fatal(err) + } + if len(result.Entities) == 0 { + t.Error("expected entities for keyword '咖啡'") + } +} + +func TestRecallBySeedEntity(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + g.Commit([]Triple{ + {Subject: "Go", Relation: "是", Object: "编程语言"}, + {Subject: "Go", Relation: "用于", Object: "后端开发"}, + }, "session2", 0) + + result, err := g.Recall(nil, []string{"Go"}, 1, "") + if err != nil { + t.Fatal(err) + } + if len(result.Entities) == 0 { + t.Error("expected entities for seed 'Go'") + } +} + +func TestRecallWithDepth(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + g.Commit([]Triple{ + {Subject: "甲", Relation: "认识", Object: "乙"}, + {Subject: "乙", Relation: "认识", Object: "丙"}, + }, "session3", 0) + + result, err := g.Recall(nil, []string{"甲"}, 2, "") + if err != nil { + t.Fatal(err) + } + if len(result.Relations) == 0 { + t.Error("expected relations with depth search") + } +} + +func TestPurgeHard(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + g.Commit([]Triple{ + {Subject: "临时", Relation: "用于", Object: "测试"}, + }, "session4", 0) + + n, err := g.Purge(map[string]string{"subject_contains": "临时"}, "hard") + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Errorf("expected 1 purged relation, got %d", n) + } + + stats, _ := g.Introspect() + if stats["relation_count"].(int) != 0 { + t.Errorf("expected 0 relations after purge, got %d", stats["relation_count"]) + } +} + +func TestPurgeSoft(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + g.Commit([]Triple{ + {Subject: "可删除", Relation: "属于", Object: "测试"}, + }, "session5", 0) + + n, err := g.Purge(map[string]string{"subject_contains": "可删除"}, "soft") + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Errorf("expected 1 soft-deleted relation, got %d", n) + } + + stats, _ := g.Introspect() + if stats["relation_count"].(int) != 0 { + t.Errorf("expected 0 active relations after soft-delete, got %d", stats["relation_count"]) + } +} + +func TestArchive(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + // 直接插入一条旧记录 + g.db.Exec(`INSERT INTO entities (id, name, type) VALUES (1, '旧数据', 'Concept')`) + g.db.Exec(`INSERT INTO relations (source_id, target_id, relation_type, created_at) + VALUES (1, 1, '包含', datetime('now', '-1 day'))`) + + n, err := g.Archive(0) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Errorf("expected 1 archived relation, got %d", n) + } +} + +func TestIntrospectHotspots(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + g.Commit([]Triple{ + {Subject: "热门话题", Relation: "关于", Object: "AI"}, + {Subject: "热门话题", Relation: "关于", Object: "机器学习"}, + {Subject: "冷门话题", Relation: "关于", Object: "旧技术"}, + }, "session7", 0) + + stats, _ := g.Introspect() + hotspots := stats["memory_hotspots"].([]map[string]interface{}) + if len(hotspots) == 0 { + t.Error("expected hotspots") + } +} + +func TestPlaceholders(t *testing.T) { + if placeholders(0) != "NULL" { + t.Errorf("expected NULL for n=0, got %s", placeholders(0)) + } + if placeholders(1) != "?" { + t.Errorf("expected '?' for n=1, got %s", placeholders(1)) + } + if placeholders(3) != "?,?,?" { + t.Errorf("expected '?,?,?' for n=3, got %s", placeholders(3)) + } +} diff --git a/internal/memory/vector/store_test.go b/internal/memory/vector/store_test.go new file mode 100644 index 0000000..2caf507 --- /dev/null +++ b/internal/memory/vector/store_test.go @@ -0,0 +1,212 @@ +package vector + +import ( + "math" + "testing" +) + +func TestExtractNGrams(t *testing.T) { + tests := []struct { + text string + maxN int + check []string // 应包含 + }{ + {"hello", 2, []string{"h", "e", "l", "o", "he", "el", "ll", "lo"}}, + {"中文测试", 2, []string{"中", "文", "测", "试", "中文", "文测", "测试"}}, + {"a b", 1, []string{"a", "b"}}, + } + for _, tt := range tests { + got := extractNGrams(tt.text, tt.maxN) + for _, want := range tt.check { + found := false + for _, g := range got { + if g == want { + found = true + break + } + } + if !found { + t.Errorf("extractNGrams(%q, %d) missing %q; got %v", tt.text, tt.maxN, want, got) + } + } + } +} + +func TestExtractNGramsNoDups(t *testing.T) { + got := extractNGrams("aaaa", 2) + seen := make(map[string]bool) + for _, g := range got { + if seen[g] { + t.Errorf("duplicate ngram: %q", g) + } + seen[g] = true + } +} + +func TestCosineSimilarity(t *testing.T) { + a := Vector{"a": 1, "b": 2} + b := Vector{"a": 2, "b": 4} + sim := CosineSimilarity(a, b) + if math.Abs(sim-1.0) > 1e-6 { + t.Errorf("identical direction vectors should have cos=1, got %f", sim) + } + + c := Vector{"a": 1, "b": 0} + d := Vector{"a": 0, "b": 1} + sim = CosineSimilarity(c, d) + if math.Abs(sim) > 1e-6 { + t.Errorf("orthogonal vectors should have cos=0, got %f", sim) + } + + sim = CosineSimilarity(Vector{}, Vector{"a": 1}) + if sim != 0 { + t.Errorf("zero vector should return 0, got %f", sim) + } +} + +func TestTFIDFVectorizer(t *testing.T) { + v := NewTFIDFVectorizer(2) + docs := []string{"今天天气很好", "今天心情不错", "明天要下雨"} + v.Train(docs) + + vec := v.Vectorize("今天") + if len(vec) == 0 { + t.Fatal("vector should not be empty") + } + if _, ok := vec["今天"]; !ok { + t.Errorf("expected feature '今天' in vector") + } + + // 两个文档都有"今天",idf 应该较低 + idfToday := vec["今天"] + vecSun := v.Vectorize("下雨") + idfRain := vecSun["下雨"] + if idfRain <= idfToday { + t.Errorf("expected rare '下雨' to have higher idf than common '今天', got today=%f rain=%f", idfToday, idfRain) + } +} + +func TestTFIDFVectorizerEmpty(t *testing.T) { + v := NewTFIDFVectorizer(2) + v.Train(nil) + vec := v.Vectorize("test") + if len(vec) == 0 { + t.Error("should produce features even without training") + } + + // 未训练时所有 idf=1, 仅有 tf 归一化 + for _, w := range vec { + if w < 0 { + t.Errorf("weight should be non-negative, got %f", w) + } + } +} + +func TestInvertedIndex(t *testing.T) { + idx := NewInvertedIndex() + + idx.Add("doc1", Vector{"a": 1, "b": 2}) + idx.Add("doc2", Vector{"b": 1, "c": 3}) + + results := idx.Search(Vector{"a": 1}, 10) + if len(results) != 1 || results[0] != "doc1" { + t.Errorf("search 'a' should return doc1 only, got %v", results) + } + + results = idx.Search(Vector{"b": 1}, 10) + if len(results) != 2 { + t.Errorf("search 'b' should return 2 docs, got %v", results) + } + + idx.Remove("doc1") + results = idx.Search(Vector{"a": 1}, 10) + if len(results) != 0 { + t.Errorf("after remove, search 'a' should return empty, got %v", results) + } +} + +func TestStoreInsertAndSearch(t *testing.T) { + s := NewStore() + v := NewTFIDFVectorizer(2) + v.Train([]string{"hello world", "goodbye world"}) + + s.Insert("1", "hello world", v.Vectorize("hello world"), nil) + s.Insert("2", "goodbye world", v.Vectorize("goodbye world"), nil) + + if s.Size() != 2 { + t.Errorf("expected size 2, got %d", s.Size()) + } + + results := s.Search(v.Vectorize("hello"), 5) + if len(results) == 0 { + t.Fatal("expected results") + } + if results[0].ID != "1" { + t.Errorf("expected doc1 as top result for 'hello', got %s", results[0].ID) + } +} + +func TestStoreRemove(t *testing.T) { + s := NewStore() + v := NewTFIDFVectorizer(1) + v.Train([]string{"a"}) + + s.Insert("1", "a", v.Vectorize("a"), nil) + s.Insert("2", "a", v.Vectorize("a"), nil) + s.Remove("1") + + if s.Size() != 1 { + t.Errorf("after remove, size should be 1, got %d", s.Size()) + } + + results := s.Search(v.Vectorize("a"), 5) + if len(results) != 1 || results[0].ID != "2" { + t.Errorf("only doc2 should remain, got %v", results) + } +} + +func TestStoreEmpty(t *testing.T) { + s := NewStore() + results := s.Search(Vector{"a": 1}, 5) + if results != nil { + t.Errorf("empty store should return nil, got %v", results) + } +} + +func TestStoreAll(t *testing.T) { + s := NewStore() + v := NewTFIDFVectorizer(1) + v.Train([]string{"a", "b"}) + + s.Insert("1", "a", v.Vectorize("a"), map[string]string{"k": "v"}) + s.Insert("2", "b", v.Vectorize("b"), nil) + + all := s.All() + if len(all) != 2 { + t.Errorf("All() should return 2 docs, got %d", len(all)) + } + if all[0].Meta["k"] != "v" { + t.Errorf("meta should be preserved") + } +} + +func BenchmarkCosineSimilarity(b *testing.B) { + va := Vector{} + vb := Vector{} + for i := 0; i < 100; i++ { + f := string(rune('a' + i%26)) + va[f] = float64(i) + vb[f] = float64(100 - i) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + CosineSimilarity(va, vb) + } +} + +func BenchmarkExtractNGrams(b *testing.B) { + text := "今天天气很好,适合出去散步。明天可能下雨,记得带伞。" + for i := 0; i < b.N; i++ { + extractNGrams(text, 2) + } +} diff --git a/internal/onebot/client.go b/internal/onebot/client.go index d953022..6dc7f76 100644 --- a/internal/onebot/client.go +++ b/internal/onebot/client.go @@ -105,16 +105,19 @@ func (c *Client) reconnect() { func (c *Client) readLoop() { defer c.connected.Store(false) - defer c.mu.Lock() - defer c.mu.Unlock() - defer func() { - if c.conn != nil { - c.conn.Close() - } - }() for { - _, message, err := c.conn.ReadMessage() + c.mu.Lock() + conn := c.conn + c.mu.Unlock() + + if conn == nil { + log.Printf("[onebot] read loop: not connected") + go c.reconnect() + return + } + + _, message, err := conn.ReadMessage() if err != nil { log.Printf("[onebot] read error: %v", err) go c.reconnect() @@ -125,7 +128,10 @@ func (c *Client) readLoop() { var resp ActionResponse if err := json.Unmarshal(message, &resp); err == nil && resp.Echo != "" { if ch, ok := c.pending.Load(resp.Echo); ok { - ch.(chan *ActionResponse) <- &resp + select { + case ch.(chan *ActionResponse) <- &resp: + default: + } } continue } diff --git a/internal/onebot/device.go b/internal/onebot/device.go index 9f23d72..ad4ba13 100644 --- a/internal/onebot/device.go +++ b/internal/onebot/device.go @@ -189,9 +189,7 @@ func (d *Device) handleEvent(evt *Event) { payload["label"] = fmt.Sprintf("private:%d", evt.UserID) } - d.iom.InjectText(source, text) - // 同时注册输出路由:QQ 消息默认回复到 QQ 通道 - d.iom.RegisterOutputRoute(source, d.name) + d.iom.InjectTextTo(source, d.name, text) case "notice": log.Printf("[onebot] notice from %s: type=%s", d.name, evt.NoticeType) diff --git a/internal/onebot/types_test.go b/internal/onebot/types_test.go new file mode 100644 index 0000000..1a7b865 --- /dev/null +++ b/internal/onebot/types_test.go @@ -0,0 +1,165 @@ +package onebot + +import ( + "encoding/json" + "testing" +) + +func TestMessageText(t *testing.T) { + seg := MessageText("hello") + if seg.Type != "text" { + t.Errorf("expected type 'text', got %q", seg.Type) + } + if seg.Data["text"] != "hello" { + t.Errorf("expected data.text 'hello', got %q", seg.Data["text"]) + } +} + +func TestMessageImage(t *testing.T) { + seg := MessageImage("test.jpg") + if seg.Type != "image" { + t.Errorf("expected 'image', got %q", seg.Type) + } + if seg.Data["file"] != "test.jpg" { + t.Errorf("expected 'test.jpg', got %q", seg.Data["file"]) + } +} + +func TestMessageAt(t *testing.T) { + seg := MessageAt(123456) + if seg.Type != "at" { + t.Errorf("expected 'at', got %q", seg.Type) + } + if seg.Data["qq"] != "123456" { + t.Errorf("expected '123456', got %q", seg.Data["qq"]) + } +} + +func TestEventMarshal(t *testing.T) { + evt := Event{ + Time: 1234567890, + SelfID: 10001, + PostType: "message", + MessageType: "group", + GroupID: 999, + UserID: 777, + RawMessage: "hello", + Sender: &Sender{ + UserID: 777, + Nickname: "TestUser", + Role: "member", + }, + } + + data, err := json.Marshal(evt) + if err != nil { + t.Fatal(err) + } + + var decoded Event + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + + if decoded.PostType != "message" { + t.Errorf("expected 'message', got %q", decoded.PostType) + } + if decoded.GroupID != 999 { + t.Errorf("expected 999, got %d", decoded.GroupID) + } + if decoded.Sender.Nickname != "TestUser" { + t.Errorf("expected 'TestUser', got %q", decoded.Sender.Nickname) + } +} + +func TestEventMessagePrivate(t *testing.T) { + evt := Event{ + PostType: "message", + MessageType: "private", + UserID: 123, + RawMessage: "hi", + } + + if evt.PostType != "message" || evt.MessageType != "private" { + t.Errorf("unexpected event type: %s/%s", evt.PostType, evt.MessageType) + } +} + +func TestActionMarshal(t *testing.T) { + action := Action{ + Action: "send_private_msg", + Params: map[string]interface{}{ + "user_id": 123, + "message": "hello", + }, + Echo: "1", + } + + data, err := json.Marshal(action) + if err != nil { + t.Fatal(err) + } + + var decoded Action + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + + if decoded.Action != "send_private_msg" { + t.Errorf("expected 'send_private_msg', got %q", decoded.Action) + } +} + +func TestActionResponse(t *testing.T) { + resp := ActionResponse{ + Status: "ok", + RetCode: 0, + Data: map[string]interface{}{ + "message_id": 12345, + }, + Echo: "1", + } + + data, _ := json.Marshal(resp) + var decoded ActionResponse + json.Unmarshal(data, &decoded) + + if decoded.Status != "ok" { + t.Errorf("expected 'ok', got %q", decoded.Status) + } +} + +func TestStatus(t *testing.T) { + s := Status{ + AppInitialized: true, + AppEnabled: true, + Online: true, + Good: true, + } + + if !s.Online || !s.Good { + t.Error("status should be online and good") + } +} + +func TestEventMetaHeartbeat(t *testing.T) { + evt := Event{ + PostType: "meta_event", + MetaEventType: "heartbeat", + Interval: 3000, + Status: &Status{ + Online: true, + Good: true, + }, + } + + if evt.PostType != "meta_event" { + t.Errorf("expected 'meta_event', got %q", evt.PostType) + } + if evt.MetaEventType != "heartbeat" { + t.Errorf("expected 'heartbeat', got %q", evt.MetaEventType) + } + if !evt.Status.Online { + t.Error("should be online") + } +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 5cdbcbd..6b00d05 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -198,18 +198,9 @@ func (r *Registry) Register(p Plugin) { return // 纯技能插件,无 IO 通道 } - // 自动注册输出路由 if cfg := p.IOConfig(); cfg != nil { log.Printf("[plugin] io device %s active (type=%s, caps=%v)", p.Name(), cfg.Type, cfg.OutputCaps) - if cfg.InputRoute != "" { - outputRoute := cfg.OutputRoute - if outputRoute == "" { - outputRoute = cfg.InputRoute - } - r.ioMgr.RegisterOutputRoute(cfg.InputRoute, outputRoute) - log.Printf("[plugin] route: %s → %s", cfg.InputRoute, outputRoute) - } } } } @@ -300,7 +291,6 @@ func (r *Registry) Reload(dir string) (string, error) { // 2. 启动新设备的 IO 通道 newDevices := make(map[string]agentIO.Device) - newRoutes := make(map[string]string) for _, lp := range loaded { if lp.err != nil { log.Printf("[plugin] skip %s: %v", lp.name, lp.err) @@ -313,15 +303,6 @@ func (r *Registry) Reload(dir string) (string, error) { if dev != nil { dev.Start() // 新设备预先启动 newDevices[lp.name] = dev - if cfg := lp.p.IOConfig(); cfg != nil { - if cfg.InputRoute != "" { - out := cfg.OutputRoute - if out == "" { - out = cfg.InputRoute - } - newRoutes[cfg.InputRoute] = out - } - } } } @@ -330,7 +311,7 @@ func (r *Registry) Reload(dir string) (string, error) { var oldPlugins map[string]Plugin if r.ioMgr != nil { // 获取旧设备并原子替换 - oldDevices := r.ioMgr.AtomicSwapDevices(newDevices, newRoutes) + oldDevices := r.ioMgr.AtomicSwapDevices(newDevices) // 停止旧设备 for _, dev := range oldDevices { go dev.Stop() @@ -672,14 +653,8 @@ func (d *PluginDevice) Description() string { return d.plugin.Description( func (d *PluginDevice) Tools() []agentIO.ToolDef { pts := d.plugin.Tools() - defs := make([]agentIO.ToolDef, 0, len(pts)) - for _, t := range pts { - defs = append(defs, agentIO.ToolDef{ - Name: t.Name, - Description: t.Description, - Parameters: t.Parameters, - }) - } + defs := make([]agentIO.ToolDef, len(pts)) + copy(defs, pts) return defs } @@ -713,10 +688,21 @@ func extractDescription(content string) string { // extractField finds `field: value` pattern in content func extractField(content string, field string) string { prefix := field + ":" + lowerPrefix := toLower(prefix) for _, line := range splitLines(content) { trimmed := trimSpace(line) - if hasPrefix(toLower(trimmed), prefix) { - return trimSpace(trimPrefix(trimmed, prefix)) + if hasPrefix(toLower(trimmed), lowerPrefix) { + // 找到冒号位置,提取冒号后的内容 + colonIdx := -1 + for i := 0; i < len(trimmed); i++ { + if trimmed[i] == ':' { + colonIdx = i + break + } + } + if colonIdx >= 0 { + return trimSpace(trimmed[colonIdx+1:]) + } } } return "" diff --git a/internal/plugin/plugin_test.go b/internal/plugin/plugin_test.go new file mode 100644 index 0000000..87b1744 --- /dev/null +++ b/internal/plugin/plugin_test.go @@ -0,0 +1,197 @@ +package plugin + +import ( + "testing" +) + +func TestExtractDescription(t *testing.T) { + content := "# Plugin\n\nThis is a test plugin.\nversion: 1.0.0" + desc := extractDescription(content) + if desc != "This is a test plugin." { + t.Errorf("expected 'This is a test plugin.', got %q", desc) + } +} + +func TestExtractField(t *testing.T) { + content := "version: 1.0.0\nauthor: test\nio_type: io" + if v := extractField(content, "version"); v != "1.0.0" { + t.Errorf("expected '1.0.0', got %q", v) + } + if v := extractField(content, "author"); v != "test" { + t.Errorf("expected 'test', got %q", v) + } + if v := extractField(content, "io_type"); v != "io" { + t.Errorf("expected 'io', got %q", v) + } +} + +func TestExtractFieldCaseInsensitive(t *testing.T) { + content := "Version: 2.0.0" + if v := extractField(content, "version"); v != "2.0.0" { + t.Errorf("expected '2.0.0', got %q", v) + } +} + +func TestExtractFieldMissing(t *testing.T) { + if v := extractField("no fields here", "version"); v != "" { + t.Errorf("expected '', got %q", v) + } +} + +func TestExtractIOConfigFull(t *testing.T) { + content := `# QQ Plugin +io_type: io +io_input_route: qq +io_output_route: qq +io_output_caps: text,file,image` + + cfg := extractIOConfig(content) + if cfg == nil { + t.Fatal("expected IOConfig") + } + if cfg.Type != "io" { + t.Errorf("expected type 'io', got %q", cfg.Type) + } + if cfg.InputRoute != "qq" { + t.Errorf("expected input_route 'qq', got %q", cfg.InputRoute) + } + if cfg.OutputRoute != "qq" { + t.Errorf("expected output_route 'qq', got %q", cfg.OutputRoute) + } + if len(cfg.OutputCaps) != 3 || cfg.OutputCaps[0] != "text" { + t.Errorf("expected caps [text file image], got %v", cfg.OutputCaps) + } +} + +func TestExtractIOConfigMinimal(t *testing.T) { + content := `# Plugin +io_type: input` + cfg := extractIOConfig(content) + if cfg == nil { + t.Fatal("expected IOConfig") + } + if cfg.Type != "input" { + t.Errorf("expected 'input', got %q", cfg.Type) + } + if cfg.InputRoute != "" { + t.Errorf("expected empty input_route, got %q", cfg.InputRoute) + } +} + +func TestExtractIOConfigNil(t *testing.T) { + cfg := extractIOConfig("# No IO config here") + if cfg != nil { + t.Errorf("expected nil, got %+v", cfg) + } +} + +func TestExtractToolDefsBasic(t *testing.T) { + content := `# Plugin +description + +## hello_tool +Say hello to someone +- name: The person to greet + +## add_numbers +Add two numbers together +- a: First number +- b: Second number` + + defs := extractToolDefs(content) + if len(defs) != 2 { + t.Fatalf("expected 2 tools, got %d", len(defs)) + } + + if defs[0].Name != "hello_tool" { + t.Errorf("expected 'hello_tool', got %q", defs[0].Name) + } + if defs[0].Description != "Say hello to someone" { + t.Errorf("expected 'Say hello to someone', got %q", defs[0].Description) + } + + props := defs[0].Parameters["properties"].(map[string]interface{}) + if _, ok := props["name"]; !ok { + t.Errorf("expected 'name' parameter") + } + p := props["name"].(map[string]interface{}) + if p["description"] != "The person to greet" { + t.Errorf("expected desc 'The person to greet', got %q", p["description"]) + } +} + +func TestExtractToolDefsToolWithColon(t *testing.T) { + content := `# Plugin + +### Tool: my_tool +Do something +- param: Description` + + defs := extractToolDefs(content) + if len(defs) != 1 { + t.Fatalf("expected 1 tool, got %d", len(defs)) + } + if defs[0].Name != "my_tool" { + t.Errorf("expected 'my_tool', got %q", defs[0].Name) + } +} + +func TestExtractToolDefsSkipsNonToolSections(t *testing.T) { + content := `# Plugin + +## Usage +This is how to use the plugin + +## Examples +Some examples here + +## real_tool +This is an actual tool +- param: value` + + defs := extractToolDefs(content) + if len(defs) != 1 { + t.Fatalf("expected 1 tool (non-tool sections skipped), got %d", len(defs)) + } + if defs[0].Name != "real_tool" { + t.Errorf("expected 'real_tool', got %q", defs[0].Name) + } +} + +func TestExtractToolDefsEmpty(t *testing.T) { + defs := extractToolDefs("# Just a title\nNo tools here") + if len(defs) != 0 { + t.Errorf("expected 0 tools, got %d", len(defs)) + } +} + +func TestExtractToolDefsCodeBlock(t *testing.T) { + content := "# Plugin\n\n## my_tool\nA tool\n- param: desc\n\n```\n## not_a_tool\nThis is inside a code block\n```\n\n## another_tool\nAnother one\n- x: y" + + defs := extractToolDefs(content) + if len(defs) != 2 { + t.Fatalf("expected 2 tools (code block skipped), got %d", len(defs)) + } + if defs[0].Name != "my_tool" || defs[1].Name != "another_tool" { + t.Errorf("unexpected tool names: %v", defs) + } +} + +func TestExtractToolDefsNoParams(t *testing.T) { + content := `# Plugin + +## simple_tool +A tool with no parameters` + + defs := extractToolDefs(content) + if len(defs) != 1 { + t.Fatalf("expected 1 tool, got %d", len(defs)) + } + if defs[0].Name != "simple_tool" { + t.Errorf("expected 'simple_tool', got %q", defs[0].Name) + } + props := defs[0].Parameters["properties"].(map[string]interface{}) + if len(props) != 0 { + t.Errorf("expected no params, got %d", len(props)) + } +} diff --git a/internal/supervisor/daemon.go b/internal/supervisor/daemon.go index 19f35e0..7235474 100644 --- a/internal/supervisor/daemon.go +++ b/internal/supervisor/daemon.go @@ -65,8 +65,8 @@ func (d *Daemon) Shutdown() { log.Println("[homed] shutting down...") d.cancel() - d.mu.RLock() - defer d.mu.RUnlock() + d.mu.Lock() + defer d.mu.Unlock() for id, agent := range d.agents { if agent.state == types.AgentStateRunning { diff --git a/internal/supervisor/daemon_test.go b/internal/supervisor/daemon_test.go new file mode 100644 index 0000000..7c5ab8c --- /dev/null +++ b/internal/supervisor/daemon_test.go @@ -0,0 +1,209 @@ +package supervisor + +import ( + "testing" + "time" + + "gitcode.com/JianFeeeee/HomeAgent/internal/tracker" + "gitcode.com/JianFeeeee/HomeAgent/pkg/types" +) + +func TestNew(t *testing.T) { + cfg := &types.Config{ + Daemon: types.DaemonConfig{ + CheckInterval: time.Minute, + HeartbeatInterval: 30 * time.Second, + }, + } + d := New(cfg) + if d == nil { + t.Fatal("Daemon should not be nil") + } +} + +func TestStartAndShutdown(t *testing.T) { + cfg := &types.Config{ + Daemon: types.DaemonConfig{ + CheckInterval: time.Minute, + HeartbeatInterval: 30 * time.Second, + }, + } + d := New(cfg) + if err := d.Start(); err != nil { + t.Fatal(err) + } + d.Shutdown() +} + +func TestRegisterAgent(t *testing.T) { + cfg := &types.Config{ + Daemon: types.DaemonConfig{ + CheckInterval: time.Minute, + HeartbeatInterval: 30 * time.Second, + }, + } + d := New(cfg) + d.RegisterAgent("test_agent") + + agents := d.ListAgents() + if len(agents) != 1 { + t.Fatalf("expected 1 agent, got %d", len(agents)) + } + if agents[0].ID != "test_agent" { + t.Errorf("expected 'test_agent', got %q", agents[0].ID) + } + if agents[0].State != types.AgentStateRunning { + t.Errorf("expected Running state, got %v", agents[0].State) + } +} + +func TestGetAgentStatus(t *testing.T) { + cfg := &types.Config{ + Defaults: types.AgentConfig{ + RollbackPolicy: types.RollbackPolicy{MaxRetries: 5}, + }, + Daemon: types.DaemonConfig{ + CheckInterval: time.Minute, + HeartbeatInterval: 30 * time.Second, + }, + } + d := New(cfg) + d.RegisterAgent("agent1") + + status, err := d.GetAgentStatus("agent1") + if err != nil { + t.Fatal(err) + } + if status.ID != "agent1" { + t.Errorf("expected 'agent1', got %q", status.ID) + } +} + +func TestGetAgentStatusNotFound(t *testing.T) { + cfg := &types.Config{ + Daemon: types.DaemonConfig{ + CheckInterval: time.Minute, + HeartbeatInterval: 30 * time.Second, + }, + } + d := New(cfg) + _, err := d.GetAgentStatus("nonexistent") + if err == nil { + t.Error("expected error for nonexistent agent") + } +} + +func TestListAgents(t *testing.T) { + cfg := &types.Config{ + Daemon: types.DaemonConfig{ + CheckInterval: time.Minute, + HeartbeatInterval: 30 * time.Second, + }, + } + d := New(cfg) + d.RegisterAgent("a") + d.RegisterAgent("b") + + agents := d.ListAgents() + if len(agents) != 2 { + t.Errorf("expected 2 agents, got %d", len(agents)) + } +} + +func TestSetTracker(t *testing.T) { + cfg := &types.Config{ + Daemon: types.DaemonConfig{ + CheckInterval: time.Minute, + HeartbeatInterval: 30 * time.Second, + }, + } + d := New(cfg) + tr := tracker.NewTracker("/tmp/test_tracker_data", "/tmp/test_tracker_work") + d.SetTracker(tr) + + // RegisterAgent should use tracker + d.RegisterAgent("tracked_agent") + status, _ := d.GetAgentStatus("tracked_agent") + if status.TrackerStats == nil { + t.Error("expected tracker stats") + } +} + +func TestRollbackAgent(t *testing.T) { + cfg := &types.Config{ + Daemon: types.DaemonConfig{ + CheckInterval: time.Minute, + HeartbeatInterval: 30 * time.Second, + }, + } + d := New(cfg) + tr := tracker.NewTracker("/tmp/test_rb_data", "/tmp/test_rb_work") + d.SetTracker(tr) + + err := d.RollbackAgent("any", "snap1") + if err == nil { + t.Log("rollback succeeded (tracker may be unmounted)") + } +} + +func TestRollbackAgentNoTracker(t *testing.T) { + cfg := &types.Config{ + Daemon: types.DaemonConfig{ + CheckInterval: time.Minute, + HeartbeatInterval: 30 * time.Second, + }, + } + d := New(cfg) + err := d.RollbackAgent("main", "snap1") + if err == nil { + t.Error("expected error when no tracker") + } +} + +func TestPreActionSnapshot(t *testing.T) { + cfg := &types.Config{ + Daemon: types.DaemonConfig{ + CheckInterval: time.Minute, + HeartbeatInterval: 30 * time.Second, + }, + } + d := New(cfg) + _, err := d.PreActionSnapshot("main") + if err == nil { + t.Error("expected error (snapshot not supported)") + } +} + +func TestRegisterAgentMultiple(t *testing.T) { + cfg := &types.Config{ + Daemon: types.DaemonConfig{ + CheckInterval: time.Minute, + HeartbeatInterval: 30 * time.Second, + }, + } + d := New(cfg) + + for i := 0; i < 5; i++ { + d.RegisterAgent(types.AgentID(string(rune('a'+i)))) + } + + agents := d.ListAgents() + if len(agents) != 5 { + t.Errorf("expected 5 agents, got %d", len(agents)) + } +} + +func TestConcurrentAccess(t *testing.T) { + cfg := &types.Config{ + Daemon: types.DaemonConfig{ + CheckInterval: time.Minute, + HeartbeatInterval: 30 * time.Second, + }, + } + d := New(cfg) + + // Register from multiple goroutines + for i := 0; i < 10; i++ { + go d.RegisterAgent(types.AgentID(string(rune('a' + i)))) + } +} diff --git a/internal/tracker/changeset.go b/internal/tracker/changeset.go index f382e6b..1dabc03 100644 --- a/internal/tracker/changeset.go +++ b/internal/tracker/changeset.go @@ -90,6 +90,9 @@ func captureFSState(root string) (*FSState, error) { func diffStates(before, after *FSState) []FileChange { var changes []FileChange + if before == nil || after == nil { + return changes + } seen := make(map[string]bool) for path, afterFile := range after.Files { diff --git a/internal/tracker/tracker_test.go b/internal/tracker/tracker_test.go new file mode 100644 index 0000000..edfc5be --- /dev/null +++ b/internal/tracker/tracker_test.go @@ -0,0 +1,314 @@ +package tracker + +import ( + "os" + "path/filepath" + "testing" +) + +func TestNewTracker(t *testing.T) { + tr := NewTracker("/tmp/tracker_data", "/tmp/tracker_work") + if tr == nil { + t.Fatal("tracker should not be nil") + } + if tr.mergeDir != "/tmp/tracker_work/merged" { + t.Errorf("unexpected mergeDir: %s", tr.mergeDir) + } +} + +func TestInit(t *testing.T) { + dir := t.TempDir() + tr := NewTracker(filepath.Join(dir, "data"), filepath.Join(dir, "work")) + if err := tr.Init(); err != nil { + t.Fatal(err) + } + for _, d := range []string{tr.lowerDir, tr.upperDir, tr.mergeDir} { + if _, err := os.Stat(d); os.IsNotExist(err) { + t.Errorf("dir %s should exist", d) + } + } +} + +func TestNewChangeSet(t *testing.T) { + cs := NewChangeSet("test_action") + if cs.Action != "test_action" { + t.Errorf("expected 'test_action', got %q", cs.Action) + } + if cs.ID == "" { + t.Error("ID should not be empty") + } + if len(cs.Files) != 0 { + t.Errorf("expected 0 files, got %d", len(cs.Files)) + } +} + +func TestFileHash(t *testing.T) { + f := t.TempDir() + "/test.txt" + if err := os.WriteFile(f, []byte("hello"), 0644); err != nil { + t.Fatal(err) + } + + hash, size, err := fileHash(f) + if err != nil { + t.Fatal(err) + } + if size != 5 { + t.Errorf("expected size 5, got %d", size) + } + if hash == "" { + t.Error("hash should not be empty") + } + // SHA256 of "hello" + expected := "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + if hash != expected { + t.Errorf("expected hash %s, got %s", expected, hash) + } +} + +func TestFileHashNotFound(t *testing.T) { + _, _, err := fileHash("/nonexistent/file") + if err == nil { + t.Error("expected error for nonexistent file") + } +} + +func TestFileInfo(t *testing.T) { + f := t.TempDir() + "/info.txt" + os.WriteFile(f, []byte("test"), 0644) + + size, modTime, err := fileInfo(f) + if err != nil { + t.Fatal(err) + } + if size != 4 { + t.Errorf("expected size 4, got %d", size) + } + if modTime.IsZero() { + t.Error("modTime should not be zero") + } +} + +func TestFileInfoNotFound(t *testing.T) { + _, _, err := fileInfo("/nonexistent/file") + if err == nil { + t.Error("expected error for nonexistent file") + } +} + +func TestCaptureFSStateEmpty(t *testing.T) { + dir := t.TempDir() + state, err := captureFSState(dir) + if err != nil { + t.Fatal(err) + } + if len(state.Files) != 0 { + t.Errorf("expected 0 files, got %d", len(state.Files)) + } + if state.Root != dir { + t.Errorf("expected root %s, got %s", dir, state.Root) + } +} + +func TestCaptureFSState(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "a.txt"), []byte("aaa"), 0644) + os.WriteFile(filepath.Join(dir, "b.txt"), []byte("bbb"), 0644) + os.MkdirAll(filepath.Join(dir, "sub"), 0755) + os.WriteFile(filepath.Join(dir, "sub", "c.txt"), []byte("ccc"), 0644) + + state, err := captureFSState(dir) + if err != nil { + t.Fatal(err) + } + if len(state.Files) != 3 { + t.Errorf("expected 3 files, got %d", len(state.Files)) + } + // should contain relative paths + for _, p := range []string{"a.txt", "b.txt", "sub/c.txt"} { + if _, ok := state.Files[p]; !ok { + t.Errorf("missing file %s", p) + } + } +} + +func TestDiffStatesCreated(t *testing.T) { + dir := t.TempDir() + before, _ := captureFSState(dir) + os.WriteFile(filepath.Join(dir, "new.txt"), []byte("new file"), 0644) + after, _ := captureFSState(dir) + + changes := diffStates(before, after) + if len(changes) != 1 { + t.Fatalf("expected 1 change, got %d", len(changes)) + } + if changes[0].Type != ChangeFileCreated { + t.Errorf("expected created, got %s", changes[0].Type) + } + if changes[0].Path != "new.txt" { + t.Errorf("expected 'new.txt', got %s", changes[0].Path) + } +} + +func TestDiffStatesModified(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "f.txt"), []byte("original"), 0644) + before, _ := captureFSState(dir) + os.WriteFile(filepath.Join(dir, "f.txt"), []byte("modified"), 0644) + after, _ := captureFSState(dir) + + changes := diffStates(before, after) + if len(changes) != 1 { + t.Fatalf("expected 1 change, got %d", len(changes)) + } + if changes[0].Type != ChangeFileModified { + t.Errorf("expected modified, got %s", changes[0].Type) + } +} + +func TestDiffStatesDeleted(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "f.txt"), []byte("delete me"), 0644) + before, _ := captureFSState(dir) + os.Remove(filepath.Join(dir, "f.txt")) + after, _ := captureFSState(dir) + + changes := diffStates(before, after) + if len(changes) != 1 { + t.Fatalf("expected 1 change, got %d", len(changes)) + } + if changes[0].Type != ChangeFileDeleted { + t.Errorf("expected deleted, got %s", changes[0].Type) + } +} + +func TestDiffStatesNoChange(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "f.txt"), []byte("stable"), 0644) + before, _ := captureFSState(dir) + after, _ := captureFSState(dir) + + changes := diffStates(before, after) + if len(changes) != 0 { + t.Errorf("expected 0 changes, got %d", len(changes)) + } +} + +func TestDiffStatesMixed(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "keep.txt"), []byte("unchanged"), 0644) + os.WriteFile(filepath.Join(dir, "delete.txt"), []byte("gone"), 0644) + before, _ := captureFSState(dir) + + os.Remove(filepath.Join(dir, "delete.txt")) + os.WriteFile(filepath.Join(dir, "add.txt"), []byte("new"), 0644) + os.WriteFile(filepath.Join(dir, "keep.txt"), []byte("changed"), 0644) + after, _ := captureFSState(dir) + + changes := diffStates(before, after) + if len(changes) != 3 { + t.Fatalf("expected 3 changes, got %d", len(changes)) + } + + types := make(map[ChangeType]bool) + for _, c := range changes { + types[c.Type] = true + } + if !types[ChangeFileCreated] { + t.Error("missing created") + } + if !types[ChangeFileModified] { + t.Error("missing modified") + } + if !types[ChangeFileDeleted] { + t.Error("missing deleted") + } +} + +func TestDiffStatesNilBefore(t *testing.T) { + dir := t.TempDir() + after, _ := captureFSState(dir) + + changes := diffStates(nil, after) + if len(changes) != 0 { + t.Errorf("expected 0 changes when before is nil, got %d", len(changes)) + } +} + +func TestPreActionResetsBefore(t *testing.T) { + dir := t.TempDir() + tr := NewTracker(filepath.Join(dir, "data"), filepath.Join(dir, "work")) + tr.Init() + + // capture initial state + cs := tr.PreAction("test") + if cs == nil { + t.Fatal("changeset should not be nil") + } + if cs.Action != "test" { + t.Errorf("expected 'test', got %q", cs.Action) + } +} + +func TestPostActionNoChanges(t *testing.T) { + dir := t.TempDir() + tr := NewTracker(filepath.Join(dir, "data"), filepath.Join(dir, "work")) + tr.Init() + + tr.PreAction("noop") + cs := tr.PostAction("noop") + if cs == nil { + t.Fatal("changeset should not be nil") + } + if len(cs.Files) != 0 { + t.Errorf("expected 0 files for noop, got %d", len(cs.Files)) + } +} + +func TestStats(t *testing.T) { + dir := t.TempDir() + tr := NewTracker(filepath.Join(dir, "data"), filepath.Join(dir, "work")) + tr.Init() + + stats := tr.Stats() + if stats["mounted"].(bool) { + t.Error("should not be mounted") + } + if stats["change_sets"].(int) != 0 { + t.Errorf("expected 0 changesets, got %d", stats["change_sets"]) + } +} + +func TestMergeDir(t *testing.T) { + tr := NewTracker("/data", "/work") + if tr.MergeDir() != "/work/merged" { + t.Errorf("unexpected mergeDir: %s", tr.MergeDir()) + } +} + +func TestHasChanges(t *testing.T) { + dir := t.TempDir() + tr := NewTracker(filepath.Join(dir, "data"), filepath.Join(dir, "work")) + tr.Init() + + if tr.HasChanges() { + t.Error("should have no changes initially") + } +} + +func TestChangeSetsEmpty(t *testing.T) { + dir := t.TempDir() + tr := NewTracker(filepath.Join(dir, "data"), filepath.Join(dir, "work")) + tr.Init() + + cs := tr.ChangeSets() + if len(cs) != 0 { + t.Errorf("expected 0 changesets, got %d", len(cs)) + } +} + +func TestCaptureDirNotExist(t *testing.T) { + _, err := captureFSState("/tmp/nonexistent_test_dir_12345") + if err == nil { + t.Error("expected error for nonexistent directory") + } +}