package mcp import ( "context" "encoding/json" "testing" "github.com/sundynix/sundynix-shared/contract" "github.com/sundynix/sundynix-mcp-go/internal/rag" ) // kb_search 的两种返回形状必须泾渭分明: // - 不带 diag(生产调用:dispatcher、Agent 工具链)→ 裸 JSON 数组,契约不能变; // - 带 diag(只有检索试验台会传)→ {hits, routes} 对象,带每一路的诊断。 // // 把它钉住是因为这层是"改可观测性顺手改坏生产"的高风险位置:diag 分支若写成无条件 // 生效,所有调用方拿到的就从数组变成了对象,工具链会静默解析失败。 // newFulltextOnlyGateway 造一个只有全文路可用的 Gateway:无 embedding、无 Milvus、无 Neo4j。 // 这正是 mcp-go 先于 gateway 启动(控制面配置尚未下发)时的真实状态。 func newFulltextOnlyGateway(t *testing.T) *Gateway { t.Helper() t.Setenv("BLEVE_PATH", t.TempDir()+"/bleve") e := rag.Open(context.Background(), rag.Config{}) // 不给 Milvus/Neo4j/embedding return &Gateway{rag: e} } func TestKbSearch_ProductionShapeIsBareArray(t *testing.T) { g := newFulltextOnlyGateway(t) res := g.kbSearch(context.Background(), &contract.ToolCall{ Tool: "kb_search", Args: map[string]any{"kb": "k1", "q": "星间链路"}, }) if !res.OK { t.Fatalf("不应失败: %s", res.Error) } var arr []map[string]any if err := json.Unmarshal([]byte(res.Content), &arr); err != nil { t.Fatalf("生产调用必须返回裸数组(工具链据此解析),得 %q: %v", res.Content, err) } } func TestKbSearch_DiagShapeCarriesRoutes(t *testing.T) { g := newFulltextOnlyGateway(t) res := g.kbSearch(context.Background(), &contract.ToolCall{ Tool: "kb_search", Args: map[string]any{"kb": "k1", "q": "星间链路", "diag": true, "mode": "hybrid"}, }) if !res.OK { t.Fatalf("不应失败: %s", res.Error) } var out struct { Hits []map[string]any `json:"hits"` Routes []rag.RouteDiag `json:"routes"` } if err := json.Unmarshal([]byte(res.Content), &out); err != nil { t.Fatalf("diag 调用应返回 {hits,routes} 对象,得 %q: %v", res.Content, err) } if len(out.Routes) != 3 { t.Fatalf("应有 vector/fulltext/graph 三路诊断,得 %d 条: %+v", len(out.Routes), out.Routes) } for _, r := range out.Routes { if r.Status == "" { t.Fatalf("每一路都必须有 status,否则界面仍无法解释这个空: %+v", r) } // 没配置的路必须给出原因——这正是当初排查半天的痛点。 if r.Status == "disabled" && r.Note == "" { t.Fatalf("%s 路 disabled 却没写原因", r.Name) } } } // 未配置 embedding/Milvus 时不能整体短路返回空:以前用 rag.Ready() 当总闸, // 连不依赖它们的全文/图谱路一起毙掉,表现为"知识库什么都搜不到"且不报错。 func TestKbSearch_NotShortCircuitedByReadiness(t *testing.T) { g := newFulltextOnlyGateway(t) if g.rag.Ready() { t.Fatal("前提不成立:该引擎本应 not ready") } res := g.kbSearch(context.Background(), &contract.ToolCall{ Tool: "kb_search", Args: map[string]any{"kb": "k1", "q": "星间链路", "diag": true}, }) var out struct { Routes []rag.RouteDiag `json:"routes"` } if err := json.Unmarshal([]byte(res.Content), &out); err != nil { t.Fatalf("not ready 时也应正常走到各路诊断,得 %q", res.Content) } if len(out.Routes) == 0 { t.Fatal("not ready 时被总闸短路了(返回空数组而非逐路诊断)") } }