// config_mcp_test.go - regression guards for engine.Config.MCPServers wiring. // // Before v0.1.0-alpha, Config.MCPServers was defined on the SDK-facing struct // but never read anywhere in engine.New -- a dead field that consumers (e.g. // the flysafe agent team) silently hit by writing "// this doesn't work" code // around it. The fix in this commit wires startConfigMCPServers into New. The // tests below lock that wiring in place so any future refactor that stops // reading the field fails CI immediately. // // 在 v0.1.0-alpha 之前, Config.MCPServers 是 SDK 面向的字段但 engine.New // 从未读过它 -- 这是死字段, 消费方 (e.g. 隔壁 flysafe agent 团队) 绕路写 // 代码才暴露. 本 commit 把 startConfigMCPServers wire 进 New. 下面的测试 // 把这条 wiring 锁住, 未来任何 refactor 如果停止读此字段会被 CI 直接拦下. package engine import ( "strings" "testing" "git.flytoex.net/yuanwei/flyto-agent/pkg/config" ) // TestConfigMCPServerKey_Roundtrip verifies configMCPServerKey and // parseConfigMCPServerKey round-trip, and that the "config." / "plugin." // prefixes stay disjoint so the two subsystems cannot step on each other. // // TestConfigMCPServerKey_Roundtrip 验证 configMCPServerKey 与 // parseConfigMCPServerKey 互逆, 且 "config." / "plugin." 前缀相互不吞, // 两个子系统永远不会踩到对方的 key. func TestConfigMCPServerKey_Roundtrip(t *testing.T) { cases := []string{"foo", "a.b.c", "", "config", "plugin"} for _, name := range cases { key := configMCPServerKey(name) if !strings.HasPrefix(key, configMCPServerKeyPrefix) { t.Errorf("configMCPServerKey(%q) = %q, missing prefix %q", name, key, configMCPServerKeyPrefix) } got, ok := parseConfigMCPServerKey(key) if !ok { t.Errorf("parseConfigMCPServerKey(%q) ok=false, want true", key) } if got != name { t.Errorf("parseConfigMCPServerKey(%q) = %q, want %q", key, got, name) } } // A plugin-owned key must NOT parse as config-owned. // plugin-owned key 不能被 config parser 误认. if _, ok := parseConfigMCPServerKey("plugin.somePlugin.someServer"); ok { t.Errorf("parseConfigMCPServerKey matched plugin-owned key") } // A config-owned key must NOT parse as plugin-owned. // config-owned key 也不能被 plugin parser 误认. if _, _, ok := parsePluginMCPServerKey(configMCPServerKey("foo")); ok { t.Errorf("parsePluginMCPServerKey matched config-owned key") } } // TestConfigMCPServers_FieldIsRead is the primary regression guard: it // proves engine.New actually reads Config.MCPServers and attempts each // entry. We avoid a real MCP subprocess by pointing Command at nonexistent // paths -- the connect fails fast, and the observer records one // config_mcp_connect_failed event per entry. If the field silently stops // being read in the future, this count drops to zero and the test fails. // // TestConfigMCPServers_FieldIsRead 是核心 regression guard: 证明 engine.New // 真的读了 Config.MCPServers 且对每个 entry 做了 attempt. 用不存在的 // Command 路径让 connect 快速失败, 避开 fake subprocess 依赖; observer 会 // 为每个 entry 收到一次 config_mcp_connect_failed. 未来若字段再次被静默 // 不读, 这个计数会掉到 0, test 会 fail. func TestConfigMCPServers_FieldIsRead(t *testing.T) { obs := &MockObserver{} cfg := testConfig() cfg.Observer = obs cfg.MCPServers = []config.MCPServerConfig{ {Name: "bogus-a", Transport: "stdio", Command: "/nonexistent/mcp-a"}, {Name: "bogus-b", Transport: "stdio", Command: "/nonexistent/mcp-b"}, } eng, err := New(cfg) if err != nil { t.Fatalf("engine.New: %v", err) } defer eng.Close() if got := obs.EventCount("config_mcp_connect_failed"); got != 2 { t.Errorf("config_mcp_connect_failed count: want 2, got %d", got) } last := obs.LastEvent("config_mcp_servers_started") if last == nil { t.Fatal("config_mcp_servers_started event missing (startConfigMCPServers never ran?)") } if sc, _ := last.Data["server_count"].(int); sc != 0 { t.Errorf("server_count: want 0 (both failed), got %v", last.Data["server_count"]) } } // TestConfigMCPServers_EmptyNameSkipped verifies an entry with an empty // Name produces a config_mcp_skipped observer event and does not continue // into ConnectOne (which would yield a misleading "config." key with // nothing after the dot). // // TestConfigMCPServers_EmptyNameSkipped 验证空 Name 的 entry 会触发 // config_mcp_skipped 事件, 且不会继续进 ConnectOne (否则会生成 "config." // 这种 . 后面什么都没有的误导性 key). func TestConfigMCPServers_EmptyNameSkipped(t *testing.T) { obs := &MockObserver{} cfg := testConfig() cfg.Observer = obs cfg.MCPServers = []config.MCPServerConfig{ {Name: "", Transport: "stdio", Command: "/bin/true"}, } eng, err := New(cfg) if err != nil { t.Fatalf("engine.New: %v", err) } defer eng.Close() if got := obs.EventCount("config_mcp_skipped"); got != 1 { t.Errorf("config_mcp_skipped count: want 1, got %d", got) } if got := obs.EventCount("config_mcp_connect_failed"); got != 0 { t.Errorf("config_mcp_connect_failed should be 0 for empty-Name entry, got %d", got) } } // TestConfigMCPServers_EmptySliceIsNoop verifies the common path -- SDK // consumers who don't use this field see no overhead and no observer noise. // // TestConfigMCPServers_EmptySliceIsNoop 验证通用路径: 不用此字段的 SDK // 消费方没任何开销, observer 无噪音事件. func TestConfigMCPServers_EmptySliceIsNoop(t *testing.T) { obs := &MockObserver{} cfg := testConfig() cfg.Observer = obs // cfg.MCPServers left nil intentionally. eng, err := New(cfg) if err != nil { t.Fatalf("engine.New: %v", err) } defer eng.Close() for _, ev := range []string{ "config_mcp_connect_failed", "config_mcp_skipped", "config_mcp_servers_started", } { if got := obs.EventCount(ev); got != 0 { t.Errorf("%s count: want 0 for nil MCPServers, got %d", ev, got) } } }