rdev/internal/adapter/codeagent/claudecode/adapter_test.go
jordan 39df51defd feat: Add multi-provider code agent interface with Claude Code and OpenCode adapters
Implements weeks 1-4 of the multi-provider architecture:

Week 1 - Foundation:
- Add domain models (AgentProvider, AgentRequest, AgentEvent, AgentResult)
- Define CodeAgent port interface with Execute, Cancel, Capabilities
- Create thread-safe provider registry with first-registered default

Week 2 - Claude Code Adapter:
- Extract kubectl exec logic into CodeAgent implementation
- Parse stream-json output format (init, message, tool_use, result)
- Support session continuation via --resume flag

Week 3 - OpenCode Adapter:
- HTTP/SSE client for opencode serve API
- Session management (create, send message, abort)
- Event streaming with documented buffer rationale

Week 4 - Quality & Polish:
- Fix race condition in OpenCode Cancel method
- Add AgentRequest.Validate() with ErrPromptRequired, ErrInvalidTimeout
- Document DefaultAvailabilityTimeout constants
- Add HTTP error context for debugging

Also includes:
- Work queue system with PostgreSQL adapter
- Credential store for infrastructure secrets
- Project templates with Woodpecker CI integration
- Comprehensive test coverage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 09:25:51 -07:00

290 lines
7.5 KiB
Go

package claudecode
import (
"context"
"strings"
"testing"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
// Ensure Adapter implements port.CodeAgent at compile time.
var _ port.CodeAgent = (*Adapter)(nil)
func TestAdapter_Name(t *testing.T) {
adapter := NewAdapter("test-ns")
if name := adapter.Name(); name != "Claude Code" {
t.Errorf("expected name 'Claude Code', got %q", name)
}
}
func TestAdapter_Provider(t *testing.T) {
adapter := NewAdapter("test-ns")
if p := adapter.Provider(); p != domain.AgentProviderClaudeCode {
t.Errorf("expected provider 'claudecode', got %q", p)
}
}
func TestAdapter_Capabilities(t *testing.T) {
adapter := NewAdapter("test-ns")
caps := adapter.Capabilities()
if caps.Provider != domain.AgentProviderClaudeCode {
t.Errorf("expected provider claudecode")
}
if !caps.SupportsSessionContinuation {
t.Error("expected session continuation support")
}
if caps.SupportsModelSelection {
t.Error("expected no model selection support")
}
if !caps.SupportsToolControl {
t.Error("expected tool control support")
}
if !caps.SupportsStreaming {
t.Error("expected streaming support")
}
if len(caps.SupportedModels) == 0 {
t.Error("expected at least one supported model")
}
}
func TestAdapter_buildCommandArgs_Basic(t *testing.T) {
adapter := NewAdapter("rdev")
req := &domain.AgentRequest{
Prompt: "Hello, Claude",
}
args := adapter.buildCommandArgs("rdev", "pod-123", req)
// Verify essential args are present
argsStr := strings.Join(args, " ")
if !strings.Contains(argsStr, "exec -n rdev pod-123") {
t.Error("expected kubectl exec command")
}
if !strings.Contains(argsStr, "claude") {
t.Error("expected claude command")
}
if !strings.Contains(argsStr, "-p") {
t.Error("expected -p flag")
}
if !strings.Contains(argsStr, "--output-format stream-json") {
t.Error("expected stream-json output format")
}
if !strings.Contains(argsStr, "--dangerously-skip-permissions") {
t.Error("expected permission skip flag")
}
if !strings.Contains(argsStr, "Hello, Claude") {
t.Error("expected prompt in args")
}
}
func TestAdapter_buildCommandArgs_WithSessionResume(t *testing.T) {
adapter := NewAdapter("rdev")
req := &domain.AgentRequest{
Prompt: "Continue working",
SessionID: "session-abc",
}
args := adapter.buildCommandArgs("rdev", "pod-123", req)
argsStr := strings.Join(args, " ")
if !strings.Contains(argsStr, "--resume session-abc") {
t.Error("expected --resume flag with session ID")
}
}
func TestAdapter_buildCommandArgs_WithAllowedTools(t *testing.T) {
adapter := NewAdapter("rdev")
req := &domain.AgentRequest{
Prompt: "Run tests",
AllowedTools: []string{"Bash", "Read", "Edit"},
}
args := adapter.buildCommandArgs("rdev", "pod-123", req)
argsStr := strings.Join(args, " ")
if !strings.Contains(argsStr, "--allowedTools Bash") {
t.Error("expected --allowedTools Bash")
}
if !strings.Contains(argsStr, "--allowedTools Read") {
t.Error("expected --allowedTools Read")
}
if !strings.Contains(argsStr, "--allowedTools Edit") {
t.Error("expected --allowedTools Edit")
}
}
func TestAdapter_buildCommandArgs_WithWorkingDir(t *testing.T) {
adapter := NewAdapter("rdev")
req := &domain.AgentRequest{
Prompt: "List files",
WorkingDir: "/workspace/subdir",
}
args := adapter.buildCommandArgs("rdev", "pod-123", req)
argsStr := strings.Join(args, " ")
if !strings.Contains(argsStr, "--add-dir /workspace/subdir") {
t.Error("expected --add-dir for non-default working directory")
}
}
func TestAdapter_buildCommandArgs_DefaultWorkingDir(t *testing.T) {
adapter := NewAdapter("rdev")
req := &domain.AgentRequest{
Prompt: "List files",
WorkingDir: "/workspace", // default
}
args := adapter.buildCommandArgs("rdev", "pod-123", req)
argsStr := strings.Join(args, " ")
// Should NOT have --add-dir for default workspace
if strings.Contains(argsStr, "--add-dir") {
t.Error("expected no --add-dir for default workspace")
}
}
func TestAdapter_Execute_MissingPrompt(t *testing.T) {
adapter := NewAdapter("rdev")
req := &domain.AgentRequest{
Prompt: "", // missing
Metadata: map[string]string{
"pod_name": "test-pod",
},
}
_, err := adapter.Execute(context.Background(), req, func(e domain.AgentEvent) {})
if err == nil {
t.Error("expected error for missing prompt")
}
if !strings.Contains(err.Error(), "prompt is required") {
t.Errorf("expected 'prompt is required' error, got: %v", err)
}
}
func TestAdapter_Execute_MissingPodName(t *testing.T) {
adapter := NewAdapter("rdev")
req := &domain.AgentRequest{
Prompt: "Hello",
Metadata: map[string]string{}, // no pod_name
}
result, _ := adapter.Execute(context.Background(), req, func(e domain.AgentEvent) {})
if result.Error == nil {
t.Error("expected error for missing pod_name")
}
if !strings.Contains(result.Error.Error(), "pod_name") {
t.Errorf("expected pod_name error, got: %v", result.Error)
}
}
func TestAdapter_Cancel(t *testing.T) {
adapter := NewAdapter("rdev")
// Cancel non-existent session should not error
err := adapter.Cancel(context.Background(), "nonexistent")
if err != nil {
t.Errorf("expected no error for non-existent session, got: %v", err)
}
}
func TestAdapter_parseStreamOutput(t *testing.T) {
adapter := NewAdapter("rdev")
input := strings.NewReader(`{"type":"init","session_id":"test-123"}
{"type":"message","role":"assistant","content":[{"type":"text","text":"Hello!"}]}
{"type":"result","status":"success","duration_ms":100}
`)
var events []domain.AgentEvent
handler := func(e domain.AgentEvent) {
events = append(events, e)
}
var output strings.Builder
resultMsg, err := adapter.parseStreamOutput(input, handler, &output)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(events) != 3 {
t.Errorf("expected 3 events, got %d", len(events))
}
// Verify event types
if events[0].Type != domain.AgentEventOutput {
t.Errorf("expected first event to be output (init)")
}
if events[1].Type != domain.AgentEventOutput {
t.Errorf("expected second event to be output (message)")
}
if events[2].Type != domain.AgentEventComplete {
t.Errorf("expected third event to be complete (result)")
}
// Verify result message
if resultMsg == nil {
t.Fatal("expected result message")
}
if !resultMsg.IsSuccess() {
t.Error("expected success result")
}
// Verify output was collected
if !strings.Contains(output.String(), "Hello!") {
t.Error("expected output to contain assistant message")
}
}
func TestAdapter_parseStreamOutput_PlainText(t *testing.T) {
adapter := NewAdapter("rdev")
// Non-JSON lines should be treated as plain output
input := strings.NewReader(`Not JSON output
Another plain line
{"type":"result","status":"success"}
`)
var events []domain.AgentEvent
handler := func(e domain.AgentEvent) {
events = append(events, e)
}
var output strings.Builder
_, err := adapter.parseStreamOutput(input, handler, &output)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(events) != 3 {
t.Errorf("expected 3 events, got %d", len(events))
}
// First two should be plain output events
if events[0].Content != "Not JSON output" {
t.Errorf("expected plain text content")
}
if events[0].Stream != "stdout" {
t.Errorf("expected stdout stream for plain text")
}
}
func TestGenerateSessionID(t *testing.T) {
id1 := generateSessionID()
id2 := generateSessionID()
if id1 == id2 {
t.Error("expected unique session IDs")
}
if !strings.HasPrefix(id1, "claude-") {
t.Errorf("expected session ID to start with 'claude-', got %q", id1)
}
}