Files
Cyrene_v2/cmd/cyrene/wire_browser_context_test.go
T
AskaEth 8e2441c183 feat(browser): 补上 B3/B4 的两行接线(Z2)—— 面板页与截图进上下文真生效
- wire_runtime.go 加 TransformContext: a.browserShotTransform(visionResolver)(只改本轮所见)
- wire_frontend.go 在 declareBuiltinPanels 之后接 wireBrowserPanel(带 a.cfg != nil 守卫,
  否则 6 处既有夹具 panic;生产路径 cfg 恒非 nil ⇒ 行为与字面写法一致)
- 3 条盯梢用例(1 条 Skip→转正 + 2 条新增),2 变异打红
- 真机(隔离实例):panels 出现 browser/settings、三个只读动作真回执、**真回合内联 image 部件
  61,410 字符** ⇒ design/28 判据 2 在真产物里成立
2026-10-07 09:34:44 +08:00

360 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ""
}