Files
HomeAgent/internal/plugins/webui/handler_test.go
JianFeeeee ae42e486de feat(pluginmgr): 插件更新接口(upgrade/downgrade 保留配置)+ skill_install overwrite
内核 Registry 拆出 StopAndUnload:
- 停止并从注册表移除插件但保留 config_<name> 表
- 不触发 onRemove 回调(那是删除专用语义)
- RemovePlugin 改为追加清理配置表示清除,更新场景调 StopAndUnload

pluginmgr:
- installFromData/installFromURL/installFromPath 加 overwrite 参数
- 已存在+overwrite=true:StopAndUnload→备份旧目录→解压新包→失败回滚→
  返回 action=upgraded/downgraded/reinstalled+previous_version+config_kept
- 已存在+overwrite=false:返回 error+hint(指向 overwrite 用法)
- cmpVersion 点分版本号数字比较(非字典序)
- 测试覆盖:首次安装→重装拒绝→升级保留配置→降级→失败回滚

skill_install 加 overwrite 参数:
- 同名技能存在时先卸载旧实例+删除目录再安装新包

SDK PluginMgr 接口同步加 StopAndUnload(name string) error

工具链 plugindev 已重建到 /usr/local/bin(7/29→8/25 版本)
QQ 插件诊断日志版(webhook recv 到达+isAtBot 失败日志)已打包并
通过 upgrade 接口热更新部署,配置保留验证通过。
2026-08-25 22:02:17 +08:00

988 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

package webui
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
)
func testSDK(cfg sdk.SDKConfig) *sdk.PluginSDK {
if cfg.EventBus == nil {
cfg.EventBus = events.NewBus()
}
return sdk.New("webui", cfg)
}
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()
s := testSDK(sdk.SDKConfig{
Supervisor: supervisor.NewSDKAdapter(sup),
Config: sdk.NewConfig(cfg),
})
return NewHandler(s), sup
}
func seedWebUIConfig(cfgReg *internalConfig.ConfigRegistry) {
webuiCfg := cfgReg.PluginConfig("webui")
webuiCfg.RegisterDef(internalConfig.ConfigDef{Key: "api_key", Default: ""})
webuiCfg.RegisterDef(internalConfig.ConfigDef{Key: "username", Default: "admin"})
webuiCfg.RegisterDef(internalConfig.ConfigDef{Key: "password", Default: ""})
webuiCfg.RegisterDef(internalConfig.ConfigDef{Key: "session_ttl_hours", Default: "24"})
webuiCfg.Set("api_key", "test-api-key")
webuiCfg.Set("username", "admin")
webuiCfg.Set("password", "secret-pass")
webuiCfg.Set("session_ttl_hours", "24")
}
func TestAuthMiddleware(t *testing.T) {
cfgReg := internalConfig.NewConfigRegistry("")
seedWebUIConfig(cfgReg)
sup := supervisor.New(&types.Config{
Daemon: types.DaemonConfig{
CheckInterval: time.Minute,
HeartbeatInterval: 30 * time.Second,
},
})
sup.Start()
defer sup.Shutdown()
s := testSDK(sdk.SDKConfig{
Supervisor: supervisor.NewSDKAdapter(sup),
Settings: sdk.NewSettings("webui", cfgReg),
Config: sdk.NewConfig(&types.Config{}),
})
h := NewHandler(s)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
t.Run("api_requires_auth", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
})
t.Run("api_key_allows_access", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
req.Header.Set("X-API-Key", "test-api-key")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
})
t.Run("login_sets_session_cookie", func(t *testing.T) {
body := `{"username":"admin","password":"secret-pass"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if len(w.Result().Cookies()) == 0 {
t.Fatal("expected session cookie")
}
})
t.Run("root_redirects_to_login_without_session", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("expected 302, got %d", w.Code)
}
})
}
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()
s := testSDK(sdk.SDKConfig{
Supervisor: supervisor.NewSDKAdapter(sup),
Knowledge: sdk.NewKnowledge(ks),
Config: sdk.NewConfig(cfg),
})
h := NewHandler(s)
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()
s := testSDK(sdk.SDKConfig{
Supervisor: supervisor.NewSDKAdapter(sup),
Knowledge: sdk.NewKnowledge(ks),
Config: sdk.NewConfig(cfg),
})
h := NewHandler(s)
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()
s := testSDK(sdk.SDKConfig{
Supervisor: supervisor.NewSDKAdapter(sup),
Tracker: tr,
Config: sdk.NewConfig(cfg),
})
h := NewHandler(s)
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) {
cfgReg := internalConfig.NewConfigRegistry("")
seedWebUIConfig(cfgReg)
sup := supervisor.New(&types.Config{
Daemon: types.DaemonConfig{
CheckInterval: time.Minute,
HeartbeatInterval: 30 * time.Second,
},
})
sup.Start()
defer sup.Shutdown()
s := testSDK(sdk.SDKConfig{
Supervisor: supervisor.NewSDKAdapter(sup),
Settings: sdk.NewSettings("webui", cfgReg),
Config: sdk.NewConfig(&types.Config{}),
})
h := NewHandler(s)
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.StatusFound},
{"/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)
if strings.HasPrefix(tt.path, "/api/v1/") {
req.Header.Set("X-API-Key", "test-api-key")
}
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)
}
}
// === 全流程集成测试ConfigRegistry → WebUI → HTTP API ===
func TestSettingsAPIFlow(t *testing.T) {
cfgReg := internalConfig.NewConfigRegistry("")
cfgReg.Register("core.llm.model", "deepseek-v4-flash")
cfgReg.Register("core.llm.base_url", "https://api.deepseek.com")
cfgReg.Register("webui.listen_addr", ":8080")
cfgReg.Register("plugin.qq.access_token", "secret123")
sup := supervisor.New(&types.Config{
Daemon: types.DaemonConfig{
CheckInterval: time.Minute,
HeartbeatInterval: 30 * time.Second,
},
})
sup.Start()
defer sup.Shutdown()
s := testSDK(sdk.SDKConfig{
Supervisor: supervisor.NewSDKAdapter(sup),
Settings: sdk.NewSettings("webui", cfgReg),
Config: sdk.NewConfig(&types.Config{}),
})
h := NewHandler(s)
t.Run("GET_settings_lists_keys_and_plugins", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
w := httptest.NewRecorder()
h.handleSettings(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
settings, ok := resp["settings"].(map[string]interface{})
if !ok {
t.Fatal("settings not a map")
}
if v, _ := settings["core.llm.model"].(string); v != "deepseek-v4-flash" {
t.Fatalf("expected deepseek-v4-flash, got %v", settings["core.llm.model"])
}
plugins, ok := resp["plugins"].([]interface{})
if !ok || len(plugins) == 0 {
t.Fatal("expected plugins list")
}
if plugins[0] != "core" {
t.Fatalf("expected first plugin 'core', got %v", plugins[0])
}
})
t.Run("GET_settings_with_prefix", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings?prefix=webui", nil)
w := httptest.NewRecorder()
h.handleSettings(w, req)
var resp map[string]interface{}
json.NewDecoder(w.Body).Decode(&resp)
settings := resp["settings"].(map[string]interface{})
if _, ok := settings["webui.listen_addr"]; !ok {
t.Fatal("expected webui.listen_addr in filtered results")
}
if _, ok := settings["core.llm.model"]; ok {
t.Fatal("core.llm.model should not be in webui filtered results")
}
})
t.Run("PUT_settings_updates_value", func(t *testing.T) {
body := `{"key":"core.llm.model","value":"gpt-4"}`
req := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSettings(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
val, err := cfgReg.Get("core.llm.model")
if err != nil {
t.Fatalf("Get error: %v", err)
}
if v, _ := val.(string); v != "gpt-4" {
t.Fatalf("expected gpt-4, got %v", val)
}
})
t.Run("PUT_settings_invalid_body", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader("not json"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSettings(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
})
t.Run("handleStatic_returns_webui_html", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
h.handleStatic(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
body, _ := io.ReadAll(w.Body)
html := string(body)
if !strings.Contains(html, "settings-layout") {
t.Fatal("HTML should contain settings-layout class")
}
if !strings.Contains(html, "settings-tabs") {
t.Fatal("HTML should contain settings-tabs class")
}
if !strings.Contains(html, "saveSetting") {
t.Fatal("HTML should contain saveSetting JS function")
}
if !strings.Contains(html, `api("/settings"`) {
t.Fatal("HTML should call api('/settings')")
}
})
t.Run("settings_not_available_without_registry", func(t *testing.T) {
s2 := testSDK(sdk.SDKConfig{
Supervisor: supervisor.NewSDKAdapter(sup),
Config: sdk.NewConfig(&types.Config{}),
})
h2 := NewHandler(s2)
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
w := httptest.NewRecorder()
h2.handleSettings(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
})
}
func TestSettingsWithPluginRegistry(t *testing.T) {
cfgReg := internalConfig.NewConfigRegistry("")
cfgReg.Register("core.test.key", "value")
cfgReg.Register("plugin.testplug.apikey", "abc123")
sup := supervisor.New(&types.Config{
Daemon: types.DaemonConfig{
CheckInterval: time.Minute,
HeartbeatInterval: 30 * time.Second,
},
})
sup.Start()
defer sup.Shutdown()
s := testSDK(sdk.SDKConfig{
Supervisor: supervisor.NewSDKAdapter(sup),
Settings: sdk.NewSettings("webui", cfgReg),
Config: sdk.NewConfig(&types.Config{}),
})
h := NewHandler(s)
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
w := httptest.NewRecorder()
h.handleSettings(w, req)
var resp map[string]interface{}
json.NewDecoder(w.Body).Decode(&resp)
plugins, _ := resp["plugins"].([]interface{})
foundCore := false
for _, p := range plugins {
if p == "core" {
foundCore = true
break
}
}
if !foundCore {
t.Fatal("expected 'core' in plugins list")
}
}
// === 端到端测试Handler + IOManager + Agent + HTTP ===
type echoProvider struct{ name string }
func (p *echoProvider) Name() string { return p.name }
func (p *echoProvider) MaxContextTokens() int { return 8192 }
func (p *echoProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
content := "echo: " + lastUserContent(req.Messages)
return &agentAPI.CompletionResponse{Content: content, FinishReason: "stop"}, nil
}
func (p *echoProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
// 流契约chunk 发送完毕后必须 close(channel) 标识流结束(与
// LuaAdaptedProvider.ChatStream 的 defer close(ch) 一致);
// accumulateStream 以 channel 关闭为终止条件Done 只是 finish_reason 载体。
// 内容与 Chat() 保持一致,保证端到端断言在流式/非流式两条路径下等价。
ch := make(chan agentAPI.StreamChunk, 1)
content := "echo: " + lastUserContent(req.Messages)
ch <- agentAPI.StreamChunk{Content: content, Done: true, FinishReason: "stop"}
close(ch)
return ch, nil
}
func lastUserContent(msgs []agentAPI.Message) string {
for i := len(msgs) - 1; i >= 0; i-- {
if msgs[i].Role == "user" {
return msgs[i].Content
}
}
return ""
}
func init() {
// 避免测试时自动输出
}
func TestHandleCompletionsEndToEnd(t *testing.T) {
iom := agentIO.NewIOManager()
// 启动一个最小 Agent使用 echoProvider不调真实 LLM
memDB, err := memory.NewGraphDB(t.TempDir() + "/graph.db")
if err != nil {
t.Fatalf("NewGraphDB: %v", err)
}
defer memDB.Close()
pm := agentAPI.NewProviderManager()
pm.Register("echo", &echoProvider{name: "echo"})
agent := agentCore.New(agentCore.AgentConfig{
ID: "test",
SystemPrompt: "你是测试助手",
Provider: &echoProvider{name: "echo"},
ProviderManager: pm,
IO: iom,
Memory: memDB,
Indexer: nil,
ContextSavePath: "",
})
agent.Start()
defer agent.Stop()
sup := supervisor.New(&types.Config{
Daemon: types.DaemonConfig{
CheckInterval: time.Minute,
HeartbeatInterval: 30 * time.Second,
},
})
sup.Start()
defer sup.Shutdown()
s := testSDK(sdk.SDKConfig{
Supervisor: supervisor.NewSDKAdapter(sup),
IOManager: iom,
Config: sdk.NewConfig(&types.Config{}),
})
h := NewHandler(s)
t.Run("POST_chat_completions_returns_echo", func(t *testing.T) {
body := `{"model":"test","messages":[{"role":"user","content":"你好"}]}`
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.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
choices, ok := resp["choices"].([]interface{})
if !ok || len(choices) == 0 {
t.Fatal("expected choices")
}
msg, ok := choices[0].(map[string]interface{})["message"].(map[string]interface{})
if !ok {
t.Fatal("expected message")
}
if msg["content"] != "echo: 你好" {
t.Fatalf("expected 'echo: 你好', got '%v'", msg["content"])
}
})
t.Run("POST_chat_completions_no_iom_returns_503", func(t *testing.T) {
s2 := testSDK(sdk.SDKConfig{
Supervisor: supervisor.NewSDKAdapter(sup),
})
h2 := NewHandler(s2)
body := `{"messages":[{"role":"user","content":"hi"}]}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h2.handleOpenAICompletions(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", w.Code)
}
})
t.Run("POST_chat_completions_400_on_no_messages", func(t *testing.T) {
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.Fatalf("expected 400, got %d", w.Code)
}
})
t.Run("POST_chat_completions_400_on_non_user_last_msg", func(t *testing.T) {
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.Fatalf("expected 400, got %d", w.Code)
}
})
}
// ===== client_msg_id 去重测试(防 GUI 断线重连消息重放)=====
func TestHandleChatClientMsgIDDedup(t *testing.T) {
iom := agentIO.NewIOManager()
memDB, err := memory.NewGraphDB(t.TempDir() + "/graph.db")
if err != nil {
t.Fatalf("NewGraphDB: %v", err)
}
defer memDB.Close()
pm := agentAPI.NewProviderManager()
pm.Register("echo", &echoProvider{name: "echo"})
agent := agentCore.New(agentCore.AgentConfig{
ID: "test",
SystemPrompt: "你是测试助手",
Provider: &echoProvider{name: "echo"},
ProviderManager: pm,
IO: iom,
Memory: memDB,
})
agent.Start()
defer agent.Stop()
sup := supervisor.New(&types.Config{
Daemon: types.DaemonConfig{
CheckInterval: time.Minute,
HeartbeatInterval: 30 * time.Second,
},
})
sup.Start()
defer sup.Shutdown()
s := testSDK(sdk.SDKConfig{
Supervisor: supervisor.NewSDKAdapter(sup),
IOManager: iom,
Config: sdk.NewConfig(&types.Config{}),
})
h := NewHandler(s)
t.Run("same_client_msg_id_replay_returns_cached_response", func(t *testing.T) {
body := `{"message":"你好","client_msg_id":"msg-abc-123"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/chat", strings.NewReader(body))
w := httptest.NewRecorder()
h.handleChat(w, req)
if w.Code != http.StatusOK {
t.Fatalf("first request: expected 200, got %d: %s", w.Code, w.Body.String())
}
var first map[string]interface{}
json.NewDecoder(w.Body).Decode(&first)
if first["response"] != "echo: 你好" {
t.Fatalf("expected echo response, got %v", first["response"])
}
// 同 ID 重放:应直接复用首次结果,不重复注入 agent
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/chat", strings.NewReader(body))
w2 := httptest.NewRecorder()
h.handleChat(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("replay: expected 200, got %d: %s", w2.Code, w2.Body.String())
}
var second map[string]interface{}
json.NewDecoder(w2.Body).Decode(&second)
if second["response"] != "echo: 你好" {
t.Fatalf("replay expected same response, got %v", second["response"])
}
if second["deduplicated"] != true {
t.Fatalf("replay expected deduplicated=true, got %v", second["deduplicated"])
}
})
t.Run("different_client_msg_id_processed_normally", func(t *testing.T) {
body := `{"message":"第二条","client_msg_id":"msg-def-456"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/chat", strings.NewReader(body))
w := httptest.NewRecorder()
h.handleChat(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.NewDecoder(w.Body).Decode(&resp)
if resp["deduplicated"] == true {
t.Fatal("new msg id should not be deduplicated")
}
})
t.Run("no_client_msg_id_backward_compatible", func(t *testing.T) {
body := `{"message":"旧客户端消息"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/chat", strings.NewReader(body))
w := httptest.NewRecorder()
h.handleChat(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
})
}
// ===== agent 核心层内容级去重测试 =====
func TestAgentDuplicateInputDedup(t *testing.T) {
iom := agentIO.NewIOManager()
memDB, err := memory.NewGraphDB(t.TempDir() + "/graph.db")
if err != nil {
t.Fatalf("NewGraphDB: %v", err)
}
defer memDB.Close()
pm := agentAPI.NewProviderManager()
pm.Register("echo", &echoProvider{name: "echo"})
agent := agentCore.New(agentCore.AgentConfig{
ID: "test",
SystemPrompt: "你是测试助手",
Provider: &echoProvider{name: "echo"},
ProviderManager: pm,
IO: iom,
Memory: memDB,
})
agent.Start()
defer agent.Stop()
// 直接验证 isDuplicateInput 行为
if agent.IsDuplicateInput("webui", "重复消息") {
t.Fatal("first input should not be duplicate")
}
if !agent.IsDuplicateInput("webui", "重复消息") {
t.Fatal("immediate same-content same-source should be duplicate")
}
if agent.IsDuplicateInput("webui", "不同消息") {
t.Fatal("different content should not be duplicate")
}
if agent.IsDuplicateInput("qq", "重复消息") {
t.Fatal("different source should not be duplicate")
}
}