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

389 lines
9.4 KiB
Go

// Package opencode provides a CodeAgent implementation for the OpenCode server.
package opencode
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
// Adapter implements port.CodeAgent using OpenCode's HTTP server.
type Adapter struct {
client *Client
// Track active sessions for cancellation
activeSessions map[string]context.CancelFunc
sessionsMu sync.Mutex
}
// NewAdapter creates a new OpenCode adapter.
func NewAdapter(cfg ClientConfig) *Adapter {
return &Adapter{
client: NewClient(cfg),
activeSessions: make(map[string]context.CancelFunc),
}
}
// Ensure Adapter implements port.CodeAgent at compile time.
var _ port.CodeAgent = (*Adapter)(nil)
// Name returns a human-readable name for this agent.
func (a *Adapter) Name() string {
return "OpenCode"
}
// Provider returns the agent provider identifier.
func (a *Adapter) Provider() domain.AgentProvider {
return domain.AgentProviderOpenCode
}
// Execute runs an OpenCode command and streams events to the handler.
func (a *Adapter) Execute(ctx context.Context, req *domain.AgentRequest, handler domain.AgentEventHandler) (*domain.AgentResult, error) {
if req.Prompt == "" {
return nil, fmt.Errorf("prompt is required")
}
// Create cancellable context
execCtx, cancel := context.WithCancel(ctx)
defer cancel()
// Apply timeout if specified
if req.Timeout > 0 {
var timeoutCancel context.CancelFunc
execCtx, timeoutCancel = context.WithTimeout(execCtx, req.Timeout)
defer timeoutCancel()
}
startTime := time.Now()
// Get or create session
sessionID := req.SessionID
if sessionID == "" {
session, err := a.client.CreateSession(execCtx, &CreateSessionRequest{
Title: fmt.Sprintf("rdev-%d", time.Now().Unix()),
})
if err != nil {
return &domain.AgentResult{
ExitCode: 1,
Error: fmt.Errorf("create session: %w", err),
}, nil
}
sessionID = session.ID
}
// Track session for potential cancellation
a.sessionsMu.Lock()
a.activeSessions[sessionID] = cancel
a.sessionsMu.Unlock()
defer func() {
a.sessionsMu.Lock()
delete(a.activeSessions, sessionID)
a.sessionsMu.Unlock()
}()
// Emit session started event
handler(domain.AgentEvent{
Type: domain.AgentEventOutput,
Timestamp: time.Now(),
Content: "Session started",
Metadata: map[string]any{
"session_id": sessionID,
},
})
// Subscribe to SSE events for real-time updates
eventChan, err := a.client.SubscribeEvents(execCtx)
if err != nil {
// Non-fatal: we can still use sync API
handler(domain.AgentEvent{
Type: domain.AgentEventError,
Timestamp: time.Now(),
Content: fmt.Sprintf("SSE subscription failed: %v (using sync mode)", err),
})
}
// Start event consumer if we have SSE
var eventWg sync.WaitGroup
if eventChan != nil {
eventWg.Add(1)
go func() {
defer eventWg.Done()
a.consumeEvents(execCtx, eventChan, handler)
}()
}
// Build message request
msgReq := &SendMessageRequest{
Parts: []MessagePart{
{Type: "text", Content: req.Prompt},
},
}
// Set model if specified
if req.Model != "" {
msgReq.Model = req.Model
}
// Set tools if specified
if len(req.AllowedTools) > 0 {
msgReq.Tools = req.AllowedTools
}
// Send message (synchronous call that waits for response)
resp, err := a.client.SendMessage(execCtx, sessionID, msgReq)
if err != nil {
// Check if cancelled
if execCtx.Err() == context.Canceled {
return &domain.AgentResult{
SessionID: sessionID,
ExitCode: 1,
DurationMs: time.Since(startTime).Milliseconds(),
Error: domain.ErrCommandCancelled,
}, nil
}
return &domain.AgentResult{
SessionID: sessionID,
ExitCode: 1,
DurationMs: time.Since(startTime).Milliseconds(),
Error: fmt.Errorf("send message: %w", err),
}, nil
}
// Process response parts
var finalOutput strings.Builder
var hasError bool
var errorContent string
for _, part := range resp.Parts {
event := a.partToEvent(part)
handler(event)
if part.Type == "text" && part.Content != "" {
finalOutput.WriteString(part.Content)
}
if part.Type == "error" {
hasError = true
errorContent = part.Content
}
}
// Emit completion event
duration := time.Since(startTime)
completionStatus := "success"
if hasError {
completionStatus = "error"
}
handler(domain.AgentEvent{
Type: domain.AgentEventComplete,
Timestamp: time.Now(),
Metadata: map[string]any{
"status": completionStatus,
"duration_ms": duration.Milliseconds(),
},
})
// Wait for SSE consumer to finish (with timeout)
if eventChan != nil {
cancel() // Signal event consumer to stop
waitDone := make(chan struct{})
go func() {
eventWg.Wait()
close(waitDone)
}()
select {
case <-waitDone:
case <-time.After(2 * time.Second):
// Event consumer didn't stop in time, continue anyway
}
}
result := &domain.AgentResult{
SessionID: sessionID,
ExitCode: 0,
DurationMs: duration.Milliseconds(),
FinalOutput: finalOutput.String(),
}
// Set error state if error parts were found
if hasError {
result.ExitCode = 1
if errorContent != "" {
result.Error = fmt.Errorf("agent error: %s", errorContent)
}
}
return result, nil
}
// consumeEvents processes SSE events and dispatches them to the handler.
func (a *Adapter) consumeEvents(ctx context.Context, events <-chan SSEEvent, handler domain.AgentEventHandler) {
for {
select {
case <-ctx.Done():
return
case event, ok := <-events:
if !ok {
return
}
agentEvent := a.sseToEvent(event)
if agentEvent.Type != "" {
handler(agentEvent)
}
}
}
}
// sseToEvent converts an SSE event to a domain.AgentEvent.
func (a *Adapter) sseToEvent(sse SSEEvent) domain.AgentEvent {
event := domain.AgentEvent{
Timestamp: time.Now(),
Metadata: make(map[string]any),
}
// Parse event type
switch sse.Event {
case "server.connected":
event.Type = domain.AgentEventOutput
event.Content = "Connected to OpenCode server"
return event
case "message.created", "message.updated":
event.Type = domain.AgentEventOutput
// Try to parse data for content
var data map[string]any
if json.Unmarshal([]byte(sse.Data), &data) == nil {
if content, ok := data["content"].(string); ok {
event.Content = content
}
}
return event
case "tool.started":
event.Type = domain.AgentEventToolUse
var data map[string]any
if json.Unmarshal([]byte(sse.Data), &data) == nil {
if name, ok := data["name"].(string); ok {
event.ToolName = name
event.Content = name
}
if input, ok := data["input"].(map[string]any); ok {
event.ToolInput = input
}
}
return event
case "tool.completed":
event.Type = domain.AgentEventToolResult
var data map[string]any
if json.Unmarshal([]byte(sse.Data), &data) == nil {
if output, ok := data["output"].(string); ok {
event.Content = output
}
}
return event
case "session.completed":
event.Type = domain.AgentEventComplete
return event
case "error":
event.Type = domain.AgentEventError
event.Content = sse.Data
return event
}
// Unknown event type
return domain.AgentEvent{}
}
// partToEvent converts a message part to a domain.AgentEvent.
func (a *Adapter) partToEvent(part MessagePart) domain.AgentEvent {
event := domain.AgentEvent{
Timestamp: time.Now(),
Metadata: make(map[string]any),
}
switch part.Type {
case "text":
event.Type = domain.AgentEventOutput
event.Content = part.Content
case "tool_use":
event.Type = domain.AgentEventToolUse
event.ToolName = part.Name
event.Content = part.Name
if input, ok := part.Input.(map[string]any); ok {
event.ToolInput = input
}
case "tool_result":
event.Type = domain.AgentEventToolResult
event.Content = part.Content
default:
event.Type = domain.AgentEventOutput
event.Content = part.Content
}
return event
}
// Cancel attempts to cancel a running session.
func (a *Adapter) Cancel(ctx context.Context, sessionID string) error {
a.sessionsMu.Lock()
cancel, exists := a.activeSessions[sessionID]
if exists {
// Call cancel while holding lock and delete to prevent double-cancel
cancel()
delete(a.activeSessions, sessionID)
}
a.sessionsMu.Unlock()
// Abort on the server side regardless of local session state
return a.client.AbortSession(ctx, sessionID)
}
// Capabilities returns what this agent supports.
func (a *Adapter) Capabilities() domain.AgentCapabilities {
return domain.AgentCapabilities{
Provider: domain.AgentProviderOpenCode,
SupportsSessionContinuation: true,
SupportsModelSelection: true, // OpenCode supports multiple providers
SupportsToolControl: true,
SupportedModels: []string{
"claude-sonnet-4-20250514",
"claude-opus-4-20250514",
"gpt-4o",
"gpt-4-turbo",
"gemini-pro",
},
DefaultModel: "claude-sonnet-4-20250514",
MaxPromptLength: 0, // Unlimited
SupportsStreaming: true,
}
}
// DefaultAvailabilityTimeout is the maximum time to wait when checking agent availability.
// This timeout prevents blocking the caller when the server is slow or unresponsive.
const DefaultAvailabilityTimeout = 5 * time.Second
// Available checks if the OpenCode server is healthy.
func (a *Adapter) Available(ctx context.Context) bool {
ctx, cancel := context.WithTimeout(ctx, DefaultAvailabilityTimeout)
defer cancel()
health, err := a.client.Health(ctx)
if err != nil {
return false
}
return health.Healthy
}