Files
2026-07-13 13:00:08 +08:00

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
}