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 在真产物里成立
This commit is contained in:
AskaEth committed 2026-10-07 09:34:44 +08:00
1 parent 93fc24cade
commit 8e2441c183
3 files changed
+131 -15

No files matched your search

+113 -15
View File
@@ -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 {
+11
View File
@@ -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`
+7
View File
@@ -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),