package main // 本文件是 design/28 §7 **判据 2(M4 闭环)的真跑**(切片 Y4 / B3): // // 一张**图里才有、HTML 里没有**的内容(这里由一张真实 PNG 代表)⇒ 它进了模型请求。 // // 走的是**真实循环**(`loop.New` + 探针适配器,与组合根同一个装配形状),因此它验的是 // 完整那一段:截图箱 → `TransformContext` 钩子 → 视觉路由(主模型能收图 ⇒ 真 image 部件; // 收不了图 ⇒ vision 工具产出的文字)→ 模型请求。 // // 三段各自归各家、这里只做端到端: // - 截图**落盘**(真 Chrome、真 CDP)由 `wire_browser_e2e_test.go` 与 // `internal/capability/browser/compare_test.go` 覆盖(需要真机,默认 Skip); // - 「图 → 内容部件」的路由与裁切由 `internal/runtime/multimodal/vision/shot_test.go` 覆盖; // - 本文件把两者接起来,并且**断言模型侧确实拿到 image 部件**(判据 2 的原文要求)。 // // 生产侧的**两条挂点**都由本文件盯住(它们删掉之后都没有任何运行期症状): // `TestWireRuntime_WiresBrowserShotTransform` 钉 `wire_runtime.go` 的 `TransformContext`; // `TestWireFrontend_WiresBrowserPanel` 钉 `wire_frontend.go` 的 `wireBrowserPanel` 调用 // (`TestWirePanelHub_DeclaresBrowserPanel` 是它的**行为侧**配偶:真装配一遍再读回目录)。 import ( "bytes" "context" "image" "image/color" "image/png" "os" "path/filepath" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser" 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" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/vision" browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser" ) // b3Session 是本文件用的会话 id(截图箱按会话分桶,测试与循环必须用同一个)。 const b3Session = "sess-b3" // b3RenderOnlyText 是「图里才有、HTML 里没有」的那句话:走工具形态时它必须出现在 // 模型请求里(纯文本路径的正面证据)。 const b3RenderOnlyText = "计费周期:2026-09 至 2026-10(这是渲染后才出现的文字)" // b3PNG 造一张真实 PNG(视觉层对字节**嗅探**,假字节会被拒)。 func b3PNG(t *testing.T) []byte { t.Helper() img := image.NewRGBA(image.Rect(0, 0, 6, 4)) img.Set(0, 0, color.RGBA{R: 0x10, G: 0x20, B: 0x30, A: 0xff}) var buf bytes.Buffer if err := png.Encode(&buf, img); err != nil { t.Fatalf("编码 PNG: %v", err) } return buf.Bytes() } // b3Fixture 是一次闭环验证的夹具:真 loop + 探针适配器 + 装着截图的 browser 工具。 type b3Fixture struct { adapter *compactProbeAdapter loop *loop.Loop outbox *browsertools.ShotOutbox shot string } // newB3Fixture 装配夹具。sighted 决定主模型能不能收图(两条闭环路径的分岔点就在这里)。 func newB3Fixture(t *testing.T, sighted bool) *b3Fixture { t.Helper() data := b3PNG(t) shotPath := filepath.Join(t.TempDir(), "shot-b3.png") if err := os.WriteFile(shotPath, data, 0o600); err != nil { t.Fatalf("写截图: %v", err) } outbox := browsertools.NewShotOutbox() outbox.Put(b3Session, browsercapShot(shotPath, len(data))) // 只接**附件箱**这一面:本用例验的是「截图落盘之后」那一段,真浏览器由真机 e2e 覆盖。 tool := browsertools.New(nil, browsertools.WithShotOutbox(outbox)) var out bytes.Buffer a := &app{ cfg: &config.Config{DataDir: t.TempDir()}, log: logger.New(logger.Options{Out: &out, Level: "debug"}), caps: registry.New(), } if err := a.caps.RegisterTool(tool); err != nil { t.Fatalf("注册 browser 工具: %v", err) } adapter := &compactProbeAdapter{} resolver := vision.NewMessageResolver( vision.NewRouter(vision.WithVisionTool("vision"), vision.WithOCRTool("ocr")), func() vision.ModelInfo { return vision.ModelInfo{Name: "main", SupportsVision: sighted} }, func(_ context.Context, _ string, _ map[string]any) (any, error) { // 工具形态的假实现:返回「只有渲染后才看得见」的那句话。 return map[string]any{"description": b3RenderOnlyText, "text": b3RenderOnlyText}, nil }, ) ag, err := agent.New("你是昔涟。", nil, nil, agent.WithModel(adapter, nil)) if err != nil { t.Fatalf("agent.New: %v", err) } return &b3Fixture{ adapter: adapter, outbox: outbox, shot: shotPath, loop: loop.New(loop.Options{ Agent: ag, // ← 与生产接线逐字同一行(wire_runtime.go 的 loop.Options.TransformContext), // 由 TestWireRuntime_WiresBrowserShotTransform 源码级盯住。 TransformContext: a.browserShotTransform(resolver), OnStream: func(model.StreamEvent) {}, }), } } // browsercapShot 造与落盘结论同形的截图值。 func browsercapShot(path string, size int) browsercap.Shot { return browsercap.Shot{Path: path, Bytes: size, Width: 6, Height: 4, Format: "png", DimensionsKnown: true} } // TestBrowserShotClosure_SightedModelReceivesTheImage 是判据 2 的正面: // 主模型能收图 ⇒ 模型请求里**确实有 image 部件**,且是服务端内联的 data: URI。 func TestBrowserShotClosure_SightedModelReceivesTheImage(t *testing.T) { t.Parallel() fx := newB3Fixture(t, true) if _, err := fx.loop.Run(context.Background(), b3Session, []model.Message{model.TextMessage(model.RoleUser, "这一页写了什么?")}); err != nil { t.Fatalf("Run: %v", err) } if len(fx.adapter.reqs) == 0 { t.Fatal("没有捕获到模型请求") } req := fx.adapter.reqs[len(fx.adapter.reqs)-1] part, ok := findImagePart(req.Messages) if !ok { t.Fatalf("模型请求里没有 image 部件 —— 截图没有进上下文(判据 2 不成立);消息数=%d", len(req.Messages)) } if !strings.HasPrefix(part.ImageURL, "data:image/png;base64,") { t.Errorf("image 部件不是内联的 data: URI(云 provider 取不到本地路径):%.60s", part.ImageURL) } if !imagePartIsOnUserMessage(req.Messages) { t.Error("image 部件不在 user 消息上:Anthropic/DeepSeek 只接受 user 消息里的图像部件") } if note := lastUserText(req.Messages); !strings.Contains(note, fx.shot) { t.Errorf("附件说明里没给出截图路径(模型无法据此裁区域精读):%q", note) } } // TestBrowserShotClosure_TextOnlyModelGetsToolFormText 是判据 2 的另一条路: // 主模型读不了图 ⇒ 图**不进**上下文,进去的是 vision 工具的读数(「渲染后才出现的文字」)。 func TestBrowserShotClosure_TextOnlyModelGetsToolFormText(t *testing.T) { t.Parallel() fx := newB3Fixture(t, false) if _, err := fx.loop.Run(context.Background(), b3Session, []model.Message{model.TextMessage(model.RoleUser, "这一页写了什么?")}); err != nil { t.Fatalf("Run: %v", err) } req := fx.adapter.reqs[len(fx.adapter.reqs)-1] if _, hasImage := findImagePart(req.Messages); hasImage { t.Error("主模型读不了图时不该把 image 部件塞进上下文(那是无意义的 token 与 400 风险)") } text := allText(req.Messages) if !strings.Contains(text, b3RenderOnlyText) { t.Errorf("工具形态的文字没进上下文:%q", text) } } // TestBrowserShotClosure_ShotIsConsumedOnce 钉住「取走即消费」:同一张截图不会被送到两次 // (重复送图既费预算,又会诱导模型以为「又拍了一张」)。 func TestBrowserShotClosure_ShotIsConsumedOnce(t *testing.T) { t.Parallel() fx := newB3Fixture(t, true) msgs := []model.Message{model.TextMessage(model.RoleUser, "看看这一页")} if _, err := fx.loop.Run(context.Background(), b3Session, msgs); err != nil { t.Fatalf("第一次 Run: %v", err) } if fx.outbox.Pending(b3Session) != 0 { t.Fatalf("第一次之后箱子里还剩 %d 张(取走即消费被破坏)", fx.outbox.Pending(b3Session)) } if _, err := fx.loop.Run(context.Background(), b3Session, msgs); err != nil { t.Fatalf("第二次 Run: %v", err) } if _, ok := findImagePart(fx.adapter.reqs[len(fx.adapter.reqs)-1].Messages); ok { t.Error("同一张截图在第二次回合里又被送了一次") } } // TestWireRuntime_WiresBrowserShotTransform 盯住生产接线那一行(B3 的生产挂点)。 // // 它拦的是「以后有人把那行删了」:删掉之后**没有任何运行期症状**——编译照过、回合照跑, // 只是截图不再进上下文。因此断言的是**一整行字段赋值**(行首到行尾),被注释掉的 // (`// TransformContext: …`)或写在别处的同名字符串都不会让它通过。 func TestWireRuntime_WiresBrowserShotTransform(t *testing.T) { t.Parallel() src := readSource(t, "wire_runtime.go") const assignment = "TransformContext: a.browserShotTransform(visionResolver)," if !hasNormalizedCodeLine(src, assignment) { t.Fatalf("wire_runtime.go 的 loop.Options 里找不到 %q —— B3 截图闭环的生产挂点缺失:"+ "截图照落盘、回合照跑,只是**一张图都不会进上下文**", assignment) } // 钩子捕获的就是 `visionResolver`,因此它必须在 loop.New 之前建好;顺序反过来装上的 // 就是一个 nil 解析器(ShotAttacher 为 nil),截图静默不送——同样是**零症状**的坏法。 idxResolver := strings.Index(src, "visionResolver := vision.NewMessageResolver(") idxLoop := strings.Index(src, "a.loop = loop.New(loop.Options{") if idxResolver < 0 || idxLoop < 0 || idxResolver > idxLoop { t.Fatalf("visionResolver 与 loop.New 的先后不对(偏移 %d vs %d):钩子必须在解析器**之后**装配", idxResolver, idxLoop) } } // TestWireFrontend_WiresBrowserPanel 盯住 B4 的生产挂点:`wirePanelHub` 必须在 // `declareBuiltinPanels` 之后调用 `wireBrowserPanel`。 // // 与上面那条同族——删掉之后**没有任何编译期或运行期症状**:只是面板目录里没有 browser 页、 // 六个动作全回 501(`GET /api/v1/admin/panels` 少一条、点击全部 501),而整树测试照绿。 // 断言同样锚定**一整行调用**,注释掉那一行不会通过。 func TestWireFrontend_WiresBrowserPanel(t *testing.T) { t.Parallel() src := readSource(t, "wire_frontend.go") const call = "wireBrowserPanel(hub, a, a.log)" if !hasNormalizedCodeLine(src, call) { t.Fatalf("wire_frontend.go 的 wirePanelHub 里找不到 %q —— B4 的 browser 面板声明"+ "与六个动作都进不了目录(面板无 browser 页、动作全 501)", call) } // 位置:声明在前、登记在后是 PLG-2 的硬要求(登记期会核对有没有对应声明)。这里钉住 // 它**在 wirePanelHub 内**且**在 declareBuiltinPanels 之后**,防止被挪到别处或提前。 idxDeclare := strings.Index(src, "declareBuiltinPanels(hub, log)") idxBrowser := strings.Index(src, call) if idxDeclare < 0 || idxBrowser < idxDeclare { t.Fatalf("wireBrowserPanel 的调用位置不对(declareBuiltinPanels 偏移 %d,调用偏移 %d):"+ "它必须在声明之后(wirePanelHub 内部)", idxDeclare, idxBrowser) } } // TestWirePanelHub_DeclaresBrowserPanel 是上面那条源码级断言的**行为侧配偶**:真的走一遍 // `wirePanelHub` 装配,再从面板中枢里读回目录与动作白名单 —— 声明在、六个动作也登记上, // 才算 B4 的接线成立。 // // 两条合起来才够:源码级那条拦「那一行被删掉」,这条拦「那一行还在、但调用链断了」 // (wireBrowserPanel 被改成早返回、hub 传错、或声明/登记顺序被调反 ⇒ 只会得到一串 WARN)。 // 这台机器上**没有**浏览器也没关系:不可用时面板仍要如实声明(`available=false`), // 这正是 design/09 §5.2.1 的「该面板不注册 + 显式报错」之外的正常档。 func TestWirePanelHub_DeclaresBrowserPanel(t *testing.T) { t.Parallel() mgr := pluginmgr.NewManager(registry.New(), filepath.Join(t.TempDir(), "plugins.json"), testLog()) defer mgr.Close(context.Background()) b := bus.New() defer b.Close() wirePanelHub(&app{cfg: &config.Config{DataDir: t.TempDir()}, plugins: mgr, bus: b, log: testLog()}) hub := mgr.Panels() if hub == nil { t.Fatal("wirePanelHub 未把面板中枢接到插件管理器上") } entries, err := hub.List(auth.RoleOwner) if err != nil { t.Fatalf("List: %v", err) } found := false for _, e := range entries { if e.PluginID == browsertools.PanelPluginID && e.Panel.ID == browsertools.PanelIDSettings { found = true } } if !found { t.Fatalf("面板目录里没有 browser/settings(目录=%+v):B4 的挂点没生效", entries) } for _, action := range []string{ browsertools.ActionState, browsertools.ActionFingerprint, browsertools.ActionShots, browsertools.ActionShoot, browsertools.ActionCheck, browsertools.ActionSaveConfig, } { if _, ok := hub.Action(browsertools.PanelPluginID, action); !ok { t.Errorf("动作 %q 没进白名单(声明有、没人执行 ⇒ 面板点下去只会回 501)", action) } } } /* --------------------------------------------------------------------------- * 断言助手 * ------------------------------------------------------------------------- */ // hasNormalizedCodeLine 报告源码里是否存在这样**一行代码**:去掉行首空白、把连续的空白 // 折叠成一个空格之后,与 want 逐字相等。被 `//` 注释掉的行一律不算。 // // 为什么要折叠空白:字段赋值在 gofmt 里按块对齐(`TransformContext:` 与 // `DocumentMaxInjectChars:` 同一组时中间是 7 个空格,格式而非语义),逐字节比对整行会 // 在**一次无关的字段增删触发重新对齐**时误红;而折叠后逐行相等仍然能挡住 // 「注释掉那一行」与「别处的同名字符串」这两种假绿。 func hasNormalizedCodeLine(src, want string) bool { for _, line := range strings.Split(src, "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "//") { continue } if strings.Join(strings.Fields(line), " ") == want { return true } } return false } // findImagePart 返回请求里的第一个 image 部件。 func findImagePart(msgs []model.Message) (model.Content, bool) { for _, m := range msgs { for _, part := range m.Content { if part.Type == model.ContentTypeImage { return part, true } } } return model.Content{}, false } // imagePartIsOnUserMessage 报告 image 部件是否挂在 user 消息上(协议硬要求)。 func imagePartIsOnUserMessage(msgs []model.Message) bool { for _, m := range msgs { for _, part := range m.Content { if part.Type == model.ContentTypeImage { return m.Role == model.RoleUser } } } return false } // allText 拼出请求里全部文本(断言工具形态的文字是否进了上下文)。 func allText(msgs []model.Message) string { var b strings.Builder for _, m := range msgs { b.WriteString(loopText(m)) b.WriteString("\n") } return b.String() } // lastUserText 取最后一条 user 消息的文本(附件说明挂在它上面)。 func lastUserText(msgs []model.Message) string { for i := len(msgs) - 1; i >= 0; i-- { if msgs[i].Role == model.RoleUser { return loopText(msgs[i]) } } return "" }