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>
163 lines
4.8 KiB
Go
163 lines
4.8 KiB
Go
// Package claudecode provides a CodeAgent implementation for Anthropic's Claude Code CLI.
|
|
package claudecode
|
|
|
|
import (
|
|
"encoding/json"
|
|
"time"
|
|
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
)
|
|
|
|
// StreamMessageType identifies the type of stream-json message from Claude Code CLI.
|
|
type StreamMessageType string
|
|
|
|
const (
|
|
// StreamMessageInit is emitted when a session starts.
|
|
StreamMessageInit StreamMessageType = "init"
|
|
// StreamMessageMessage contains assistant or user text.
|
|
StreamMessageMessage StreamMessageType = "message"
|
|
// StreamMessageToolUse indicates a tool is being invoked.
|
|
StreamMessageToolUse StreamMessageType = "tool_use"
|
|
// StreamMessageToolResult contains the output of a tool invocation.
|
|
StreamMessageToolResult StreamMessageType = "tool_result"
|
|
// StreamMessageResult is the final message indicating completion.
|
|
StreamMessageResult StreamMessageType = "result"
|
|
)
|
|
|
|
// StreamMessage represents a single NDJSON message from Claude Code's stream-json output.
|
|
type StreamMessage struct {
|
|
Type StreamMessageType `json:"type"`
|
|
Timestamp string `json:"timestamp,omitempty"`
|
|
SessionID string `json:"session_id,omitempty"`
|
|
Role string `json:"role,omitempty"` // "assistant" or "user"
|
|
Content []ContentBlock `json:"content,omitempty"`
|
|
Name string `json:"name,omitempty"` // Tool name for tool_use
|
|
Input json.RawMessage `json:"input,omitempty"` // Tool input for tool_use
|
|
Output string `json:"output,omitempty"`
|
|
Status string `json:"status,omitempty"` // "success" or "error"
|
|
DurationMs int64 `json:"duration_ms,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// ContentBlock represents a content item within a message.
|
|
type ContentBlock struct {
|
|
Type string `json:"type"` // "text" or "tool_use"
|
|
Text string `json:"text,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
Input json.RawMessage `json:"input,omitempty"`
|
|
}
|
|
|
|
// ParseStreamMessage parses a single NDJSON line into a StreamMessage.
|
|
func ParseStreamMessage(line []byte) (*StreamMessage, error) {
|
|
var msg StreamMessage
|
|
if err := json.Unmarshal(line, &msg); err != nil {
|
|
return nil, err
|
|
}
|
|
return &msg, nil
|
|
}
|
|
|
|
// ToAgentEvent converts a StreamMessage to a domain.AgentEvent.
|
|
func (m *StreamMessage) ToAgentEvent() domain.AgentEvent {
|
|
event := domain.AgentEvent{
|
|
Timestamp: parseTimestamp(m.Timestamp),
|
|
Metadata: make(map[string]any),
|
|
}
|
|
|
|
switch m.Type {
|
|
case StreamMessageInit:
|
|
event.Type = domain.AgentEventOutput
|
|
event.Content = "Session started"
|
|
if m.SessionID != "" {
|
|
event.Metadata["session_id"] = m.SessionID
|
|
}
|
|
|
|
case StreamMessageMessage:
|
|
event.Type = domain.AgentEventOutput
|
|
event.Content = extractTextContent(m.Content)
|
|
if m.Role != "" {
|
|
event.Metadata["role"] = m.Role
|
|
}
|
|
|
|
case StreamMessageToolUse:
|
|
event.Type = domain.AgentEventToolUse
|
|
event.ToolName = m.Name
|
|
if len(m.Name) == 0 {
|
|
// Check content blocks for tool_use
|
|
for _, block := range m.Content {
|
|
if block.Type == "tool_use" {
|
|
event.ToolName = block.Name
|
|
if len(block.Input) > 0 {
|
|
var input map[string]any
|
|
if err := json.Unmarshal(block.Input, &input); err == nil {
|
|
event.ToolInput = input
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
} else if len(m.Input) > 0 {
|
|
var input map[string]any
|
|
if err := json.Unmarshal(m.Input, &input); err == nil {
|
|
event.ToolInput = input
|
|
}
|
|
}
|
|
event.Content = event.ToolName
|
|
|
|
case StreamMessageToolResult:
|
|
event.Type = domain.AgentEventToolResult
|
|
event.Content = m.Output
|
|
|
|
case StreamMessageResult:
|
|
event.Type = domain.AgentEventComplete
|
|
if m.Status == "error" {
|
|
event.Type = domain.AgentEventError
|
|
event.Content = m.Error
|
|
}
|
|
if m.DurationMs > 0 {
|
|
event.Metadata["duration_ms"] = m.DurationMs
|
|
}
|
|
if m.Status != "" {
|
|
event.Metadata["status"] = m.Status
|
|
}
|
|
|
|
default:
|
|
// Unknown message type, treat as output
|
|
event.Type = domain.AgentEventOutput
|
|
event.Content = extractTextContent(m.Content)
|
|
}
|
|
|
|
return event
|
|
}
|
|
|
|
// extractTextContent extracts text from content blocks.
|
|
func extractTextContent(blocks []ContentBlock) string {
|
|
for _, block := range blocks {
|
|
if block.Type == "text" && block.Text != "" {
|
|
return block.Text
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// parseTimestamp parses an ISO8601 timestamp string.
|
|
func parseTimestamp(s string) time.Time {
|
|
if s == "" {
|
|
return time.Now()
|
|
}
|
|
t, err := time.Parse(time.RFC3339, s)
|
|
if err != nil {
|
|
return time.Now()
|
|
}
|
|
return t
|
|
}
|
|
|
|
// IsTerminal returns true if this message indicates execution is complete.
|
|
func (m *StreamMessage) IsTerminal() bool {
|
|
return m.Type == StreamMessageResult
|
|
}
|
|
|
|
// IsSuccess returns true if this is a successful result message.
|
|
func (m *StreamMessage) IsSuccess() bool {
|
|
return m.Type == StreamMessageResult && m.Status == "success"
|
|
}
|