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 的原文要求)。 // // ⚠️ **接线状态(如实)**:本文件把 `a.browserShotTransform(...)` 装进 `loop.Options` // 来验它;生产是否装上由 `wire_runtime.go` 的一行决定(本片无权改那个文件,见 // `wire_browser_context.go` 的接线状态注释)。`TestWireRuntime_WiresBrowserShotTransform` // 会盯着那一行:装上之前它 Skip 并写明要加什么,装上之后它就是正向断言。 import ( "bytes" "context" "image" "image/color" "image/png" "os" "path/filepath" "strings" "testing" browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser" "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 的接线状态注释)。 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 盯住生产接线那一行。 // // 装上之前**Skip 并写明要加什么**(不是失败:本片(Y4)的文件面不含 wire_runtime.go, // 那一行由主会话落地)。装上之后本用例就是正向断言 —— 它拦的是「以后有人把那行删了」, // 因为删掉之后**没有任何运行期症状**:编译照过、回合照跑,只是截图不再进上下文。 func TestWireRuntime_WiresBrowserShotTransform(t *testing.T) { t.Parallel() src := readSource(t, "wire_runtime.go") if !strings.Contains(src, "browserShotTransform(") { t.Skip("接线未应用:请在 wire_runtime.go 的 loop.New(loop.Options{…}) 里加 " + "`TransformContext: a.browserShotTransform(visionResolver),`" + "(B3 闭环的生产挂点;本片无权改该文件)") } if !strings.Contains(src, "TransformContext:") { t.Fatal("wire_runtime.go 里出现了 browserShotTransform,但没挂在 TransformContext 上") } } /* --------------------------------------------------------------------------- * 断言助手 * ------------------------------------------------------------------------- */ // 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 "" }