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) } }