From 8e2441c1837344d8bbcf0558d01704fdcd5c6dc0 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 7 Oct 2026 09:34:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(browser):=20=E8=A1=A5=E4=B8=8A=20B3/B4=20?= =?UTF-8?q?=E7=9A=84=E4=B8=A4=E8=A1=8C=E6=8E=A5=E7=BA=BF=EF=BC=88Z2?= =?UTF-8?q?=EF=BC=89=E2=80=94=E2=80=94=20=E9=9D=A2=E6=9D=BF=E9=A1=B5?= =?UTF-8?q?=E4=B8=8E=E6=88=AA=E5=9B=BE=E8=BF=9B=E4=B8=8A=E4=B8=8B=E6=96=87?= =?UTF-8?q?=E7=9C=9F=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 在真产物里成立 --- cmd/cyrene/wire_browser_context_test.go | 128 +++++++++++++++++++++--- cmd/cyrene/wire_frontend.go | 11 ++ cmd/cyrene/wire_runtime.go | 7 ++ 3 files changed, 131 insertions(+), 15 deletions(-) diff --git a/cmd/cyrene/wire_browser_context_test.go b/cmd/cyrene/wire_browser_context_test.go index 2ed3d41..90220fc 100644 --- a/cmd/cyrene/wire_browser_context_test.go +++ b/cmd/cyrene/wire_browser_context_test.go @@ -14,10 +14,10 @@ package main // - 「图 → 内容部件」的路由与裁切由 `internal/runtime/multimodal/vision/shot_test.go` 覆盖; // - 本文件把两者接起来,并且**断言模型侧确实拿到 image 部件**(判据 2 的原文要求)。 // -// ⚠️ **接线状态(如实)**:本文件把 `a.browserShotTransform(...)` 装进 `loop.Options` -// 来验它;生产是否装上由 `wire_runtime.go` 的一行决定(本片无权改那个文件,见 -// `wire_browser_context.go` 的接线状态注释)。`TestWireRuntime_WiresBrowserShotTransform` -// 会盯着那一行:装上之前它 Skip 并写明要加什么,装上之后它就是正向断言。 +// 生产侧的**两条挂点**都由本文件盯住(它们删掉之后都没有任何运行期症状): +// `TestWireRuntime_WiresBrowserShotTransform` 钉 `wire_runtime.go` 的 `TransformContext`; +// `TestWireFrontend_WiresBrowserPanel` 钉 `wire_frontend.go` 的 `wireBrowserPanel` 调用 +// (`TestWirePanelHub_DeclaresBrowserPanel` 是它的**行为侧**配偶:真装配一遍再读回目录)。 import ( "bytes" @@ -30,7 +30,10 @@ import ( "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" @@ -113,7 +116,8 @@ func newB3Fixture(t *testing.T, sighted bool) *b3Fixture { shot: shotPath, loop: loop.New(loop.Options{ Agent: ag, - // ← 生产接线就是这一行(见 wire_runtime.go 的接线状态注释)。 + // ← 与生产接线逐字同一行(wire_runtime.go 的 loop.Options.TransformContext), + // 由 TestWireRuntime_WiresBrowserShotTransform 源码级盯住。 TransformContext: a.browserShotTransform(resolver), OnStream: func(model.StreamEvent) {}, }), @@ -194,21 +198,95 @@ func TestBrowserShotClosure_ShotIsConsumedOnce(t *testing.T) { } } -// TestWireRuntime_WiresBrowserShotTransform 盯住生产接线那一行。 +// TestWireRuntime_WiresBrowserShotTransform 盯住生产接线那一行(B3 的生产挂点)。 // -// 装上之前**Skip 并写明要加什么**(不是失败:本片(Y4)的文件面不含 wire_runtime.go, -// 那一行由主会话落地)。装上之后本用例就是正向断言 —— 它拦的是「以后有人把那行删了」, -// 因为删掉之后**没有任何运行期症状**:编译照过、回合照跑,只是截图不再进上下文。 +// 它拦的是「以后有人把那行删了」:删掉之后**没有任何运行期症状**——编译照过、回合照跑, +// 只是截图不再进上下文。因此断言的是**一整行字段赋值**(行首到行尾),被注释掉的 +// (`// TransformContext: …`)或写在别处的同名字符串都不会让它通过。 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 闭环的生产挂点;本片无权改该文件)") + + const assignment = "TransformContext: a.browserShotTransform(visionResolver)," + if !hasNormalizedCodeLine(src, assignment) { + t.Fatalf("wire_runtime.go 的 loop.Options 里找不到 %q —— B3 截图闭环的生产挂点缺失:"+ + "截图照落盘、回合照跑,只是**一张图都不会进上下文**", assignment) } - if !strings.Contains(src, "TransformContext:") { - t.Fatal("wire_runtime.go 里出现了 browserShotTransform,但没挂在 TransformContext 上") + // 钩子捕获的就是 `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) + } } } @@ -216,6 +294,26 @@ func TestWireRuntime_WiresBrowserShotTransform(t *testing.T) { * 断言助手 * ------------------------------------------------------------------------- */ +// 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 { diff --git a/cmd/cyrene/wire_frontend.go b/cmd/cyrene/wire_frontend.go index 9ebadbf..d2cf104 100644 --- a/cmd/cyrene/wire_frontend.go +++ b/cmd/cyrene/wire_frontend.go @@ -203,6 +203,17 @@ func wirePanelHub(a *app) { }) a.plugins.AttachPanels(hub) declareBuiltinPanels(hub, log) + // browser 插件(design/28 §8 B4)的面板声明 + 六个动作:它自己就把「先声明、后登记 + // 动作」这一步走完(wire_browser_panel.go 一处),因此与 search 的纯声明并列。 + // + // 为什么要挡一下 `cfg == nil`:浏览器面板的配置路径与表单默认值**全部**由 + // `$DATA_DIR` 推出(wire_browser_panel.go 里 `resolveDataDir(a.cfg)`),而部分只读 + // 夹具(wire_panels_test.go / wire_plugin_actions_test.go 只填了 plugins/bus/log) + // 没有配置 ⇒ 直调会解空指针。生产路径上 wireFrontend 跑在 config.Load 之后, + // `a.cfg` 恒非 nil,因此这一支只为「不完整夹具」存在(同 wireBrowser 的取舍)。 + if a.cfg != nil { + wireBrowserPanel(hub, a, a.log) + } // 登记动作处理器必须在**声明之后**:登记期会核对「登记的处理器有没有对应声明」, // 顺序反过来只会得到一串「声明还没到」的 WARN。 // 日志来源另标一处(动作接线 ≠ 面板中枢):`source=cmd/cyrene/wire_plugin_actions` diff --git a/cmd/cyrene/wire_runtime.go b/cmd/cyrene/wire_runtime.go index b343fed..82479bc 100644 --- a/cmd/cyrene/wire_runtime.go +++ b/cmd/cyrene/wire_runtime.go @@ -344,6 +344,13 @@ func (a *app) wireRuntime() error { Thinking: thinking, ReasoningEffort: reasoningEffort, ThinkingShowToUser: thinkingShow, + TransformContext: a.browserShotTransform(visionResolver), + // 截图闭环(design/28 §7 判据 2 / B3):browser 拍照落盘后,由上一行那个钩子在 + // **每个模型步之前**把该会话待送的图并入本轮视图(取走即消费)。它只改本轮所见 ⇒ + // 截图不进 checkpoint、也不进后台记忆提取的输入面(design/28 §4 第 3 条)。 + // 钩子捕获的就是上面那个 visionResolver,所以这一行必须在它之后;装配细节与 + // 「删掉它没有任何运行期症状」这条理由见 wire_browser_context.go。 + // // 按调用(而非整批)准入:敏感与否取决于该次调用的参数 // (file 的 read 自主、write 代审),见 toolAdmissionHooks。 Hooks: a.toolAdmissionHooks(approvalGate),