mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
2784223ad5
Default the sample web search provider to auto, route Sogou vs DuckDuckGo dynamically based on query/UI language, and sync frontend language changes back to the backend so Current Service and runtime selection stay aligned.
416 lines
13 KiB
Go
416 lines
13 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"runtime"
|
|
"testing"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
picotools "github.com/sipeed/picoclaw/pkg/tools"
|
|
)
|
|
|
|
func TestHandleListTools(t *testing.T) {
|
|
configPath, cleanup := setupOAuthTestEnv(t)
|
|
defer cleanup()
|
|
|
|
cfg, err := config.LoadConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig() error = %v", err)
|
|
}
|
|
cfg.Tools.ReadFile.Enabled = true
|
|
cfg.Tools.WriteFile.Enabled = false
|
|
cfg.Tools.Cron.Enabled = true
|
|
cfg.Tools.FindSkills.Enabled = true
|
|
cfg.Tools.Skills.Enabled = true
|
|
cfg.Tools.Spawn.Enabled = true
|
|
cfg.Tools.Subagent.Enabled = false
|
|
cfg.Tools.MCP.Enabled = true
|
|
cfg.Tools.MCP.Discovery.Enabled = true
|
|
cfg.Tools.MCP.Discovery.UseRegex = true
|
|
cfg.Tools.MCP.Discovery.UseBM25 = false
|
|
err = config.SaveConfig(configPath, cfg)
|
|
if err != nil {
|
|
t.Fatalf("SaveConfig() error = %v", err)
|
|
}
|
|
|
|
h := NewHandler(configPath)
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/tools", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
|
|
var resp toolSupportResponse
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("Unmarshal() error = %v", err)
|
|
}
|
|
gotTools := make(map[string]toolSupportItem, len(resp.Tools))
|
|
for _, tool := range resp.Tools {
|
|
gotTools[tool.Name] = tool
|
|
}
|
|
if gotTools["read_file"].Status != "enabled" {
|
|
t.Fatalf("read_file status = %q, want enabled", gotTools["read_file"].Status)
|
|
}
|
|
if gotTools["write_file"].Status != "disabled" {
|
|
t.Fatalf("write_file status = %q, want disabled", gotTools["write_file"].Status)
|
|
}
|
|
if gotTools["cron"].Status != "enabled" {
|
|
t.Fatalf("cron status = %q, want enabled", gotTools["cron"].Status)
|
|
}
|
|
if gotTools["spawn"].Status != "blocked" || gotTools["spawn"].ReasonCode != "requires_subagent" {
|
|
t.Fatalf("spawn = %#v, want blocked/requires_subagent", gotTools["spawn"])
|
|
}
|
|
if gotTools["find_skills"].Status != "enabled" {
|
|
t.Fatalf("find_skills status = %q, want enabled", gotTools["find_skills"].Status)
|
|
}
|
|
if gotTools["tool_search_tool_regex"].Status != "enabled" {
|
|
t.Fatalf("tool_search_tool_regex status = %q, want enabled", gotTools["tool_search_tool_regex"].Status)
|
|
}
|
|
if gotTools["tool_search_tool_regex"].ConfigKey != "mcp.discovery.use_regex" {
|
|
t.Fatalf(
|
|
"tool_search_tool_regex config_key = %q, want mcp.discovery.use_regex",
|
|
gotTools["tool_search_tool_regex"].ConfigKey,
|
|
)
|
|
}
|
|
if gotTools["tool_search_tool_bm25"].Status != "disabled" {
|
|
t.Fatalf("tool_search_tool_bm25 status = %q, want disabled", gotTools["tool_search_tool_bm25"].Status)
|
|
}
|
|
if gotTools["tool_search_tool_bm25"].ConfigKey != "mcp.discovery.use_bm25" {
|
|
t.Fatalf(
|
|
"tool_search_tool_bm25 config_key = %q, want mcp.discovery.use_bm25",
|
|
gotTools["tool_search_tool_bm25"].ConfigKey,
|
|
)
|
|
}
|
|
if runtime.GOOS == "linux" {
|
|
if gotTools["i2c"].Status != "disabled" {
|
|
t.Fatalf("i2c status = %q, want disabled on linux when config is off", gotTools["i2c"].Status)
|
|
}
|
|
} else {
|
|
cfg.Tools.I2C.Enabled = true
|
|
cfg.Tools.SPI.Enabled = true
|
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
|
t.Fatalf("SaveConfig() error = %v", err)
|
|
}
|
|
|
|
rec = httptest.NewRecorder()
|
|
req = httptest.NewRequest(http.MethodGet, "/api/tools", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("Unmarshal() error = %v", err)
|
|
}
|
|
gotTools = make(map[string]toolSupportItem, len(resp.Tools))
|
|
for _, tool := range resp.Tools {
|
|
gotTools[tool.Name] = tool
|
|
}
|
|
|
|
if gotTools["i2c"].Status != "blocked" || gotTools["i2c"].ReasonCode != "requires_linux" {
|
|
t.Fatalf("i2c = %#v, want blocked/requires_linux", gotTools["i2c"])
|
|
}
|
|
if gotTools["spi"].Status != "blocked" || gotTools["spi"].ReasonCode != "requires_linux" {
|
|
t.Fatalf("spi = %#v, want blocked/requires_linux", gotTools["spi"])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandleUpdateToolState(t *testing.T) {
|
|
configPath, cleanup := setupOAuthTestEnv(t)
|
|
defer cleanup()
|
|
|
|
cfg, err := config.LoadConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig() error = %v", err)
|
|
}
|
|
cfg.Tools.Spawn.Enabled = false
|
|
cfg.Tools.Subagent.Enabled = false
|
|
cfg.Tools.Cron.Enabled = false
|
|
cfg.Tools.MCP.Enabled = false
|
|
cfg.Tools.MCP.Discovery.Enabled = false
|
|
cfg.Tools.MCP.Discovery.UseRegex = false
|
|
err = config.SaveConfig(configPath, cfg)
|
|
if err != nil {
|
|
t.Fatalf("SaveConfig() error = %v", err)
|
|
}
|
|
|
|
h := NewHandler(configPath)
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(
|
|
http.MethodPut,
|
|
"/api/tools/spawn/state",
|
|
bytes.NewBufferString(`{"enabled":true}`),
|
|
)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("spawn status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
|
|
rec2 := httptest.NewRecorder()
|
|
req2 := httptest.NewRequest(
|
|
http.MethodPut,
|
|
"/api/tools/tool_search_tool_regex/state",
|
|
bytes.NewBufferString(`{"enabled":true}`),
|
|
)
|
|
req2.Header.Set("Content-Type", "application/json")
|
|
mux.ServeHTTP(rec2, req2)
|
|
if rec2.Code != http.StatusOK {
|
|
t.Fatalf("regex status = %d, want %d, body=%s", rec2.Code, http.StatusOK, rec2.Body.String())
|
|
}
|
|
|
|
rec3 := httptest.NewRecorder()
|
|
req3 := httptest.NewRequest(
|
|
http.MethodPut,
|
|
"/api/tools/cron/state",
|
|
bytes.NewBufferString(`{"enabled":true}`),
|
|
)
|
|
req3.Header.Set("Content-Type", "application/json")
|
|
mux.ServeHTTP(rec3, req3)
|
|
if rec3.Code != http.StatusOK {
|
|
t.Fatalf("cron status = %d, want %d, body=%s", rec3.Code, http.StatusOK, rec3.Body.String())
|
|
}
|
|
|
|
updated, err := config.LoadConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig(updated) error = %v", err)
|
|
}
|
|
if !updated.Tools.Spawn.Enabled || !updated.Tools.Subagent.Enabled {
|
|
t.Fatalf("spawn/subagent should both be enabled: %#v", updated.Tools)
|
|
}
|
|
if !updated.Tools.MCP.Enabled || !updated.Tools.MCP.Discovery.Enabled || !updated.Tools.MCP.Discovery.UseRegex {
|
|
t.Fatalf("mcp regex discovery should be enabled: %#v", updated.Tools.MCP)
|
|
}
|
|
if !updated.Tools.Cron.Enabled {
|
|
t.Fatalf("cron should be enabled: %#v", updated.Tools.Cron)
|
|
}
|
|
}
|
|
|
|
func TestHandleGetWebSearchConfig(t *testing.T) {
|
|
configPath, cleanup := setupOAuthTestEnv(t)
|
|
defer cleanup()
|
|
|
|
cfg, err := config.LoadConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig() error = %v", err)
|
|
}
|
|
cfg.Tools.Web.Provider = "sogou"
|
|
cfg.Tools.Web.Sogou.Enabled = true
|
|
cfg.Tools.Web.Sogou.MaxResults = 6
|
|
cfg.Tools.Web.Brave.Enabled = true
|
|
cfg.Tools.Web.Brave.SetAPIKey("brave-test-key")
|
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
|
t.Fatalf("SaveConfig() error = %v", err)
|
|
}
|
|
|
|
h := NewHandler(configPath)
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/tools/web-search-config", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
|
|
var resp webSearchConfigResponse
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("Unmarshal() error = %v", err)
|
|
}
|
|
if resp.Provider != "sogou" {
|
|
t.Fatalf("provider = %q, want sogou", resp.Provider)
|
|
}
|
|
if resp.CurrentService != "sogou" {
|
|
t.Fatalf("current_service = %q, want sogou", resp.CurrentService)
|
|
}
|
|
if !resp.Settings["brave"].APIKeySet {
|
|
t.Fatalf("brave api_key_set should be true: %#v", resp.Settings["brave"])
|
|
}
|
|
}
|
|
|
|
func TestHandleUpdateWebSearchConfig(t *testing.T) {
|
|
configPath, cleanup := setupOAuthTestEnv(t)
|
|
defer cleanup()
|
|
|
|
cfg, err := config.LoadConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig() error = %v", err)
|
|
}
|
|
cfg.Tools.Web.Brave.SetAPIKeys([]string{"brave-old-1", "brave-old-2"})
|
|
if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
|
|
t.Fatalf("SaveConfig() error = %v", saveErr)
|
|
}
|
|
|
|
h := NewHandler(configPath)
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(
|
|
http.MethodPut,
|
|
"/api/tools/web-search-config",
|
|
bytes.NewBufferString(`{
|
|
"provider":"brave",
|
|
"prefer_native":false,
|
|
"proxy":"http://127.0.0.1:7890",
|
|
"settings":{
|
|
"sogou":{"enabled":true,"max_results":4},
|
|
"brave":{"enabled":true,"max_results":7,"api_key":"brave-new-key"},
|
|
"duckduckgo":{"enabled":false,"max_results":3}
|
|
}
|
|
}`),
|
|
)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
|
|
updated, err := config.LoadConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig() error = %v", err)
|
|
}
|
|
if updated.Tools.Web.Provider != "brave" {
|
|
t.Fatalf("provider = %q, want brave", updated.Tools.Web.Provider)
|
|
}
|
|
if updated.Tools.Web.PreferNative {
|
|
t.Fatal("prefer_native should be false after update")
|
|
}
|
|
if updated.Tools.Web.Proxy != "http://127.0.0.1:7890" {
|
|
t.Fatalf("proxy = %q", updated.Tools.Web.Proxy)
|
|
}
|
|
if !updated.Tools.Web.Sogou.Enabled || updated.Tools.Web.Sogou.MaxResults != 4 {
|
|
t.Fatalf("sogou config not updated: %#v", updated.Tools.Web.Sogou)
|
|
}
|
|
if !updated.Tools.Web.Brave.Enabled || updated.Tools.Web.Brave.MaxResults != 7 {
|
|
t.Fatalf("brave config not updated: %#v", updated.Tools.Web.Brave)
|
|
}
|
|
if updated.Tools.Web.Brave.APIKey() != "brave-new-key" {
|
|
t.Fatalf("brave api key not updated")
|
|
}
|
|
}
|
|
|
|
func TestHandleUpdateWebSearchConfig_PreservesAndReplacesMultiKeys(t *testing.T) {
|
|
configPath, cleanup := setupOAuthTestEnv(t)
|
|
defer cleanup()
|
|
|
|
cfg, err := config.LoadConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig() error = %v", err)
|
|
}
|
|
cfg.Tools.Web.Brave.SetAPIKeys([]string{"brave-old-1", "brave-old-2"})
|
|
if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
|
|
t.Fatalf("SaveConfig() error = %v", saveErr)
|
|
}
|
|
|
|
h := NewHandler(configPath)
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(
|
|
http.MethodPut,
|
|
"/api/tools/web-search-config",
|
|
bytes.NewBufferString(`{
|
|
"provider":"auto",
|
|
"prefer_native":true,
|
|
"proxy":"",
|
|
"settings":{
|
|
"brave":{"enabled":true,"max_results":7}
|
|
}
|
|
}`),
|
|
)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
|
|
updated, err := config.LoadConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig() error = %v", err)
|
|
}
|
|
if got := updated.Tools.Web.Brave.APIKeys.Values(); len(got) != 2 ||
|
|
got[0] != "brave-old-1" || got[1] != "brave-old-2" {
|
|
t.Fatalf("brave api keys should be preserved, got %#v", got)
|
|
}
|
|
|
|
rec = httptest.NewRecorder()
|
|
req = httptest.NewRequest(
|
|
http.MethodPut,
|
|
"/api/tools/web-search-config",
|
|
bytes.NewBufferString(`{
|
|
"provider":"auto",
|
|
"prefer_native":true,
|
|
"proxy":"",
|
|
"settings":{
|
|
"brave":{"enabled":true,"max_results":7,"api_keys":["brave-new-1","brave-new-2","brave-new-1"]}
|
|
}
|
|
}`),
|
|
)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
|
|
updated, err = config.LoadConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig() error = %v", err)
|
|
}
|
|
if got := updated.Tools.Web.Brave.APIKeys.Values(); len(got) != 2 ||
|
|
got[0] != "brave-new-1" || got[1] != "brave-new-2" {
|
|
t.Fatalf("brave api keys should be replaced by api_keys, got %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestResolveCurrentWebSearchProvider_PrefersConfiguredProvidersBeforeSogou(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Tools.Web.Provider = "auto"
|
|
cfg.Tools.Web.Sogou.Enabled = true
|
|
cfg.Tools.Web.Brave.Enabled = true
|
|
cfg.Tools.Web.Brave.SetAPIKey("brave-test-key")
|
|
|
|
if got := resolveCurrentWebSearchProvider(cfg); got != "brave" {
|
|
t.Fatalf("resolveCurrentWebSearchProvider() = %q, want brave", got)
|
|
}
|
|
}
|
|
|
|
func TestResolveCurrentWebSearchProvider_UsesPreferredLanguageForSogouAndDuckDuckGo(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Tools.Web.Provider = "auto"
|
|
cfg.Tools.Web.Sogou.Enabled = true
|
|
cfg.Tools.Web.DuckDuckGo.Enabled = true
|
|
|
|
picotools.SetPreferredWebSearchLanguage("en")
|
|
t.Cleanup(func() {
|
|
picotools.SetPreferredWebSearchLanguage("")
|
|
})
|
|
|
|
if got := resolveCurrentWebSearchProvider(cfg); got != "duckduckgo" {
|
|
t.Fatalf("resolveCurrentWebSearchProvider() = %q, want duckduckgo", got)
|
|
}
|
|
|
|
picotools.SetPreferredWebSearchLanguage("zh")
|
|
if got := resolveCurrentWebSearchProvider(cfg); got != "sogou" {
|
|
t.Fatalf("resolveCurrentWebSearchProvider() = %q, want sogou", got)
|
|
}
|
|
}
|