- 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 在真产物里成立
360 lines
15 KiB
Go
360 lines
15 KiB
Go
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 ""
|
||
}
|