feat(search): web-search 接线本机 SearXNG 兜底 + 显式开关(她不再拿一把用不了的锤子)
主人报的故障:她调 web-search 必失败(no search endpoint configured)。根因两条:组合根从未注入 WithSearchEndpoint + 配置层根本没有搜索配置项。 - internal/config/websearch.go:WEB_SEARCH_ENDPOINT(默认本机兜底 http://127.0.0.1:38471)+ WEB_SEARCH_TIMEOUT;ResolveEndpoint() 三态裁决(可用/显式关闭/非法),非法值启动即报错 - cmd/cyrene/wire_io_tools.go:组合根唯一的 io 选项装配点;端点不可用 ⇒ WithoutSearch() + 带 source 的 WARN(只记键名与原因,不打印 URL 值) - plugins/tools/io:新增 WithoutSearch()(**根本不进注册面** ⇒ 模型看不见)+ WithSearchTimeout();协议解析逻辑一字未动 - 隐蔽坑(额外查出并修):SearXNG 对 /?q= 回 308 → /search,重定向那一跳走**共享 SSRF 白名单** ⇒ 只注入端点照样被拦。改为**只给 web-search 一件**加宽自己的客户端与白名单,共享白名单与 http/web-fetch 客户端一字未动(有测试钉住它仍拒绝本机网关地址) - .env.example 新增 2 键 + 注释说明「空串/off/none = 不注册」 - 真机 e2e 两次绿(SearXNG 返回 9 条);变异:不注入端点 / 关闭态仍注册 / 默认值改错 / 不并入端点主机 / 重定向用共享白名单 ⇒ 5 条全红
This commit is contained in:
1 parent
8e56163418
commit
8e6c77ad10
11 files changed
+1137
-20
No files matched your search
@@ -0,0 +1,66 @@
|
||||
package main
|
||||
|
||||
// 本文件装配 io 工具集(plugins/tools/io)的选项:知识检索器、日志,以及
|
||||
// **web-search 的端点与超时**(切片 WS1-A)。
|
||||
//
|
||||
// 为什么单独成文件而不是塞在 wire_runtime.go 里:那条装配线本就接近文件行数上限,
|
||||
// 而本片新增的裁决(端点关闭/非法 ⇒ 工具不进注册面)需要一个能单测的落点。
|
||||
//
|
||||
// 三条口径:
|
||||
//
|
||||
// 1. **端点默认开箱即用**:`WEB_SEARCH_ENDPOINT` 未配置时取 config 标签默认值
|
||||
// (本机兜底 http://127.0.0.1:38471,配套服务见 scripts/searxng/settings.yml)。
|
||||
// 2. **拿不到可用端点就不注册工具**(诚意要求,主人 2026-10-05 报障的核心诉求):
|
||||
// 端点显式关闭(空串 / off / none)或不是 http(s) 绝对地址时,web-search
|
||||
// **不进注册面** —— 模型根本看不见它,而不是看得见、一调就报错。
|
||||
// 3. **超时不另造默认值**:`WEB_SEARCH_TIMEOUT` 未配置(0)时沿用 io 包既有超时;
|
||||
// 负值是笔误,按未配置处理并记一条带 env 键名的 WARN(不静默)。
|
||||
|
||||
import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
||||
)
|
||||
|
||||
// web-search 的两个 env 键名:必须与 `.env.example` 逐字一致(AGENTS §10 的 E6 条目),
|
||||
// 由 wire_web_search_test.go 逐字核对。
|
||||
const (
|
||||
webSearchEndpointEnv = "WEB_SEARCH_ENDPOINT"
|
||||
webSearchTimeoutEnv = "WEB_SEARCH_TIMEOUT"
|
||||
)
|
||||
|
||||
// ioLogSource 是本文件日志的来源标签(项目规范 §10:禁止无来源裸日志)。
|
||||
const ioLogSource = "cmd/cyrene/wire_io_tools"
|
||||
|
||||
// ioToolOptions 组装 io 工具集的全部选项。wire_runtime 把它原样交给 iotools.New,
|
||||
// 因此「注册面里有哪些 io 工具」只由这一处决定。
|
||||
func (a *app) ioToolOptions() []iotools.Option {
|
||||
opts := []iotools.Option{
|
||||
iotools.WithKnowledgeRetriever(a.retriever),
|
||||
iotools.WithLogger(a.log),
|
||||
}
|
||||
return append(opts, a.webSearchOptions()...)
|
||||
}
|
||||
|
||||
// webSearchOptions 把 web-search 的配置翻译成 io 选项。端点裁决见
|
||||
// config.WebSearchConfig.ResolveEndpoint(关闭 / 非法 / 可用三种去处都在那里定死)。
|
||||
func (a *app) webSearchOptions() []iotools.Option {
|
||||
log := logger.WithSource(a.log, ioLogSource)
|
||||
decision := a.cfg.WebSearch.ResolveEndpoint()
|
||||
if !decision.Enabled {
|
||||
// 端点的值**绝不写进日志**(URL 里可能带凭据),只记键名与原因。
|
||||
log.Warn("web-search tool not registered",
|
||||
"env", webSearchEndpointEnv, "reason", decision.Reason,
|
||||
"effect", "the model will not see a web-search tool at all")
|
||||
return []iotools.Option{iotools.WithoutSearch()}
|
||||
}
|
||||
|
||||
opts := []iotools.Option{iotools.WithSearchEndpoint(decision.URL)}
|
||||
switch {
|
||||
case a.cfg.WebSearch.Timeout > 0:
|
||||
opts = append(opts, iotools.WithSearchTimeout(a.cfg.WebSearch.Timeout))
|
||||
case a.cfg.WebSearch.Timeout < 0:
|
||||
log.Warn("web-search timeout is negative, treated as unset",
|
||||
"env", webSearchTimeoutEnv, "effect", "io package default timeout applies")
|
||||
}
|
||||
return opts
|
||||
}
|
||||
@@ -159,7 +159,9 @@ func (a *app) wireRuntime() error {
|
||||
if err := basic.Register(a.caps); err != nil {
|
||||
return fmt.Errorf("register basic tools: %w", err)
|
||||
}
|
||||
ioTools, ioErr := iotools.New(a.cfg, iotools.WithKnowledgeRetriever(a.retriever), iotools.WithLogger(a.log))
|
||||
// io 工具集的选项(含 web-search 的端点与超时裁决)在 wire_io_tools.go 一处组装:
|
||||
// 端点不可用时它交出 WithoutSearch(),使 web-search **不进注册面**(WS1-A)。
|
||||
ioTools, ioErr := iotools.New(a.cfg, a.ioToolOptions()...)
|
||||
if ioErr != nil {
|
||||
return fmt.Errorf("build io tools: %w", ioErr)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
||||
)
|
||||
|
||||
// 本文件钉住切片 WS1-A 的组合根接线:`WEB_SEARCH_ENDPOINT` / `WEB_SEARCH_TIMEOUT`
|
||||
// 经 `a.ioToolOptions()` 传进 io 工具集,端点不可用时 web-search **不进注册面**。
|
||||
//
|
||||
// 断言落在**注册表**上(`toolNames`,即模型真正看到的那一面),而不是源码字符串 ——
|
||||
// 源码接缝只作为最后一段的防回归补充。
|
||||
|
||||
// wsTestEnvFile 写一份只含 JWT_SECRET(唯一的必填键)加若干自定义行的 .env,
|
||||
// 返回路径。走 config.Load 是为了让「未写这个键 ⇒ 用标签默认值」这条链真实发生。
|
||||
func wsTestEnvFile(t *testing.T, extra ...string) string {
|
||||
t.Helper()
|
||||
lines := append([]string{"JWT_SECRET=" + strings.Repeat("s", 40)}, extra...)
|
||||
path := filepath.Join(t.TempDir(), ".env")
|
||||
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
|
||||
t.Fatalf("write .env: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// wsLoadConfig 按给定 .env 行加载配置(进程环境优先级更高,由 config 层负责)。
|
||||
func wsLoadConfig(t *testing.T, extra ...string) *config.Config {
|
||||
t.Helper()
|
||||
cfg, err := config.Load(wsTestEnvFile(t, extra...))
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load: %v", err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// wsSurfaceApp 用组合根的**同一条路**把 io 工具注册进能力面:ioToolOptions →
|
||||
// iotools.New → dispatcher.Register。logOut 非空时用它当日志出口(用于断言 WARN)。
|
||||
func wsSurfaceApp(t *testing.T, cfg *config.Config, logOut *bytes.Buffer) *app {
|
||||
t.Helper()
|
||||
caps := registry.New()
|
||||
dispatcher, err := tool.New(caps)
|
||||
if err != nil {
|
||||
t.Fatalf("tool.New: %v", err)
|
||||
}
|
||||
a := &app{cfg: cfg, log: testLog()}
|
||||
if logOut != nil {
|
||||
a.log = logger.New(logger.Options{Out: logOut, Level: "debug"})
|
||||
}
|
||||
set, err := iotools.New(cfg, a.ioToolOptions()...)
|
||||
if err != nil {
|
||||
t.Fatalf("iotools.New: %v", err)
|
||||
}
|
||||
for _, tl := range set {
|
||||
if rerr := dispatcher.Register(tl); rerr != nil {
|
||||
t.Fatalf("register %s: %v", tl.Name(), rerr)
|
||||
}
|
||||
}
|
||||
a.caps = caps
|
||||
a.dispatcher = dispatcher
|
||||
return a
|
||||
}
|
||||
|
||||
// wsHasTool 报告注册表(模型可见的工具面)里有没有这个名字。
|
||||
func wsHasTool(a *app, name string) bool {
|
||||
for _, n := range toolNames(a) {
|
||||
if n == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isOffSentinel 报告取值是否属于「显式关闭」的三个哨兵词(大小写/空白不敏感)。
|
||||
func isOffSentinel(raw string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "", "off", "none":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireIOTools_DefaultEndpointRegistersWebSearch 是判据 ①:**不写这个键**时
|
||||
// 默认值生效(本机兜底),web-search 出现在注册面里。
|
||||
func TestWireIOTools_DefaultEndpointRegistersWebSearch(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t)
|
||||
const want = "http://127.0.0.1:38471"
|
||||
if cfg.WebSearch.Endpoint != want {
|
||||
t.Fatalf("默认端点 = %q, want %q", cfg.WebSearch.Endpoint, want)
|
||||
}
|
||||
a := wsSurfaceApp(t, cfg, nil)
|
||||
if !wsHasTool(a, "web-search") {
|
||||
t.Fatalf("默认端点下 web-search 必须在注册面里,实际工具面 = %v", toolNames(a))
|
||||
}
|
||||
for _, name := range []string{"file", "image-crop", "http", "web-fetch", "knowledge", "document-read"} {
|
||||
if !wsHasTool(a, name) {
|
||||
t.Errorf("其余 io 工具不得受影响,但 %q 不在注册面里", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireIOTools_UnusableEndpointOmitsWebSearch 是判据 ②④:端点显式关闭
|
||||
// (空串 / off / none)或不是可用 http(s) 地址时,web-search **不进注册面** ——
|
||||
// 模型看不见它,且启动不报错(是「少一把工具」,不是「启动失败」)。同时必须留下
|
||||
// 一条带键名的 WARN:配了却没用,绝不静默。
|
||||
func TestWireIOTools_UnusableEndpointOmitsWebSearch(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
envLine string
|
||||
wantValue string
|
||||
}{
|
||||
{"显式留空", "WEB_SEARCH_ENDPOINT=", ""},
|
||||
{"off", "WEB_SEARCH_ENDPOINT=off", "off"},
|
||||
{"大写 NONE", "WEB_SEARCH_ENDPOINT=NONE", "NONE"},
|
||||
{"非 http scheme", "WEB_SEARCH_ENDPOINT=ftp://searx.example.com", "ftp://searx.example.com"},
|
||||
{"缺 scheme", "WEB_SEARCH_ENDPOINT=127.0.0.1:38471", "127.0.0.1:38471"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var buf bytes.Buffer
|
||||
cfg := wsLoadConfig(t, tc.envLine)
|
||||
if cfg.WebSearch.Endpoint != tc.wantValue {
|
||||
t.Fatalf("端点 = %q, want %q", cfg.WebSearch.Endpoint, tc.wantValue)
|
||||
}
|
||||
a := wsSurfaceApp(t, cfg, &buf)
|
||||
if wsHasTool(a, "web-search") {
|
||||
t.Fatalf("端点不可用时 web-search 不得进注册面,实际工具面 = %v", toolNames(a))
|
||||
}
|
||||
// 其余工具照常(关掉的只是一件工具,不是整套 io 能力)。
|
||||
for _, name := range []string{"file", "image-crop", "http", "web-fetch", "knowledge", "document-read"} {
|
||||
if !wsHasTool(a, name) {
|
||||
t.Errorf("%q 不得因 web-search 关闭而消失", name)
|
||||
}
|
||||
}
|
||||
// WARN 必须点出是哪个键、为什么(不打印 URL 本身,避免 URL 里带凭据)。
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, webSearchEndpointEnv) {
|
||||
t.Fatalf("日志里没有点出 %s:%q", webSearchEndpointEnv, out)
|
||||
}
|
||||
if !strings.Contains(out, "web-search tool not registered") {
|
||||
t.Fatalf("日志里没有说明 web-search 未注册:%q", out)
|
||||
}
|
||||
// 关闭哨兵词本身会出现在原因文案里("空串 / off / none"),故只对
|
||||
// 「像端点/主机」的那几个取值断言它没被打印。
|
||||
if !isOffSentinel(tc.wantValue) && tc.wantValue != "" && strings.Contains(out, tc.wantValue) {
|
||||
t.Fatalf("日志里出现了端点值本身(URL 可能带凭据,禁止打印):%q", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireIOTools_EndpointIsPassedThrough 是判据 ③:断言的不是「没报错」,而是
|
||||
// **配置里的端点真的被用了** —— 用一个可注入的 httptest 出口收请求,核对路径/查询串。
|
||||
func TestWireIOTools_EndpointIsPassedThrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
hits := make(chan *http.Request, 1)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits <- r
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"results":[{"title":"A","url":"http://a.example","content":"alpha"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := wsLoadConfig(t, "WEB_SEARCH_ENDPOINT="+srv.URL+"/search")
|
||||
a := wsSurfaceApp(t, cfg, nil)
|
||||
tl, err := a.caps.Get("web-search")
|
||||
if err != nil {
|
||||
t.Fatalf("web-search 不在注册面里: %v", err)
|
||||
}
|
||||
toolValue, ok := tl.Value.(interface {
|
||||
Execute(context.Context, any) (any, error)
|
||||
})
|
||||
if !ok {
|
||||
t.Fatalf("注册表里的值类型 = %T, 不是可执行工具", tl.Value)
|
||||
}
|
||||
if _, err := toolValue.Execute(context.Background(), map[string]any{"query": "昔涟"}); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
select {
|
||||
case r := <-hits:
|
||||
if r.URL.Path != "/search" {
|
||||
t.Errorf("请求路径 = %q, want /search(配置里的端点必须原样使用)", r.URL.Path)
|
||||
}
|
||||
if got := r.URL.Query().Get("q"); got != "昔涟" {
|
||||
t.Errorf("q = %q", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("format"); got != "json" {
|
||||
t.Errorf("format = %q, want json", got)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("配置里的端点一个请求都没收到 —— 配置没被透传到工具")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireIOTools_TimeoutIsWired 钉住 WEB_SEARCH_TIMEOUT 真的接到了工具上:
|
||||
// 一个慢端点 + 100ms 预算必须被掐断(默认 15s 够它跑完,因此「被掐断」只可能来自
|
||||
// 这个配置)。负值按未配置处理并记一条 WARN。
|
||||
func TestWireIOTools_TimeoutIsWired(t *testing.T) {
|
||||
t.Parallel()
|
||||
release := make(chan struct{})
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-release:
|
||||
case <-r.Context().Done():
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"results":[]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
defer close(release)
|
||||
|
||||
cfg := wsLoadConfig(t,
|
||||
"WEB_SEARCH_ENDPOINT="+srv.URL,
|
||||
"WEB_SEARCH_TIMEOUT=100ms",
|
||||
)
|
||||
if cfg.WebSearch.Timeout != 100*time.Millisecond {
|
||||
t.Fatalf("WEB_SEARCH_TIMEOUT 没被解析: %v", cfg.WebSearch.Timeout)
|
||||
}
|
||||
a := wsSurfaceApp(t, cfg, nil)
|
||||
tl, err := a.caps.Get("web-search")
|
||||
if err != nil {
|
||||
t.Fatalf("web-search 不在注册面里: %v", err)
|
||||
}
|
||||
exec := tl.Value.(interface {
|
||||
Execute(context.Context, any) (any, error)
|
||||
})
|
||||
if _, err := exec.Execute(context.Background(), map[string]any{"query": "cyrene"}); err == nil {
|
||||
t.Fatal("100ms 预算下慢端点必须被掐断(说明配置没接到工具上)")
|
||||
}
|
||||
|
||||
// 负值 = 笔误:按未配置处理(工具仍在),并留下一条带键名的 WARN。
|
||||
var buf bytes.Buffer
|
||||
negCfg := wsLoadConfig(t,
|
||||
"WEB_SEARCH_ENDPOINT="+srv.URL,
|
||||
"WEB_SEARCH_TIMEOUT=-5s",
|
||||
)
|
||||
negApp := wsSurfaceApp(t, negCfg, &buf)
|
||||
if !wsHasTool(negApp, "web-search") {
|
||||
t.Fatal("负超时只应退回默认,不该把工具从注册面摘掉")
|
||||
}
|
||||
if out := buf.String(); !strings.Contains(out, webSearchTimeoutEnv) {
|
||||
t.Fatalf("负超时必须在日志里点出 %s:%q", webSearchTimeoutEnv, out)
|
||||
}
|
||||
}
|
||||
|
||||
// wsSearxngResultCount 用**裸 HTTP**问兜底服务(与工具实现无关)同一个查询,返回它此刻
|
||||
// 真能产出的结果条数;不可达/应答不可解析时返回 -1。理由与命名同 io 侧的探针:上游
|
||||
// 引擎被限流时兜底会「200 但 0 条」,那是外部降级,不该判成本仓的代码红。
|
||||
func wsSearxngResultCount(endpoint, query string) int {
|
||||
client := &http.Client{Timeout: 25 * time.Second}
|
||||
resp, err := client.Get(endpoint + "/search?q=" + url.QueryEscape(query) + "&format=json")
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return -1
|
||||
}
|
||||
var payload struct {
|
||||
Results []struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"results"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return -1
|
||||
}
|
||||
return len(payload.Results)
|
||||
}
|
||||
|
||||
// TestWireIOTools_RealSearXNG_EndToEnd 是**真机**端到端:默认配置(一个 WEB_SEARCH_*
|
||||
// 键都不写)⇒ 组合根接线 ⇒ 注册面里的 web-search ⇒ 打本机兜底 SearXNG 真的拿到结果。
|
||||
// 它只读外部服务,不碰后端进程、不碰仓库根产物。兜底没起、或其上游引擎被限流到产不出
|
||||
// 结果时 **skip**(外部降级不判代码红;探针有结果而工具没有 = 真红)。
|
||||
func TestWireIOTools_RealSearXNG_EndToEnd(t *testing.T) {
|
||||
t.Parallel()
|
||||
const query = "昔涟 崩坏星穹铁道"
|
||||
cfg := wsLoadConfig(t)
|
||||
upstream := wsSearxngResultCount(cfg.WebSearch.Endpoint, query)
|
||||
if upstream < 0 {
|
||||
t.Skipf("本机兜底 SearXNG 不可达(%s),跳过真机 e2e", cfg.WebSearch.Endpoint)
|
||||
}
|
||||
if upstream == 0 {
|
||||
t.Skipf("兜底 SearXNG 当前产不出结果(上游引擎被限流/CAPTCHA),跳过真机 e2e")
|
||||
}
|
||||
|
||||
a := wsSurfaceApp(t, cfg, nil)
|
||||
if !wsHasTool(a, "web-search") {
|
||||
t.Fatalf("默认配置下 web-search 必须在注册面里,实际工具面 = %v", toolNames(a))
|
||||
}
|
||||
tl, err := a.caps.Get("web-search")
|
||||
if err != nil {
|
||||
t.Fatalf("caps.Get(web-search): %v", err)
|
||||
}
|
||||
exec := tl.Value.(interface {
|
||||
Execute(context.Context, any) (any, error)
|
||||
})
|
||||
got, err := exec.Execute(context.Background(), map[string]any{"query": query})
|
||||
if err != nil {
|
||||
t.Fatalf("默认端点真机查询失败: %v", err)
|
||||
}
|
||||
m, ok := got.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("返回值类型 = %T", got)
|
||||
}
|
||||
count, _ := m["count"].(int)
|
||||
if count == 0 {
|
||||
t.Fatalf("裸 HTTP 探针拿到 %d 条、工具却拿到 0 条 —— 这不再是外部降级: %v", upstream, m)
|
||||
}
|
||||
t.Logf("真机 e2e(默认配置 %s):返回 %d 条结果", cfg.WebSearch.Endpoint, count)
|
||||
}
|
||||
|
||||
// TestWireIOTools_SeamsAndEnvTemplate 是防回归补充:组合根那两个接缝还在,
|
||||
// 两个 env 键与 `.env.example` 逐字一致(AGENTS §10 的 E6 固定动作)。
|
||||
func TestWireIOTools_SeamsAndEnvTemplate(t *testing.T) {
|
||||
t.Parallel()
|
||||
seams := map[string][]string{
|
||||
"wire_runtime.go": {
|
||||
// io 工具集必须经这一个入口构造,否则端点裁决会被绕过。
|
||||
"iotools.New(a.cfg, a.ioToolOptions()...)",
|
||||
},
|
||||
"wire_io_tools.go": {
|
||||
"iotools.WithSearchEndpoint(decision.URL)",
|
||||
"iotools.WithoutSearch()",
|
||||
},
|
||||
}
|
||||
for file, wants := range seams {
|
||||
src, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", file, err)
|
||||
}
|
||||
for _, want := range wants {
|
||||
if !strings.Contains(string(src), want) {
|
||||
t.Fatalf("%s 里找不到 %q —— web-search 的接线/开关断了", file, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
|
||||
if err != nil {
|
||||
t.Fatalf("read .env.example: %v", err)
|
||||
}
|
||||
template := string(raw)
|
||||
for _, line := range []string{
|
||||
webSearchEndpointEnv + "=",
|
||||
webSearchTimeoutEnv + "=",
|
||||
// 默认值必须在模板里如实写出(本机兜底),否则照模板部署的人看不出它接的是什么。
|
||||
webSearchEndpointEnv + "=http://127.0.0.1:38471",
|
||||
} {
|
||||
if !strings.Contains(template, line) {
|
||||
t.Fatalf(".env.example 里找不到 %q(新 env 键必须同步模板,AGENTS §10)", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user