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>
463 lines
12 KiB
Go
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)
|
|
}
|
|
}
|