返回 DeepSeek-Reasonix
attachment_routing_test.go
根目录 / internal / boot / attachment_routing_test.go
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 approveWorkspace(t, dir)
54 png, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==")
55 if err != nil {
56 t.Fatalf("decode test png: %v", err)
57 }
58 if err := os.MkdirAll(filepath.Join(dir, ".reasonix", "attachments"), 0o755); err != nil {
59 t.Fatal(err)
60 }
61 if err := os.WriteFile(filepath.Join(dir, ".reasonix", "attachments", "shot.png"), png, 0o644); err != nil {
62 t.Fatal(err)
63 }
64 mcpImage := base64.StdEncoding.EncodeToString(png)
65 var mcpCalls atomic.Int32
66 mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
67 var request struct {
68 ID *int `json:"id"`
69 Method string `json:"method"`
70 Params json.RawMessage `json:"params"`
71 }
72 if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
73 http.Error(w, "bad request", http.StatusBadRequest)
74 return
75 }
76 if request.ID == nil {
77 w.WriteHeader(http.StatusAccepted)
78 return
79 }
80 var result any
81 switch request.Method {
82 case "initialize":
83 result = map[string]any{
84 "protocolVersion": "2024-11-05",
85 "serverInfo": map[string]any{"name": "vision-reader", "version": "1"},
86 "capabilities": map[string]any{"tools": map[string]any{}},
87 }
88 case "tools/list":
89 result = map[string]any{"tools": []map[string]any{{
90 "name": "inspect",
91 "description": "Inspect an image file by path.",
92 "inputSchema": map[string]any{
93 "type": "object",
94 "properties": map[string]any{"path": map[string]any{"type": "string"}},
95 "required": []string{"path"},
96 },
97 "annotations": map[string]any{"readOnlyHint": true},
98 }}}
99 case "tools/call":
100 mcpCalls.Add(1)
101 result = map[string]any{"content": []map[string]any{
102 {"type": "text", "text": "vision-mcp-ok"},
103 {"type": "image", "mimeType": "image/png", "data": mcpImage},
104 }}
105 default:
106 http.Error(w, "unsupported method", http.StatusBadRequest)
107 return
108 }
109 w.Header().Set("Content-Type", "application/json")
110 _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": *request.ID, "result": result})
111 }))
112 defer mcpServer.Close()
113
114 ctrl, err := Build(context.Background(), Options{
115 Sink: event.Discard,
116 ExtraPlugins: []plugin.Spec{{
117 Name: "vision-reader",
118 Type: "http",
119 URL: mcpServer.URL,
120 Authorized: true,
121 }},
122 })
123 if err != nil {
124 t.Fatalf("Build: %v", err)
125 }
126 defer ctrl.Close()
127 if ctrl.ImageInputEnabled() {
128 loaded, loadErr := config.LoadForRoot(dir)
129 resolved, _ := loaded.ResolveModel(ctrl.ModelRef())
130 t.Fatalf("official DeepSeek parent unexpectedly enables direct images: ref=%q entry=%+v load_err=%v", ctrl.ModelRef(), resolved, loadErr)
131 }
132 if err := ctrl.Run(context.Background(), "review @.reasonix/attachments/shot.png"); err != nil {
133 t.Fatalf("Run: %v", err)
134 }
135 reqs := prov.requestsSnapshot()
136 if len(reqs) < 4 {
137 t.Fatalf("provider requests = %d, want parent MCP call, parent subagent call, vision child, and parent final", len(reqs))
138 }
139 if got := mcpCalls.Load(); got != 1 {
140 t.Fatalf("vision MCP calls = %d, want 1", got)
141 }
142 // MCP tools stay behind use_capability; review is registered for dispatch.
143 if !requestHasTool(reqs[0], "use_capability") {
144 t.Fatalf("parent tools = %v, want use_capability for MCP/review dispatch", toolSchemaNames(reqs[0].Tools))
145 }
146 registered := map[string]bool{}
147 for _, e := range ctrl.AllToolContractEntries() {
148 registered[e.Name] = true
149 }
150 if !registered["review"] && !registered["run_skill"] {
151 t.Fatalf("capability registry missing review/run_skill: %v", registered)
152 }
153 if got := bootLastUser(reqs[0]); !strings.Contains(got, "@.reasonix/attachments/shot.png") {
154 t.Fatalf("text-only parent lost the attachment reference needed by MCP: %q", got)
155 }
156 // The text parent receives summaries; native images remain available to
157 // the independently routed vision child.
158 for _, requestIndex := range []int{0, 1, len(reqs) - 1} {
159 for _, msg := range reqs[requestIndex].Messages {
160 if msg.Role == provider.RoleUser && len(msg.Images) != 0 {
161 t.Fatalf("text-only parent request %d embedded %d direct attachment(s): %+v", requestIndex, len(msg.Images), reqs[requestIndex].Messages)
162 }
163 }
164 }
165 if !requestMessageContains(reqs[1].Messages, provider.RoleTool, "vision-mcp-ok") {
166 t.Fatalf("parent did not receive the vision MCP text result: %+v", reqs[1].Messages)
167 }
168 var parentMCPImageCount int
169 for _, msg := range reqs[1].Messages {
170 if msg.Role == provider.RoleTool && msg.Name == "mcp__vision-reader__inspect" {
171 parentMCPImageCount += len(msg.Images)
172 }
173 }
174 if parentMCPImageCount != 0 {
175 t.Fatalf("text parent MCP result images = %d, want summary only", parentMCPImageCount)
176 }
177 var childImageCount int
178 for _, msg := range reqs[2].Messages {
179 if msg.Role == provider.RoleUser {
180 childImageCount = len(msg.Images)
181 }
182 }
183 if childImageCount != 1 {
184 t.Fatalf("vision child user images = %d, want one attachment; request = %+v", childImageCount, reqs[2].Messages)
185 }
186 }
187
187 lines GO