返回 DeepSeek-Reasonix
protocol_test.go
根目录 / internal / acp / protocol_test.go
1 package acp
2
3 import (
4 "context"
5 "encoding/json"
6 "path/filepath"
7 "strings"
8 "testing"
9 )
10
11 func TestFlattenPrompt(t *testing.T) {
12 tests := []struct {
13 name string
14 blocks []ContentBlock
15 want string
16 }{
17 {
18 name: "text blocks join with blank line",
19 blocks: []ContentBlock{{Type: "text", Text: "hello"}, {Type: "text", Text: "world"}},
20 want: "hello\n\nworld",
21 },
22 {
23 name: "resource contributes inline text",
24 blocks: []ContentBlock{
25 {Type: "text", Text: "see file:"},
26 {Type: "resource", Resource: &ResourceContents{URI: "file:///x", Text: "contents"}},
27 },
28 want: "see file:\n\ncontents",
29 },
30 {
31 name: "resource without inline text is dropped",
32 blocks: []ContentBlock{
33 {Type: "resource", Resource: &ResourceContents{URI: "file:///x"}},
34 {Type: "text", Text: "only this"},
35 },
36 want: "only this",
37 },
38 {
39 name: "image and audio blocks are ignored",
40 blocks: []ContentBlock{
41 {Type: "image", MimeType: "image/png", Data: "base64"},
42 {Type: "text", Text: "kept"},
43 {Type: "audio", MimeType: "audio/wav", Data: "base64"},
44 },
45 want: "kept",
46 },
47 {
48 name: "surrounding whitespace trimmed",
49 blocks: []ContentBlock{{Type: "text", Text: " spaced "}},
50 want: "spaced",
51 },
52 {
53 name: "empty input",
54 blocks: nil,
55 want: "",
56 },
57 }
58 for _, tt := range tests {
59 t.Run(tt.name, func(t *testing.T) {
60 if got := FlattenPrompt(tt.blocks); got != tt.want {
61 t.Errorf("FlattenPrompt() = %q, want %q", got, tt.want)
62 }
63 })
64 }
65 }
66
67 func TestToolKindFor(t *testing.T) {
68 tests := map[string]string{
69 "read_file": "read",
70 "ls": "read",
71 "glob": "read",
72 "grep": "search",
73 "edit_file": "edit",
74 "move_file": "edit",
75 "multiedit": "edit",
76 "write_file": "edit",
77 "bash": "execute",
78 "webfetch": "other",
79 "task": "other",
80 "mcp__server__do_thing": "other",
81 "semantic_search": "search", // heuristic fallback
82 "run_command": "execute",
83 "unknown": "other",
84 }
85 for name, want := range tests {
86 if got := toolKindFor(name); got != want {
87 t.Errorf("toolKindFor(%q) = %q, want %q", name, got, want)
88 }
89 }
90 }
91
92 // --- newSessionID ---
93
94 func TestNewSessionID(t *testing.T) {
95 id, err := newSessionID()
96 if err != nil {
97 t.Fatalf("newSessionID: %v", err)
98 }
99 parts := strings.Split(id, "-")
100 if len(parts) != 5 {
101 t.Fatalf("UUID format: %q has %d parts, want 5", id, len(parts))
102 }
103 if len(parts[0]) != 8 || len(parts[1]) != 4 || len(parts[2]) != 4 || len(parts[3]) != 4 || len(parts[4]) != 12 {
104 t.Errorf("UUID part lengths: %v", parts)
105 }
106 // Version 4: bits 4-7 of byte 6 are 0100.
107 if id[14] != '4' {
108 t.Errorf("UUID version: char at 14 = %c, want '4'", id[14])
109 }
110 // Variant: bits 6-7 of byte 8 are 10.
111 variant := id[19]
112 if variant != '8' && variant != '9' && variant != 'a' && variant != 'b' {
113 t.Errorf("UUID variant: char at 19 = %c, want 8/9/a/b", variant)
114 }
115 }
116
117 func TestNewSessionIDUnique(t *testing.T) {
118 seen := map[string]bool{}
119 for i := 0; i < 100; i++ {
120 id, err := newSessionID()
121 if err != nil {
122 t.Fatalf("newSessionID: %v", err)
123 }
124 if seen[id] {
125 t.Fatalf("duplicate session id: %s", id)
126 }
127 seen[id] = true
128 }
129 }
130
131 // --- mcpSpecs ---
132
133 func TestMcpSpecsNil(t *testing.T) {
134 if got, err := mcpSpecs(nil, ""); err != nil || got != nil {
135 t.Errorf("mcpSpecs(nil) = %v, want nil", got)
136 }
137 if got, err := mcpSpecs([]MCPServerSpec{}, ""); err != nil || got != nil {
138 t.Errorf("mcpSpecs([]) = %v, want nil", got)
139 }
140 }
141
142 func TestMcpSpecsConversion(t *testing.T) {
143 in := []MCPServerSpec{
144 {Name: "search", Command: "search-mcp", Args: []string{"--stdio"}, Env: MCPEnv{"HOME": "/tmp"}},
145 {Name: "remote", Type: "http", URL: "https://mcp.example.test", Headers: MCPHeaders{"Authorization": "Bearer token"}},
146 }
147 got, err := mcpSpecs(in, "/workspace")
148 if err != nil {
149 t.Fatalf("mcpSpecs: %v", err)
150 }
151 if len(got) != 2 {
152 t.Fatalf("len = %d, want 2", len(got))
153 }
154 if got[0].Name != "search" || got[0].Type != "stdio" || got[0].Command != "search-mcp" {
155 t.Errorf("spec = %+v", got[0])
156 }
157 if got[0].Args[0] != "--stdio" {
158 t.Errorf("args = %v", got[0].Args)
159 }
160 if got[0].Env["HOME"] != "/tmp" {
161 t.Errorf("env = %v", got[0].Env)
162 }
163 if got[0].Dir != "/workspace" {
164 t.Errorf("dir = %q, want /workspace", got[0].Dir)
165 }
166 if got[0].WorkspaceRoot != "/workspace" || got[1].WorkspaceRoot != "/workspace" {
167 t.Errorf("workspace roots = %q, %q, want /workspace", got[0].WorkspaceRoot, got[1].WorkspaceRoot)
168 }
169 if got[1].Name != "remote" || got[1].Type != "http" || got[1].URL != "https://mcp.example.test" {
170 t.Errorf("http spec = %+v", got[1])
171 }
172 if got[1].Headers["Authorization"] != "Bearer token" {
173 t.Errorf("headers = %v", got[1].Headers)
174 }
175 }
176
177 func TestMCPEnvAcceptsOfficialArrayShape(t *testing.T) {
178 var p SessionNewParams
179 raw := []byte(`{
180 "cwd":"/tmp",
181 "mcpServers":[{
182 "name":"fs",
183 "command":"mcp-fs",
184 "args":["--stdio"],
185 "env":[{"name":"HOME","value":"/tmp"},{"name":"EMPTY","value":""}]
186 }]
187 }`)
188 if err := json.Unmarshal(raw, &p); err != nil {
189 t.Fatalf("unmarshal official env array: %v", err)
190 }
191 got, err := mcpSpecs(p.MCPServers, p.Cwd)
192 if err != nil {
193 t.Fatalf("mcpSpecs: %v", err)
194 }
195 if got[0].Env["HOME"] != "/tmp" || got[0].Env["EMPTY"] != "" {
196 t.Fatalf("env = %v, want HOME and EMPTY from official array", got[0].Env)
197 }
198 }
199
200 func TestMCPHeadersAcceptsOfficialArrayShape(t *testing.T) {
201 var p SessionNewParams
202 raw := []byte(`{
203 "cwd":"/tmp",
204 "mcpServers":[{
205 "name":"remote",
206 "type":"http",
207 "url":"https://mcp.example.test",
208 "headers":[{"name":"Authorization","value":"Bearer token"},{"name":"X-Trace","value":""}]
209 }]
210 }`)
211 if err := json.Unmarshal(raw, &p); err != nil {
212 t.Fatalf("unmarshal official headers array: %v", err)
213 }
214 got, err := mcpSpecs(p.MCPServers, p.Cwd)
215 if err != nil {
216 t.Fatalf("mcpSpecs: %v", err)
217 }
218 if got[0].Headers["Authorization"] != "Bearer token" || got[0].Headers["X-Trace"] != "" {
219 t.Fatalf("headers = %v, want Authorization and X-Trace from official array", got[0].Headers)
220 }
221 }
222
223 func TestMCPHeadersAcceptsEmptyArray(t *testing.T) {
224 var p SessionNewParams
225 raw := []byte(`{
226 "cwd":"/tmp",
227 "mcpServers":[{
228 "name":"remote",
229 "type":"http",
230 "url":"https://mcp.example.test",
231 "headers":[]
232 }]
233 }`)
234 if err := json.Unmarshal(raw, &p); err != nil {
235 t.Fatalf("unmarshal empty headers array (paseo-shape): %v", err)
236 }
237 if len(p.MCPServers[0].Headers) != 0 {
238 t.Fatalf("headers = %v, want empty", p.MCPServers[0].Headers)
239 }
240 }
241
242 func TestMCPHeadersAcceptsLegacyMap(t *testing.T) {
243 var p SessionNewParams
244 raw := []byte(`{
245 "cwd":"/tmp",
246 "mcpServers":[{
247 "name":"remote",
248 "type":"http",
249 "url":"https://mcp.example.test",
250 "headers":{"Authorization":"Bearer token"}
251 }]
252 }`)
253 if err := json.Unmarshal(raw, &p); err != nil {
254 t.Fatalf("unmarshal legacy headers map: %v", err)
255 }
256 if p.MCPServers[0].Headers["Authorization"] != "Bearer token" {
257 t.Fatalf("headers = %v, want legacy-map value", p.MCPServers[0].Headers)
258 }
259 }
260
261 func TestMcpSpecsRejectsUnsupportedTransport(t *testing.T) {
262 got, err := mcpSpecs([]MCPServerSpec{{Name: "remote", Type: "sse", URL: "https://example.test/sse"}}, "/tmp")
263 if err != nil || len(got) != 1 || got[0].Type != "sse" {
264 t.Fatalf("mcpSpecs legacy SSE = %+v, %v", got, err)
265 }
266 _, err = mcpSpecs([]MCPServerSpec{{Name: "remote", Type: "websocket", URL: "https://example.test/ws"}}, "/tmp")
267 if err == nil || !strings.Contains(err.Error(), "unsupported transport") {
268 t.Fatalf("mcpSpecs unsupported transport err = %v", err)
269 }
270 _, err = mcpSpecs([]MCPServerSpec{{Name: "remote", Type: "http"}}, "/tmp")
271 if err == nil || !strings.Contains(err.Error(), "url is required") {
272 t.Fatalf("mcpSpecs missing url err = %v", err)
273 }
274 }
275
276 // --- transcriptPath ---
277
278 func TestTranscriptPath(t *testing.T) {
279 dir := t.TempDir()
280 got := transcriptPath(dir, "abc-123")
281 if want := filepath.Join(dir, "abc-123.jsonl"); got != want {
282 t.Errorf("transcriptPath = %q, want %q", got, want)
283 }
284 }
285
286 // --- Protocol constants ---
287
288 func TestProtocolVersion(t *testing.T) {
289 if ProtocolVersion != 1 {
290 t.Errorf("ProtocolVersion = %d", ProtocolVersion)
291 }
292 }
293
294 func TestErrorCodes(t *testing.T) {
295 if ErrParse != -32700 {
296 t.Errorf("ErrParse = %d", ErrParse)
297 }
298 if ErrInvalidRequest != -32600 {
299 t.Errorf("ErrInvalidRequest = %d", ErrInvalidRequest)
300 }
301 if ErrMethodNotFound != -32601 {
302 t.Errorf("ErrMethodNotFound = %d", ErrMethodNotFound)
303 }
304 if ErrInvalidParams != -32602 {
305 t.Errorf("ErrInvalidParams = %d", ErrInvalidParams)
306 }
307 if ErrInternal != -32603 {
308 t.Errorf("ErrInternal = %d", ErrInternal)
309 }
310 }
311
312 // --- acpSession ---
313
314 func TestAcpSessionSetCancelAbort(t *testing.T) {
315 sess := &acpSession{id: "test"}
316 aborted := false
317 _, cancel, ok := sess.begin(context.Background())
318 if !ok {
319 t.Fatal("begin should succeed")
320 }
321 sess.mu.Lock()
322 sess.cancel = func() {
323 aborted = true
324 cancel()
325 }
326 sess.mu.Unlock()
327 sess.abort()
328 if !aborted {
329 t.Error("abort should call the cancel func")
330 }
331 }
332
333 func TestAcpSessionAbortNil(t *testing.T) {
334 sess := &acpSession{id: "test"}
335 sess.abort() // should not panic
336 }
337
338 func TestAcpSessionSetCancelNil(t *testing.T) {
339 sess := &acpSession{id: "test"}
340 sess.finish()
341 sess.abort() // should not panic
342 }
343
343 lines GO