rdev/internal/adapter/codeagent/opencode/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

463 lines
12 KiB
Go

package opencode
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"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(ClientConfig{})
if name := adapter.Name(); name != "OpenCode" {
t.Errorf("expected name 'OpenCode', got %q", name)
}
}
func TestAdapter_Provider(t *testing.T) {
adapter := NewAdapter(ClientConfig{})
if p := adapter.Provider(); p != domain.AgentProviderOpenCode {
t.Errorf("expected provider 'opencode', got %q", p)
}
}
func TestAdapter_Capabilities(t *testing.T) {
adapter := NewAdapter(ClientConfig{})
caps := adapter.Capabilities()
if caps.Provider != domain.AgentProviderOpenCode {
t.Errorf("expected provider opencode")
}
if !caps.SupportsSessionContinuation {
t.Error("expected session continuation support")
}
if !caps.SupportsModelSelection {
t.Error("expected 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_Execute_MissingPrompt(t *testing.T) {
adapter := NewAdapter(ClientConfig{})
req := &domain.AgentRequest{
Prompt: "", // missing
}
_, err := adapter.Execute(context.Background(), req, func(e domain.AgentEvent) {})
if err == nil {
t.Error("expected error for missing prompt")
}
}
func TestAdapter_Available_Healthy(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/global/health" {
json.NewEncoder(w).Encode(HealthResponse{Healthy: true, Version: "1.0.0"})
return
}
http.NotFound(w, r)
}))
defer server.Close()
adapter := NewAdapter(ClientConfig{BaseURL: server.URL})
if !adapter.Available(context.Background()) {
t.Error("expected adapter to be available")
}
}
func TestAdapter_Available_Unhealthy(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/global/health" {
json.NewEncoder(w).Encode(HealthResponse{Healthy: false})
return
}
http.NotFound(w, r)
}))
defer server.Close()
adapter := NewAdapter(ClientConfig{BaseURL: server.URL})
if adapter.Available(context.Background()) {
t.Error("expected adapter to be unavailable")
}
}
func TestAdapter_Available_ServerDown(t *testing.T) {
adapter := NewAdapter(ClientConfig{BaseURL: "http://localhost:59999"})
if adapter.Available(context.Background()) {
t.Error("expected adapter to be unavailable when server is down")
}
}
func TestAdapter_Execute_WithMockServer(t *testing.T) {
sessionID := "test-session-123"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/session" && r.Method == http.MethodPost:
// Create session
json.NewEncoder(w).Encode(Session{ID: sessionID})
case r.URL.Path == "/session/"+sessionID+"/message" && r.Method == http.MethodPost:
// Send message response
json.NewEncoder(w).Encode(SendMessageResponse{
Info: MessageInfo{ID: "msg-1", Role: "assistant"},
Parts: []MessagePart{
{Type: "text", Content: "Hello from OpenCode!"},
},
})
case r.URL.Path == "/event":
// SSE endpoint - just close immediately for this test
w.WriteHeader(http.StatusOK)
return
default:
http.NotFound(w, r)
}
}))
defer server.Close()
adapter := NewAdapter(ClientConfig{BaseURL: server.URL})
var events []domain.AgentEvent
handler := func(e domain.AgentEvent) {
events = append(events, e)
}
req := &domain.AgentRequest{
Prompt: "Say hello",
}
result, err := adapter.Execute(context.Background(), req, handler)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.SessionID != sessionID {
t.Errorf("expected session ID %q, got %q", sessionID, result.SessionID)
}
if result.ExitCode != 0 {
t.Errorf("expected exit code 0, got %d", result.ExitCode)
}
if result.FinalOutput != "Hello from OpenCode!" {
t.Errorf("expected final output 'Hello from OpenCode!', got %q", result.FinalOutput)
}
// Should have at least session started + output + complete events
if len(events) < 3 {
t.Errorf("expected at least 3 events, got %d", len(events))
}
}
func TestAdapter_Execute_WithModel(t *testing.T) {
var receivedModel string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/session" && r.Method == http.MethodPost:
json.NewEncoder(w).Encode(Session{ID: "sess-1"})
case r.URL.Path == "/session/sess-1/message" && r.Method == http.MethodPost:
var req SendMessageRequest
json.NewDecoder(r.Body).Decode(&req)
receivedModel = req.Model
json.NewEncoder(w).Encode(SendMessageResponse{
Info: MessageInfo{ID: "msg-1"},
Parts: []MessagePart{{Type: "text", Content: "Done"}},
})
case r.URL.Path == "/event":
w.WriteHeader(http.StatusOK)
return
default:
http.NotFound(w, r)
}
}))
defer server.Close()
adapter := NewAdapter(ClientConfig{BaseURL: server.URL})
req := &domain.AgentRequest{
Prompt: "Test",
Model: "gpt-4o",
}
_, err := adapter.Execute(context.Background(), req, func(e domain.AgentEvent) {})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if receivedModel != "gpt-4o" {
t.Errorf("expected model 'gpt-4o', got %q", receivedModel)
}
}
func TestAdapter_Cancel(t *testing.T) {
abortCalled := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/session/test-session/abort" && r.Method == http.MethodPost {
abortCalled = true
w.WriteHeader(http.StatusOK)
return
}
http.NotFound(w, r)
}))
defer server.Close()
adapter := NewAdapter(ClientConfig{BaseURL: server.URL})
err := adapter.Cancel(context.Background(), "test-session")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if !abortCalled {
t.Error("expected abort endpoint to be called")
}
}
func TestAdapter_Execute_WithErrorPart(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/session" && r.Method == http.MethodPost:
json.NewEncoder(w).Encode(Session{ID: "sess-err"})
case r.URL.Path == "/session/sess-err/message" && r.Method == http.MethodPost:
// Return response with error part
json.NewEncoder(w).Encode(SendMessageResponse{
Info: MessageInfo{ID: "msg-1"},
Parts: []MessagePart{
{Type: "text", Content: "Attempting task..."},
{Type: "error", Content: "Command failed with exit code 1"},
},
})
case r.URL.Path == "/event":
w.WriteHeader(http.StatusOK)
return
default:
http.NotFound(w, r)
}
}))
defer server.Close()
adapter := NewAdapter(ClientConfig{BaseURL: server.URL})
req := &domain.AgentRequest{
Prompt: "Run failing command",
}
result, err := adapter.Execute(context.Background(), req, func(e domain.AgentEvent) {})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should have non-zero exit code when error part is present
if result.ExitCode != 1 {
t.Errorf("expected exit code 1 for error response, got %d", result.ExitCode)
}
// Should have error set
if result.Error == nil {
t.Error("expected error to be set when error part is present")
}
// Error should contain the error content
if result.Error != nil && !strings.Contains(result.Error.Error(), "Command failed") {
t.Errorf("expected error to contain 'Command failed', got %q", result.Error.Error())
}
}
func TestAdapter_partToEvent(t *testing.T) {
adapter := NewAdapter(ClientConfig{})
tests := []struct {
name string
part MessagePart
wantType domain.AgentEventType
wantValue string
}{
{
name: "text part",
part: MessagePart{Type: "text", Content: "Hello"},
wantType: domain.AgentEventOutput,
wantValue: "Hello",
},
{
name: "tool_use part",
part: MessagePart{Type: "tool_use", Name: "Bash"},
wantType: domain.AgentEventToolUse,
wantValue: "Bash",
},
{
name: "tool_result part",
part: MessagePart{Type: "tool_result", Content: "output here"},
wantType: domain.AgentEventToolResult,
wantValue: "output here",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
event := adapter.partToEvent(tt.part)
if event.Type != tt.wantType {
t.Errorf("expected type %v, got %v", tt.wantType, event.Type)
}
if event.Content != tt.wantValue {
t.Errorf("expected content %q, got %q", tt.wantValue, event.Content)
}
})
}
}
func TestAdapter_sseToEvent(t *testing.T) {
adapter := NewAdapter(ClientConfig{})
tests := []struct {
name string
sse SSEEvent
wantType domain.AgentEventType
}{
{
name: "server connected",
sse: SSEEvent{Event: "server.connected"},
wantType: domain.AgentEventOutput,
},
{
name: "tool started",
sse: SSEEvent{Event: "tool.started", Data: `{"name":"Bash"}`},
wantType: domain.AgentEventToolUse,
},
{
name: "tool completed",
sse: SSEEvent{Event: "tool.completed", Data: `{"output":"done"}`},
wantType: domain.AgentEventToolResult,
},
{
name: "session completed",
sse: SSEEvent{Event: "session.completed"},
wantType: domain.AgentEventComplete,
},
{
name: "error",
sse: SSEEvent{Event: "error", Data: "something failed"},
wantType: domain.AgentEventError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
event := adapter.sseToEvent(tt.sse)
if event.Type != tt.wantType {
t.Errorf("expected type %v, got %v", tt.wantType, event.Type)
}
})
}
}
func TestClient_Health(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/global/health" {
json.NewEncoder(w).Encode(HealthResponse{Healthy: true, Version: "1.2.3"})
return
}
http.NotFound(w, r)
}))
defer server.Close()
client := NewClient(ClientConfig{BaseURL: server.URL})
health, err := client.Health(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !health.Healthy {
t.Error("expected healthy=true")
}
if health.Version != "1.2.3" {
t.Errorf("expected version '1.2.3', got %q", health.Version)
}
}
func TestClient_CreateSession(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/session" && r.Method == http.MethodPost {
var req CreateSessionRequest
json.NewDecoder(r.Body).Decode(&req)
json.NewEncoder(w).Encode(Session{
ID: "new-session-id",
Title: req.Title,
CreatedAt: time.Now(),
})
return
}
http.NotFound(w, r)
}))
defer server.Close()
client := NewClient(ClientConfig{BaseURL: server.URL})
session, err := client.CreateSession(context.Background(), &CreateSessionRequest{
Title: "Test Session",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if session.ID != "new-session-id" {
t.Errorf("expected ID 'new-session-id', got %q", session.ID)
}
if session.Title != "Test Session" {
t.Errorf("expected title 'Test Session', got %q", session.Title)
}
}
func TestClient_WithAuth(t *testing.T) {
var authHeader string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader = r.Header.Get("Authorization")
json.NewEncoder(w).Encode(HealthResponse{Healthy: true})
}))
defer server.Close()
client := NewClient(ClientConfig{
BaseURL: server.URL,
Username: "opencode",
Password: "secret",
})
_, err := client.Health(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if authHeader == "" {
t.Error("expected Authorization header to be set")
}
if authHeader != "Basic b3BlbmNvZGU6c2VjcmV0" {
t.Errorf("unexpected auth header: %s", authHeader)
}
}