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