feat(search): provider 热重载 + primary/fallback(W3)

- 保存被包成事务:快照 → 落盘 → 按新配置重建工具 → 原子替换注册表条目(registry.ReplaceTool
  窄方法,保留运行期 Policy);下一轮即生效,不要求重启
- 若新配置会让后端下次起不来(主家未配齐 fail-fast)⇒ 文件按字节回滚 + 409 config_unstartable
- 声明面补 primary + fallback[](可配可生效);否决「另开 form」方案(会破坏既有 reauth 回填)
- 主 provider 解析口径核查结论:没被 PLG-4 改坏(顺手修了一处日志缺陷)
- 4 个变异自检(丢 Policy/不换条目/回滚 no-op/忽略 fallback)
This commit is contained in:
AskaEth committed 2026-10-06 15:59:12 +08:00
1 parent 5ee5f9126f
commit a30f4a49ff
12 files changed
+1696 -16

No files matched your search

+211
View File
@@ -0,0 +1,211 @@
package main
// 本文件是切片 W3 那几组测试的**夹具**(`newReloadTestEnv` / 假上游 / 日志字段读取):
// 单独成一个测试文件,是为了让三个主题文件(热重载、面板选择字段、解析口径与探针)
// 各自都在 AGENTS §10 的文件行数之内,且共享的接线只写一份。
//
// 夹具的关键差别(相对 wire_plugin_actions_test.go 的 actionTestEnv):它带
// `a.caps` / `a.dispatcher` —— 热重载换的就是注册表里的条目,没有能力面的夹具
// 根本证明不了这件事(那正是 L1/PLG-2 当时如实登记的缺口)。
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"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"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// reloadTestEnv 是一个「组合根真的接好了能力面 + 面板动作面」的夹具。
type reloadTestEnv struct {
app *app
hub *pluginmgr.Panels
disp *tool.Dispatcher
path string
out *bytes.Buffer
}
func newReloadTestEnv(t *testing.T, extra ...string) *reloadTestEnv {
t.Helper()
cfg := wsLoadConfig(t, extra...)
b := bus.New()
t.Cleanup(func() { _ = b.Close() })
mgr := pluginmgr.NewManager(registry.New(), filepath.Join(cfg.DataDir, "plugins.json"), testLog())
t.Cleanup(func() { mgr.Close(context.Background()) })
var out bytes.Buffer
caps := registry.New()
disp, err := tool.New(caps)
if err != nil {
t.Fatalf("tool.New: %v", err)
}
a := &app{
cfg: cfg,
log: logger.New(logger.Options{Out: &out, Level: "debug"}),
bus: b,
plugins: mgr,
caps: caps,
dispatcher: disp,
}
// 启动期那条路(与 wsSurfaceApp 同源):ioToolOptions → iotools.New → 注册面。
set, err := iotools.New(cfg, a.ioToolOptions()...)
if err != nil {
t.Fatalf("iotools.New: %v", err)
}
for _, tl := range set {
if rerr := disp.Register(tl); rerr != nil {
t.Fatalf("register %s: %v", tl.Name(), rerr)
}
}
wirePanelHub(a)
if mgr.Panels() == nil {
t.Fatal("wirePanelHub 未把面板中枢接到插件管理器上")
}
return &reloadTestEnv{
app: a, hub: mgr.Panels(), disp: disp,
path: runtimeconfig.WebSearchConfigPath(cfg.DataDir), out: &out,
}
}
// save 走保存动作(表单形状 `{values:{...}}`),返回结果的**原始 any 与错误**。
func (e *reloadTestEnv) save(values map[string]any) (any, error) {
return e.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
PluginID: search.PanelPluginID,
Action: search.ActionSaveProvider,
Payload: map[string]any{"values": values},
})
}
// mustSave 要求保存成功,并返回响应里的 `hotReload` 块(缺了就失败:它是本片新增的
// 「这次保存有没有热重载」的可机读事实)。
func (e *reloadTestEnv) mustSave(t *testing.T, values map[string]any) webSearchReloadOutcome {
t.Helper()
res, err := e.save(values)
if err != nil {
t.Fatalf("保存失败: %v", err)
}
m, ok := res.(map[string]any)
if !ok {
t.Fatalf("保存结果类型 = %T", res)
}
outcome, ok := m["hotReload"].(webSearchReloadOutcome)
if !ok {
t.Fatalf("响应里没有 hotReload(热重载没被接线): %#v", m)
}
return outcome
}
// search 走**分派器**(模型真正调工具的那条路)打一次 web-search,返回线格式结果。
func (e *reloadTestEnv) search(t *testing.T, query string) (map[string]any, error) {
t.Helper()
got, err := e.disp.Invoke(context.Background(), webSearchToolName, map[string]any{"query": query})
if err != nil {
return nil, err
}
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("工具返回值类型 = %T", got)
}
return m, nil
}
// toolValue 取注册面里那一把 web-search 的**当前**值(不在注册面里就失败)。
func (e *reloadTestEnv) toolValue(t *testing.T) any {
t.Helper()
cap, err := e.app.caps.Get(webSearchToolName)
if err != nil {
t.Fatalf("web-search 不在注册面里: %v(工具面 = %v)", err, toolNames(e.app))
}
return cap.Value
}
// rawFile 读可变源的**原始字节**(「字节级没变」是回滚判据里最强的那一种)。
func (e *reloadTestEnv) rawFile(t *testing.T) []byte {
t.Helper()
raw, err := os.ReadFile(e.path)
if err != nil {
t.Fatalf("read %s: %v", e.path, err)
}
return raw
}
// loadFile 读盘上的可变源。
func (e *reloadTestEnv) loadFile(t *testing.T) runtimeconfig.WebSearchFile {
t.Helper()
f, found, err := runtimeconfig.LoadWebSearch(e.path)
if err != nil || !found {
t.Fatalf("LoadWebSearch(%s): found=%v err=%v", e.path, found, err)
}
return f
}
// wsFakeUpstream 是一个假搜索上游:记下每一次命中,按预设状态码与响应体作答。
type wsFakeUpstream struct {
url string
hits chan *http.Request
}
func newWSFakeUpstream(t *testing.T, status int, body string) *wsFakeUpstream {
t.Helper()
hits := make(chan *http.Request, 8)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits <- r
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return &wsFakeUpstream{url: srv.URL, hits: hits}
}
// newWSFakeSearxng 是一个「答一条结果」的 searxng 假上游(成功路径用)。
func newWSFakeSearxng(t *testing.T, title string) *wsFakeUpstream {
t.Helper()
return newWSFakeUpstream(t, http.StatusOK,
`{"results":[{"title":"`+title+`","url":"http://x.example/1","content":"`+title+`"}]}`)
}
// drain 清掉已经排队的命中(用于「重载之后打的是不是新那家」的判定)。
func (f *wsFakeUpstream) drain() {
for {
select {
case <-f.hits:
default:
return
}
}
}
// noHit 断言此刻没有新的命中(重载后**不该**再打到旧那家)。
func (f *wsFakeUpstream) noHit(t *testing.T) {
t.Helper()
select {
case r := <-f.hits:
t.Fatalf("旧上游又收到一个请求(%s)—— 重载没真的换掉工具", r.URL)
default:
}
}
// wsLogField 从启动日志里取一个字段值(slog TextHandler 的 `key=value`)。
func wsLogField(t *testing.T, out, key string) string {
t.Helper()
re := regexp.MustCompile(`(?:^|\s)` + regexp.QuoteMeta(key) + `=(\S+)`)
m := re.FindStringSubmatch(out)
if m == nil {
t.Fatalf("启动日志里找不到 %s=…: %q", key, out)
}
return strings.Trim(m[1], `"`)
}