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

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