336 lines
8.8 KiB
Go
336 lines
8.8 KiB
Go
package acp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestFlattenPrompt(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
blocks []ContentBlock
|
|
want string
|
|
}{
|
|
{
|
|
name: "text blocks join with blank line",
|
|
blocks: []ContentBlock{{Type: "text", Text: "hello"}, {Type: "text", Text: "world"}},
|
|
want: "hello\n\nworld",
|
|
},
|
|
{
|
|
name: "resource contributes inline text",
|
|
blocks: []ContentBlock{
|
|
{Type: "text", Text: "see file:"},
|
|
{Type: "resource", Resource: &ResourceContents{URI: "file:///x", Text: "contents"}},
|
|
},
|
|
want: "see file:\n\ncontents",
|
|
},
|
|
{
|
|
name: "resource without inline text is dropped",
|
|
blocks: []ContentBlock{
|
|
{Type: "resource", Resource: &ResourceContents{URI: "file:///x"}},
|
|
{Type: "text", Text: "only this"},
|
|
},
|
|
want: "only this",
|
|
},
|
|
{
|
|
name: "image and audio blocks are ignored",
|
|
blocks: []ContentBlock{
|
|
{Type: "image", MimeType: "image/png", Data: "base64"},
|
|
{Type: "text", Text: "kept"},
|
|
{Type: "audio", MimeType: "audio/wav", Data: "base64"},
|
|
},
|
|
want: "kept",
|
|
},
|
|
{
|
|
name: "surrounding whitespace trimmed",
|
|
blocks: []ContentBlock{{Type: "text", Text: " spaced "}},
|
|
want: "spaced",
|
|
},
|
|
{
|
|
name: "empty input",
|
|
blocks: nil,
|
|
want: "",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := FlattenPrompt(tt.blocks); got != tt.want {
|
|
t.Errorf("FlattenPrompt() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestToolKindFor(t *testing.T) {
|
|
tests := map[string]string{
|
|
"read_file": "read",
|
|
"ls": "read",
|
|
"glob": "read",
|
|
"grep": "search",
|
|
"edit_file": "edit",
|
|
"move_file": "edit",
|
|
"multiedit": "edit",
|
|
"write_file": "edit",
|
|
"bash": "execute",
|
|
"webfetch": "other",
|
|
"task": "other",
|
|
"mcp__server__do_thing": "other",
|
|
"semantic_search": "search", // heuristic fallback
|
|
"run_command": "execute",
|
|
"unknown": "other",
|
|
}
|
|
for name, want := range tests {
|
|
if got := toolKindFor(name); got != want {
|
|
t.Errorf("toolKindFor(%q) = %q, want %q", name, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- newSessionID ---
|
|
|
|
func TestNewSessionID(t *testing.T) {
|
|
id, err := newSessionID()
|
|
if err != nil {
|
|
t.Fatalf("newSessionID: %v", err)
|
|
}
|
|
parts := strings.Split(id, "-")
|
|
if len(parts) != 5 {
|
|
t.Fatalf("UUID format: %q has %d parts, want 5", id, len(parts))
|
|
}
|
|
if len(parts[0]) != 8 || len(parts[1]) != 4 || len(parts[2]) != 4 || len(parts[3]) != 4 || len(parts[4]) != 12 {
|
|
t.Errorf("UUID part lengths: %v", parts)
|
|
}
|
|
// Version 4: bits 4-7 of byte 6 are 0100.
|
|
if id[14] != '4' {
|
|
t.Errorf("UUID version: char at 14 = %c, want '4'", id[14])
|
|
}
|
|
// Variant: bits 6-7 of byte 8 are 10.
|
|
variant := id[19]
|
|
if variant != '8' && variant != '9' && variant != 'a' && variant != 'b' {
|
|
t.Errorf("UUID variant: char at 19 = %c, want 8/9/a/b", variant)
|
|
}
|
|
}
|
|
|
|
func TestNewSessionIDUnique(t *testing.T) {
|
|
seen := map[string]bool{}
|
|
for i := 0; i < 100; i++ {
|
|
id, err := newSessionID()
|
|
if err != nil {
|
|
t.Fatalf("newSessionID: %v", err)
|
|
}
|
|
if seen[id] {
|
|
t.Fatalf("duplicate session id: %s", id)
|
|
}
|
|
seen[id] = true
|
|
}
|
|
}
|
|
|
|
// --- mcpSpecs ---
|
|
|
|
func TestMcpSpecsNil(t *testing.T) {
|
|
if got, err := mcpSpecs(nil, ""); err != nil || got != nil {
|
|
t.Errorf("mcpSpecs(nil) = %v, want nil", got)
|
|
}
|
|
if got, err := mcpSpecs([]MCPServerSpec{}, ""); err != nil || got != nil {
|
|
t.Errorf("mcpSpecs([]) = %v, want nil", got)
|
|
}
|
|
}
|
|
|
|
func TestMcpSpecsConversion(t *testing.T) {
|
|
in := []MCPServerSpec{
|
|
{Name: "search", Command: "search-mcp", Args: []string{"--stdio"}, Env: MCPEnv{"HOME": "/tmp"}},
|
|
{Name: "remote", Type: "http", URL: "https://mcp.example.test", Headers: MCPHeaders{"Authorization": "Bearer token"}},
|
|
}
|
|
got, err := mcpSpecs(in, "/workspace")
|
|
if err != nil {
|
|
t.Fatalf("mcpSpecs: %v", err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("len = %d, want 2", len(got))
|
|
}
|
|
if got[0].Name != "search" || got[0].Type != "stdio" || got[0].Command != "search-mcp" {
|
|
t.Errorf("spec = %+v", got[0])
|
|
}
|
|
if got[0].Args[0] != "--stdio" {
|
|
t.Errorf("args = %v", got[0].Args)
|
|
}
|
|
if got[0].Env["HOME"] != "/tmp" {
|
|
t.Errorf("env = %v", got[0].Env)
|
|
}
|
|
if got[0].Dir != "/workspace" {
|
|
t.Errorf("dir = %q, want /workspace", got[0].Dir)
|
|
}
|
|
if got[1].Name != "remote" || got[1].Type != "http" || got[1].URL != "https://mcp.example.test" {
|
|
t.Errorf("http spec = %+v", got[1])
|
|
}
|
|
if got[1].Headers["Authorization"] != "Bearer token" {
|
|
t.Errorf("headers = %v", got[1].Headers)
|
|
}
|
|
}
|
|
|
|
func TestMCPEnvAcceptsOfficialArrayShape(t *testing.T) {
|
|
var p SessionNewParams
|
|
raw := []byte(`{
|
|
"cwd":"/tmp",
|
|
"mcpServers":[{
|
|
"name":"fs",
|
|
"command":"mcp-fs",
|
|
"args":["--stdio"],
|
|
"env":[{"name":"HOME","value":"/tmp"},{"name":"EMPTY","value":""}]
|
|
}]
|
|
}`)
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
t.Fatalf("unmarshal official env array: %v", err)
|
|
}
|
|
got, err := mcpSpecs(p.MCPServers, p.Cwd)
|
|
if err != nil {
|
|
t.Fatalf("mcpSpecs: %v", err)
|
|
}
|
|
if got[0].Env["HOME"] != "/tmp" || got[0].Env["EMPTY"] != "" {
|
|
t.Fatalf("env = %v, want HOME and EMPTY from official array", got[0].Env)
|
|
}
|
|
}
|
|
|
|
func TestMCPHeadersAcceptsOfficialArrayShape(t *testing.T) {
|
|
var p SessionNewParams
|
|
raw := []byte(`{
|
|
"cwd":"/tmp",
|
|
"mcpServers":[{
|
|
"name":"remote",
|
|
"type":"http",
|
|
"url":"https://mcp.example.test",
|
|
"headers":[{"name":"Authorization","value":"Bearer token"},{"name":"X-Trace","value":""}]
|
|
}]
|
|
}`)
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
t.Fatalf("unmarshal official headers array: %v", err)
|
|
}
|
|
got, err := mcpSpecs(p.MCPServers, p.Cwd)
|
|
if err != nil {
|
|
t.Fatalf("mcpSpecs: %v", err)
|
|
}
|
|
if got[0].Headers["Authorization"] != "Bearer token" || got[0].Headers["X-Trace"] != "" {
|
|
t.Fatalf("headers = %v, want Authorization and X-Trace from official array", got[0].Headers)
|
|
}
|
|
}
|
|
|
|
func TestMCPHeadersAcceptsEmptyArray(t *testing.T) {
|
|
var p SessionNewParams
|
|
raw := []byte(`{
|
|
"cwd":"/tmp",
|
|
"mcpServers":[{
|
|
"name":"remote",
|
|
"type":"http",
|
|
"url":"https://mcp.example.test",
|
|
"headers":[]
|
|
}]
|
|
}`)
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
t.Fatalf("unmarshal empty headers array (paseo-shape): %v", err)
|
|
}
|
|
if len(p.MCPServers[0].Headers) != 0 {
|
|
t.Fatalf("headers = %v, want empty", p.MCPServers[0].Headers)
|
|
}
|
|
}
|
|
|
|
func TestMCPHeadersAcceptsLegacyMap(t *testing.T) {
|
|
var p SessionNewParams
|
|
raw := []byte(`{
|
|
"cwd":"/tmp",
|
|
"mcpServers":[{
|
|
"name":"remote",
|
|
"type":"http",
|
|
"url":"https://mcp.example.test",
|
|
"headers":{"Authorization":"Bearer token"}
|
|
}]
|
|
}`)
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
t.Fatalf("unmarshal legacy headers map: %v", err)
|
|
}
|
|
if p.MCPServers[0].Headers["Authorization"] != "Bearer token" {
|
|
t.Fatalf("headers = %v, want legacy-map value", p.MCPServers[0].Headers)
|
|
}
|
|
}
|
|
|
|
func TestMcpSpecsRejectsUnsupportedTransport(t *testing.T) {
|
|
_, err := mcpSpecs([]MCPServerSpec{{Name: "remote", Type: "sse", URL: "https://example.test/sse"}}, "/tmp")
|
|
if err == nil || !strings.Contains(err.Error(), "unsupported transport") {
|
|
t.Fatalf("mcpSpecs unsupported transport err = %v", err)
|
|
}
|
|
_, err = mcpSpecs([]MCPServerSpec{{Name: "remote", Type: "http"}}, "/tmp")
|
|
if err == nil || !strings.Contains(err.Error(), "url is required") {
|
|
t.Fatalf("mcpSpecs missing url err = %v", err)
|
|
}
|
|
}
|
|
|
|
// --- transcriptPath ---
|
|
|
|
func TestTranscriptPath(t *testing.T) {
|
|
dir := t.TempDir()
|
|
got := transcriptPath(dir, "abc-123")
|
|
if want := filepath.Join(dir, "abc-123.jsonl"); got != want {
|
|
t.Errorf("transcriptPath = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// --- Protocol constants ---
|
|
|
|
func TestProtocolVersion(t *testing.T) {
|
|
if ProtocolVersion != 1 {
|
|
t.Errorf("ProtocolVersion = %d", ProtocolVersion)
|
|
}
|
|
}
|
|
|
|
func TestErrorCodes(t *testing.T) {
|
|
if ErrParse != -32700 {
|
|
t.Errorf("ErrParse = %d", ErrParse)
|
|
}
|
|
if ErrInvalidRequest != -32600 {
|
|
t.Errorf("ErrInvalidRequest = %d", ErrInvalidRequest)
|
|
}
|
|
if ErrMethodNotFound != -32601 {
|
|
t.Errorf("ErrMethodNotFound = %d", ErrMethodNotFound)
|
|
}
|
|
if ErrInvalidParams != -32602 {
|
|
t.Errorf("ErrInvalidParams = %d", ErrInvalidParams)
|
|
}
|
|
if ErrInternal != -32603 {
|
|
t.Errorf("ErrInternal = %d", ErrInternal)
|
|
}
|
|
}
|
|
|
|
// --- acpSession ---
|
|
|
|
func TestAcpSessionSetCancelAbort(t *testing.T) {
|
|
sess := &acpSession{id: "test"}
|
|
aborted := false
|
|
_, cancel, ok := sess.begin(context.Background())
|
|
if !ok {
|
|
t.Fatal("begin should succeed")
|
|
}
|
|
sess.mu.Lock()
|
|
sess.cancel = func() {
|
|
aborted = true
|
|
cancel()
|
|
}
|
|
sess.mu.Unlock()
|
|
sess.abort()
|
|
if !aborted {
|
|
t.Error("abort should call the cancel func")
|
|
}
|
|
}
|
|
|
|
func TestAcpSessionAbortNil(t *testing.T) {
|
|
sess := &acpSession{id: "test"}
|
|
sess.abort() // should not panic
|
|
}
|
|
|
|
func TestAcpSessionSetCancelNil(t *testing.T) {
|
|
sess := &acpSession{id: "test"}
|
|
sess.finish()
|
|
sess.abort() // should not panic
|
|
}
|