| 1 | package boot |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/base64" |
| 6 | "encoding/json" |
| 7 | "net/http" |
| 8 | "net/http/httptest" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "reasonix/internal/config" |
| 12 | "reasonix/internal/event" |
| 13 | "reasonix/internal/plugin" |
| 14 | "reasonix/internal/provider" |
| 15 | "strings" |
| 16 | "sync/atomic" |
| 17 | "testing" |
| 18 | ) |
| 19 | |
| 20 | func TestBuildDeepSeekTextParentCanUseImageReturningMCPAndVisionSubagent(t *testing.T) { |
| 21 | isolateConfigHome(t) |
| 22 | dir := robustTempDir(t) |
| 23 | t.Chdir(dir) |
| 24 | |
| 25 | registerBootSubagentTestProvider() |
| 26 | prov := &bootSubagentTestProvider{combinedVision: true} |
| 27 | setBootSubagentTestProvider(t, prov) |
| 28 | if _, err := config.SetCredential("BOOT_DEEPSEEK_TEST_KEY", "test-key"); err != nil { |
| 29 | t.Fatalf("store test DeepSeek credential: %v", err) |
| 30 | } |
| 31 | writeFile(t, dir, "reasonix.toml", ` |
| 32 | default_model = "parent" |
| 33 | |
| 34 | [agent] |
| 35 | system_prompt = "BASE" |
| 36 | subagent_model = "vision-model" |
| 37 | vision_model = "vision-model" |
| 38 | |
| 39 | [[providers]] |
| 40 | name = "parent" |
| 41 | kind = "boot-subagent-test" |
| 42 | base_url = "https://api.deepseek.com/anthropic" |
| 43 | model = "x" |
| 44 | api_key_env = "BOOT_DEEPSEEK_TEST_KEY" |
| 45 | |
| 46 | [[providers]] |
| 47 | name = "vision-model" |
| 48 | kind = "boot-subagent-test" |
| 49 | base_url = "https://vision.example.invalid" |
| 50 | model = "x" |
| 51 | vision = true |
| 52 | `) |
| 53 | png, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==") |
| 54 | if err != nil { |
| 55 | t.Fatalf("decode test png: %v", err) |
| 56 | } |
| 57 | if err := os.MkdirAll(filepath.Join(dir, ".reasonix", "attachments"), 0o755); err != nil { |
| 58 | t.Fatal(err) |
| 59 | } |
| 60 | if err := os.WriteFile(filepath.Join(dir, ".reasonix", "attachments", "shot.png"), png, 0o644); err != nil { |
| 61 | t.Fatal(err) |
| 62 | } |
| 63 | mcpImage := base64.StdEncoding.EncodeToString(png) |
| 64 | var mcpCalls atomic.Int32 |
| 65 | mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 66 | var request struct { |
| 67 | ID *int `json:"id"` |
| 68 | Method string `json:"method"` |
| 69 | Params json.RawMessage `json:"params"` |
| 70 | } |
| 71 | if err := json.NewDecoder(r.Body).Decode(&request); err != nil { |
| 72 | http.Error(w, "bad request", http.StatusBadRequest) |
| 73 | return |
| 74 | } |
| 75 | if request.ID == nil { |
| 76 | w.WriteHeader(http.StatusAccepted) |
| 77 | return |
| 78 | } |
| 79 | var result any |
| 80 | switch request.Method { |
| 81 | case "initialize": |
| 82 | result = map[string]any{ |
| 83 | "protocolVersion": "2024-11-05", |
| 84 | "serverInfo": map[string]any{"name": "vision-reader", "version": "1"}, |
| 85 | "capabilities": map[string]any{"tools": map[string]any{}}, |
| 86 | } |
| 87 | case "tools/list": |
| 88 | result = map[string]any{"tools": []map[string]any{{ |
| 89 | "name": "inspect", |
| 90 | "description": "Inspect an image file by path.", |
| 91 | "inputSchema": map[string]any{ |
| 92 | "type": "object", |
| 93 | "properties": map[string]any{"path": map[string]any{"type": "string"}}, |
| 94 | "required": []string{"path"}, |
| 95 | }, |
| 96 | "annotations": map[string]any{"readOnlyHint": true}, |
| 97 | }}} |
| 98 | case "tools/call": |
| 99 | mcpCalls.Add(1) |
| 100 | result = map[string]any{"content": []map[string]any{ |
| 101 | {"type": "text", "text": "vision-mcp-ok"}, |
| 102 | {"type": "image", "mimeType": "image/png", "data": mcpImage}, |
| 103 | }} |
| 104 | default: |
| 105 | http.Error(w, "unsupported method", http.StatusBadRequest) |
| 106 | return |
| 107 | } |
| 108 | w.Header().Set("Content-Type", "application/json") |
| 109 | _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": *request.ID, "result": result}) |
| 110 | })) |
| 111 | defer mcpServer.Close() |
| 112 | |
| 113 | ctrl, err := Build(context.Background(), Options{ |
| 114 | Sink: event.Discard, |
| 115 | ExtraPlugins: []plugin.Spec{{ |
| 116 | Name: "vision-reader", |
| 117 | Type: "http", |
| 118 | URL: mcpServer.URL, |
| 119 | Authorized: true, |
| 120 | }}, |
| 121 | }) |
| 122 | if err != nil { |
| 123 | t.Fatalf("Build: %v", err) |
| 124 | } |
| 125 | defer ctrl.Close() |
| 126 | if ctrl.ImageInputEnabled() { |
| 127 | loaded, loadErr := config.LoadForRoot(dir) |
| 128 | resolved, _ := loaded.ResolveModel(ctrl.ModelRef()) |
| 129 | t.Fatalf("official DeepSeek parent unexpectedly enables direct images: ref=%q entry=%+v load_err=%v", ctrl.ModelRef(), resolved, loadErr) |
| 130 | } |
| 131 | if err := ctrl.Run(context.Background(), "review @.reasonix/attachments/shot.png"); err != nil { |
| 132 | t.Fatalf("Run: %v", err) |
| 133 | } |
| 134 | reqs := prov.requestsSnapshot() |
| 135 | if len(reqs) < 4 { |
| 136 | t.Fatalf("provider requests = %d, want parent MCP call, parent subagent call, vision child, and parent final", len(reqs)) |
| 137 | } |
| 138 | if got := mcpCalls.Load(); got != 1 { |
| 139 | t.Fatalf("vision MCP calls = %d, want 1", got) |
| 140 | } |
| 141 | // MCP tools stay behind use_capability; review is registered for dispatch. |
| 142 | if !requestHasTool(reqs[0], "use_capability") { |
| 143 | t.Fatalf("parent tools = %v, want use_capability for MCP/review dispatch", toolSchemaNames(reqs[0].Tools)) |
| 144 | } |
| 145 | registered := map[string]bool{} |
| 146 | for _, e := range ctrl.AllToolContractEntries() { |
| 147 | registered[e.Name] = true |
| 148 | } |
| 149 | if !registered["review"] && !registered["run_skill"] { |
| 150 | t.Fatalf("capability registry missing review/run_skill: %v", registered) |
| 151 | } |
| 152 | if got := bootLastUser(reqs[0]); !strings.Contains(got, "@.reasonix/attachments/shot.png") { |
| 153 | t.Fatalf("text-only parent lost the attachment reference needed by MCP: %q", got) |
| 154 | } |
| 155 | // The text parent receives summaries; native images remain available to |
| 156 | // the independently routed vision child. |
| 157 | for _, requestIndex := range []int{0, 1, len(reqs) - 1} { |
| 158 | for _, msg := range reqs[requestIndex].Messages { |
| 159 | if msg.Role == provider.RoleUser && len(msg.Images) != 0 { |
| 160 | t.Fatalf("text-only parent request %d embedded %d direct attachment(s): %+v", requestIndex, len(msg.Images), reqs[requestIndex].Messages) |
| 161 | } |
| 162 | } |
| 163 | } |
| 164 | if !requestMessageContains(reqs[1].Messages, provider.RoleTool, "vision-mcp-ok") { |
| 165 | t.Fatalf("parent did not receive the vision MCP text result: %+v", reqs[1].Messages) |
| 166 | } |
| 167 | var parentMCPImageCount int |
| 168 | for _, msg := range reqs[1].Messages { |
| 169 | if msg.Role == provider.RoleTool && msg.Name == "mcp__vision-reader__inspect" { |
| 170 | parentMCPImageCount += len(msg.Images) |
| 171 | } |
| 172 | } |
| 173 | if parentMCPImageCount != 0 { |
| 174 | t.Fatalf("text parent MCP result images = %d, want summary only", parentMCPImageCount) |
| 175 | } |
| 176 | var childImageCount int |
| 177 | for _, msg := range reqs[2].Messages { |
| 178 | if msg.Role == provider.RoleUser { |
| 179 | childImageCount = len(msg.Images) |
| 180 | } |
| 181 | } |
| 182 | if childImageCount != 1 { |
| 183 | t.Fatalf("vision child user images = %d, want one attachment; request = %+v", childImageCount, reqs[2].Messages) |
| 184 | } |
| 185 | } |
| 186 |