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>
389 lines
9.4 KiB
Go
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
|
|
}
|