返回 DeepSeek-Reasonix
browser_tools_test.go
根目录 / internal / boot / browser_tools_test.go
1 package boot
2
3 import (
4 "context"
5 "encoding/json"
6 "slices"
7 "strings"
8 "testing"
9
10 "reasonix/internal/agent/testutil"
11 "reasonix/internal/browser"
12 "reasonix/internal/event"
13 "reasonix/internal/provider"
14 )
15
16 // bootBrowserExecutor is the smallest Executor a boot can attach: it lists
17 // fixed tabs and refuses everything else.
18 type bootBrowserExecutor struct{ tabs []browser.Tab }
19
20 func (b bootBrowserExecutor) Tabs(context.Context) ([]browser.Tab, error) { return b.tabs, nil }
21 func (bootBrowserExecutor) Open(context.Context, browser.OpenRequest) (browser.Tab, error) {
22 return browser.Tab{}, browser.ErrNoGrant
23 }
24 func (bootBrowserExecutor) Navigate(context.Context, browser.NavigateRequest) (browser.Tab, error) {
25 return browser.Tab{}, browser.ErrNoGrant
26 }
27 func (bootBrowserExecutor) Snapshot(context.Context, browser.SnapshotRequest) (browser.Snapshot, error) {
28 return browser.Snapshot{}, browser.ErrNoGrant
29 }
30 func (bootBrowserExecutor) Screenshot(context.Context, browser.ScreenshotRequest) (browser.Screenshot, error) {
31 return browser.Screenshot{}, browser.ErrNoGrant
32 }
33 func (bootBrowserExecutor) Act(context.Context, browser.ActRequest) (browser.ActResult, error) {
34 return browser.ActResult{}, browser.ErrNoGrant
35 }
36 func (bootBrowserExecutor) Downloads(context.Context, browser.DownloadsRequest) ([]browser.Download, error) {
37 return nil, browser.ErrNoGrant
38 }
39 func (bootBrowserExecutor) Close(context.Context, browser.CloseRequest) error {
40 return browser.ErrNoGrant
41 }
42
43 const browserBootConfig = `
44 default_model = "test-model"
45
46 [agent]
47 system_prompt = "BASE"
48
49 [environment]
50 enabled = false
51
52 [[providers]]
53 name = "test-model"
54 kind = "boot-token-profile-test"
55 model = "x"
56 `
57
58 type browserSurface struct {
59 prompt string
60 tools []string
61 visible []string
62 registry []string
63 }
64
65 func captureBrowserSurface(t *testing.T, exec browser.Executor) browserSurface {
66 t.Helper()
67 prov := testutil.NewMock("browser-surface", testutil.Turn{Text: "done"})
68 setBootTokenProfileTestProvider(t, prov)
69 ctrl, err := Build(context.Background(), Options{Sink: event.Discard, BrowserExecutor: exec})
70 if err != nil {
71 t.Fatalf("Build: %v", err)
72 }
73 defer ctrl.Close()
74 if err := ctrl.Run(context.Background(), "hello"); err != nil {
75 t.Fatalf("Run: %v", err)
76 }
77 reqs := mainConversationRequests(prov.Requests())
78 if len(reqs) != 1 {
79 t.Fatalf("requests = %d, want 1", len(reqs))
80 }
81 s := browserSurface{prompt: systemMessage(ctrl.History()), tools: toolSchemaNames(reqs[0].Tools)}
82 for _, e := range ctrl.ToolContractEntries() {
83 s.visible = append(s.visible, e.Name)
84 }
85 for _, e := range ctrl.AllToolContractEntries() {
86 s.registry = append(s.registry, e.Name)
87 }
88 return s
89 }
90
91 // TestBrowserToolsStayOffTheProviderSurface is the cache guard for the
92 // browser capability: attaching an executor registers the tools for
93 // use_capability but leaves the provider request's tool array and the
94 // system prompt byte-identical to a build without one.
95 func TestBrowserToolsStayOffTheProviderSurface(t *testing.T) {
96 isolateConfigHome(t)
97 dir := robustTempDir(t)
98 t.Chdir(dir)
99 writeFile(t, dir, "reasonix.toml", browserBootConfig)
100 registerBootTokenProfileTestProvider()
101
102 without := captureBrowserSurface(t, nil)
103 with := captureBrowserSurface(t, bootBrowserExecutor{})
104
105 for _, name := range browser.Names() {
106 if !slices.Contains(with.registry, name) {
107 t.Errorf("registry with executor lacks %s: %v", name, with.registry)
108 }
109 if slices.Contains(without.registry, name) {
110 t.Errorf("registry without executor registered %s", name)
111 }
112 if slices.Contains(with.visible, name) || slices.Contains(with.tools, name) {
113 t.Errorf("%s leaked into the provider-visible surface", name)
114 }
115 }
116 if !slices.Equal(with.tools, without.tools) {
117 t.Fatalf("provider tool array changed with a browser attached:\nwith %v\nwithout %v", with.tools, without.tools)
118 }
119 if with.prompt != without.prompt {
120 t.Fatalf("system prompt changed with a browser attached: %s", firstDivergence(without.prompt, with.prompt))
121 }
122 }
123
124 func TestUseCapabilityListsAndCallsBrowserTools(t *testing.T) {
125 isolateConfigHome(t)
126 dir := robustTempDir(t)
127 t.Chdir(dir)
128 writeFile(t, dir, "reasonix.toml", browserBootConfig)
129 registerBootTokenProfileTestProvider()
130 listArgs, _ := json.Marshal(map[string]any{"action": "list"})
131 callArgs, _ := json.Marshal(map[string]any{"action": "call", "capability_id": "tool:browser_tabs", "arguments": map[string]any{}})
132 prov := testutil.NewMock("browser-ucap",
133 testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "l1", Name: "use_capability", Arguments: string(listArgs)}}},
134 testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "c1", Name: "use_capability", Arguments: string(callArgs)}}},
135 testutil.Turn{Text: "done"},
136 )
137 setBootTokenProfileTestProvider(t, prov)
138 exec := bootBrowserExecutor{tabs: []browser.Tab{{ID: "tab-1", URL: "https://example.test", Title: "Example"}}}
139 ctrl, err := Build(context.Background(), Options{Sink: event.Discard, BrowserExecutor: exec})
140 if err != nil {
141 t.Fatal(err)
142 }
143 defer ctrl.Close()
144 if err := ctrl.Run(context.Background(), "list the browser tabs"); err != nil {
145 t.Fatalf("Run: %v", err)
146 }
147 var toolOut []string
148 for _, msg := range ctrl.History() {
149 if msg.Role == provider.RoleTool {
150 toolOut = append(toolOut, msg.Content)
151 }
152 }
153 if len(toolOut) != 2 {
154 t.Fatalf("tool outputs = %d, want 2: %q", len(toolOut), toolOut)
155 }
156 if !strings.Contains(toolOut[0], "tool:browser_snapshot") {
157 t.Fatalf("use_capability list omits browser_snapshot:\n%s", toolOut[0])
158 }
159 if !strings.Contains(toolOut[1], "tab tab-1: https://example.test") {
160 t.Fatalf("use_capability call did not reach the executor:\n%s", toolOut[1])
161 }
162 for _, req := range prov.Requests() {
163 if requestHasToolPrefix(req, "browser_") {
164 t.Fatalf("browser tool leaked into provider schemas: %v", toolSchemaNames(req.Tools))
165 }
166 }
167 }
168
168 lines GO