| 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 |