366 lines
9.4 KiB
Go
366 lines
9.4 KiB
Go
package claudecode
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
)
|
|
|
|
// syncBuffer is a thread-safe bytes.Buffer for tests where a goroutine writes
|
|
// and the test goroutine reads.
|
|
type syncBuffer struct {
|
|
mu sync.Mutex
|
|
buf bytes.Buffer
|
|
}
|
|
|
|
func (sb *syncBuffer) Write(p []byte) (int, error) {
|
|
sb.mu.Lock()
|
|
defer sb.mu.Unlock()
|
|
return sb.buf.Write(p)
|
|
}
|
|
|
|
func (sb *syncBuffer) Len() int {
|
|
sb.mu.Lock()
|
|
defer sb.mu.Unlock()
|
|
return sb.buf.Len()
|
|
}
|
|
|
|
func (sb *syncBuffer) Bytes() []byte {
|
|
sb.mu.Lock()
|
|
defer sb.mu.Unlock()
|
|
return append([]byte(nil), sb.buf.Bytes()...)
|
|
}
|
|
|
|
func newTestLogger() *logger.Logger {
|
|
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stdout"})
|
|
return log
|
|
}
|
|
|
|
func TestClient_SendUserMessage(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
client := NewClient(&buf, strings.NewReader(""), newTestLogger())
|
|
|
|
err := client.SendUserMessage("Hello, Claude!")
|
|
if err != nil {
|
|
t.Fatalf("SendUserMessage() error = %v", err)
|
|
}
|
|
|
|
// Parse what was written
|
|
var msg UserMessage
|
|
if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &msg); err != nil {
|
|
t.Fatalf("failed to parse sent message: %v", err)
|
|
}
|
|
|
|
if msg.Type != MessageTypeUser {
|
|
t.Errorf("Type = %q, want %q", msg.Type, MessageTypeUser)
|
|
}
|
|
if msg.Message.Role != "user" {
|
|
t.Errorf("Message.Role = %q, want %q", msg.Message.Role, "user")
|
|
}
|
|
if msg.Message.Content != "Hello, Claude!" {
|
|
t.Errorf("Message.Content = %q, want %q", msg.Message.Content, "Hello, Claude!")
|
|
}
|
|
}
|
|
|
|
func TestClient_SendControlResponse(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
client := NewClient(&buf, strings.NewReader(""), newTestLogger())
|
|
|
|
resp := &ControlResponseMessage{
|
|
Type: MessageTypeControlResponse,
|
|
RequestID: "req123",
|
|
Response: &ControlResponse{
|
|
Subtype: "success",
|
|
Result: &PermissionResult{
|
|
Behavior: BehaviorAllow,
|
|
},
|
|
},
|
|
}
|
|
|
|
err := client.SendControlResponse(resp)
|
|
if err != nil {
|
|
t.Fatalf("SendControlResponse() error = %v", err)
|
|
}
|
|
|
|
// Parse what was written
|
|
var parsed ControlResponseMessage
|
|
if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed); err != nil {
|
|
t.Fatalf("failed to parse sent message: %v", err)
|
|
}
|
|
|
|
if parsed.RequestID != "req123" {
|
|
t.Errorf("RequestID = %q, want %q", parsed.RequestID, "req123")
|
|
}
|
|
}
|
|
|
|
func TestClient_SendControlRequest(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
client := NewClient(&buf, strings.NewReader(""), newTestLogger())
|
|
|
|
req := &SDKControlRequest{
|
|
Type: MessageTypeControlRequest,
|
|
RequestID: "init123",
|
|
Request: SDKControlRequestBody{
|
|
Subtype: SubtypeInitialize,
|
|
},
|
|
}
|
|
|
|
err := client.SendControlRequest(req)
|
|
if err != nil {
|
|
t.Fatalf("SendControlRequest() error = %v", err)
|
|
}
|
|
|
|
// Parse what was written
|
|
var parsed SDKControlRequest
|
|
if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed); err != nil {
|
|
t.Fatalf("failed to parse sent message: %v", err)
|
|
}
|
|
|
|
if parsed.Request.Subtype != SubtypeInitialize {
|
|
t.Errorf("Request.Subtype = %q, want %q", parsed.Request.Subtype, SubtypeInitialize)
|
|
}
|
|
}
|
|
|
|
func TestClient_HandleMessages(t *testing.T) {
|
|
// Create input with multiple messages
|
|
messages := []string{
|
|
`{"type":"system","session_id":"sess123"}`,
|
|
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello"}]}}`,
|
|
}
|
|
input := strings.Join(messages, "\n") + "\n"
|
|
|
|
var buf bytes.Buffer
|
|
client := NewClient(&buf, strings.NewReader(input), newTestLogger())
|
|
|
|
var received []CLIMessage
|
|
var mu sync.Mutex
|
|
client.SetMessageHandler(func(msg *CLIMessage) {
|
|
mu.Lock()
|
|
received = append(received, *msg)
|
|
mu.Unlock()
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
defer cancel()
|
|
|
|
client.Start(ctx)
|
|
time.Sleep(50 * time.Millisecond) // Give time for processing
|
|
|
|
mu.Lock()
|
|
count := len(received)
|
|
mu.Unlock()
|
|
|
|
if count != 2 {
|
|
t.Errorf("received %d messages, want 2", count)
|
|
}
|
|
}
|
|
|
|
func TestClient_HandleControlRequest(t *testing.T) {
|
|
// Create a control request message
|
|
input := `{"type":"control_request","request_id":"req123","request":{"subtype":"can_use_tool","tool_name":"Bash"}}` + "\n"
|
|
|
|
var buf bytes.Buffer
|
|
client := NewClient(&buf, strings.NewReader(input), newTestLogger())
|
|
|
|
var receivedReq *ControlRequest
|
|
var receivedID string
|
|
var mu sync.Mutex
|
|
|
|
client.SetRequestHandler(func(requestID string, req *ControlRequest) {
|
|
mu.Lock()
|
|
receivedID = requestID
|
|
receivedReq = req
|
|
mu.Unlock()
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
defer cancel()
|
|
|
|
client.Start(ctx)
|
|
time.Sleep(50 * time.Millisecond) // Give time for processing
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
if receivedID != "req123" {
|
|
t.Errorf("requestID = %q, want %q", receivedID, "req123")
|
|
}
|
|
if receivedReq == nil {
|
|
t.Fatal("receivedReq is nil")
|
|
}
|
|
if receivedReq.Subtype != SubtypeCanUseTool {
|
|
t.Errorf("Subtype = %q, want %q", receivedReq.Subtype, SubtypeCanUseTool)
|
|
}
|
|
}
|
|
|
|
func TestClient_Stop(t *testing.T) {
|
|
// Use a pipe for continuous input
|
|
pr, _ := io.Pipe()
|
|
|
|
var buf bytes.Buffer
|
|
client := NewClient(&buf, pr, newTestLogger())
|
|
|
|
ctx := context.Background()
|
|
client.Start(ctx)
|
|
|
|
// Stop should not panic even if called multiple times
|
|
client.Stop()
|
|
client.Stop()
|
|
}
|
|
|
|
func TestClient_NoHandlerAutoReject(t *testing.T) {
|
|
// Create a control request message
|
|
input := `{"type":"control_request","request_id":"req123","request":{"subtype":"can_use_tool","tool_name":"Bash"}}` + "\n"
|
|
|
|
buf := &syncBuffer{}
|
|
client := NewClient(buf, strings.NewReader(input), newTestLogger())
|
|
|
|
// No request handler set - should auto-reject
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
defer cancel()
|
|
|
|
client.Start(ctx)
|
|
time.Sleep(50 * time.Millisecond) // Give time for processing
|
|
|
|
// Should have sent an error response
|
|
if buf.Len() == 0 {
|
|
t.Error("expected error response to be sent")
|
|
}
|
|
|
|
var resp ControlResponseMessage
|
|
if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &resp); err != nil {
|
|
t.Fatalf("failed to parse response: %v", err)
|
|
}
|
|
|
|
if resp.Response == nil || resp.Response.Subtype != "error" {
|
|
t.Error("expected error response")
|
|
}
|
|
}
|
|
|
|
func TestClient_EmptyLines(t *testing.T) {
|
|
// Test that empty lines are skipped
|
|
input := "\n\n{\"type\":\"system\",\"session_id\":\"abc\"}\n\n"
|
|
|
|
var buf bytes.Buffer
|
|
client := NewClient(&buf, strings.NewReader(input), newTestLogger())
|
|
|
|
var count int
|
|
var mu sync.Mutex
|
|
client.SetMessageHandler(func(msg *CLIMessage) {
|
|
mu.Lock()
|
|
count++
|
|
mu.Unlock()
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
defer cancel()
|
|
|
|
client.Start(ctx)
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
if count != 1 {
|
|
t.Errorf("count = %d, want 1", count)
|
|
}
|
|
}
|
|
|
|
func TestClient_InvalidJSON(t *testing.T) {
|
|
// Test that invalid JSON is handled gracefully
|
|
input := "{invalid json}\n{\"type\":\"system\"}\n"
|
|
|
|
var buf bytes.Buffer
|
|
client := NewClient(&buf, strings.NewReader(input), newTestLogger())
|
|
|
|
var count int
|
|
var mu sync.Mutex
|
|
client.SetMessageHandler(func(msg *CLIMessage) {
|
|
mu.Lock()
|
|
count++
|
|
mu.Unlock()
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
defer cancel()
|
|
|
|
client.Start(ctx)
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
// Should still process the valid message
|
|
if count != 1 {
|
|
t.Errorf("count = %d, want 1", count)
|
|
}
|
|
}
|
|
|
|
func TestClient_HandleControlResponse(t *testing.T) {
|
|
// Simulate a control response coming back from Claude Code CLI
|
|
responseJSON := `{"type":"control_response","response":{"subtype":"success","request_id":"test-req-123","response":{"commands":[{"name":"cost","description":"Show cost"}],"agents":["Bash"]}}}` + "\n"
|
|
|
|
var buf bytes.Buffer
|
|
client := NewClient(&buf, strings.NewReader(responseJSON), newTestLogger())
|
|
|
|
// Register a pending request
|
|
pending := &pendingRequest{
|
|
ch: make(chan *IncomingControlResponse, 1),
|
|
}
|
|
client.pendingRequestsMu.Lock()
|
|
client.pendingRequests["test-req-123"] = pending
|
|
client.pendingRequestsMu.Unlock()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
defer cancel()
|
|
|
|
client.Start(ctx)
|
|
|
|
// Wait for the response
|
|
select {
|
|
case resp := <-pending.ch:
|
|
if resp.Subtype != "success" {
|
|
t.Errorf("Subtype = %q, want %q", resp.Subtype, "success")
|
|
}
|
|
if resp.RequestID != "test-req-123" {
|
|
t.Errorf("RequestID = %q, want %q", resp.RequestID, "test-req-123")
|
|
}
|
|
if resp.Response == nil {
|
|
t.Fatal("Response is nil")
|
|
}
|
|
if len(resp.Response.Commands) != 1 {
|
|
t.Errorf("Commands count = %d, want %d", len(resp.Response.Commands), 1)
|
|
}
|
|
if resp.Response.Commands[0].Name != "cost" {
|
|
t.Errorf("Commands[0].Name = %q, want %q", resp.Response.Commands[0].Name, "cost")
|
|
}
|
|
case <-time.After(50 * time.Millisecond):
|
|
t.Error("timeout waiting for control response")
|
|
}
|
|
}
|
|
|
|
func TestClient_HandleControlResponse_UnknownRequest(t *testing.T) {
|
|
// Simulate a control response for an unknown request ID (should be ignored)
|
|
responseJSON := `{"type":"control_response","response":{"subtype":"success","request_id":"unknown-req","response":{"commands":[]}}}` + "\n"
|
|
|
|
var buf bytes.Buffer
|
|
client := NewClient(&buf, strings.NewReader(responseJSON), newTestLogger())
|
|
|
|
// Don't register any pending request - response should be ignored
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
defer cancel()
|
|
|
|
client.Start(ctx)
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
// Test passes if no panic occurs - the unknown response is just logged and ignored
|
|
}
|