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

351 lines
8.6 KiB
Go

package claudecode
import (
"testing"
"time"
"github.com/orchard9/rdev/internal/domain"
)
func TestParseStreamMessage_Init(t *testing.T) {
line := []byte(`{"type":"init","session_id":"abc123","timestamp":"2024-01-01T00:00:00Z"}`)
msg, err := ParseStreamMessage(line)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if msg.Type != StreamMessageInit {
t.Errorf("expected type 'init', got %q", msg.Type)
}
if msg.SessionID != "abc123" {
t.Errorf("expected session_id 'abc123', got %q", msg.SessionID)
}
}
func TestParseStreamMessage_Message(t *testing.T) {
line := []byte(`{"type":"message","role":"assistant","content":[{"type":"text","text":"Hello, world!"}]}`)
msg, err := ParseStreamMessage(line)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if msg.Type != StreamMessageMessage {
t.Errorf("expected type 'message', got %q", msg.Type)
}
if msg.Role != "assistant" {
t.Errorf("expected role 'assistant', got %q", msg.Role)
}
if len(msg.Content) != 1 {
t.Fatalf("expected 1 content block, got %d", len(msg.Content))
}
if msg.Content[0].Text != "Hello, world!" {
t.Errorf("expected text 'Hello, world!', got %q", msg.Content[0].Text)
}
}
func TestParseStreamMessage_ToolUse(t *testing.T) {
line := []byte(`{"type":"tool_use","name":"Bash","input":{"command":"ls -la"}}`)
msg, err := ParseStreamMessage(line)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if msg.Type != StreamMessageToolUse {
t.Errorf("expected type 'tool_use', got %q", msg.Type)
}
if msg.Name != "Bash" {
t.Errorf("expected name 'Bash', got %q", msg.Name)
}
}
func TestParseStreamMessage_ToolResult(t *testing.T) {
line := []byte(`{"type":"tool_result","output":"total 64\ndrwxr-xr-x 10 user staff"}`)
msg, err := ParseStreamMessage(line)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if msg.Type != StreamMessageToolResult {
t.Errorf("expected type 'tool_result', got %q", msg.Type)
}
if msg.Output != "total 64\ndrwxr-xr-x 10 user staff" {
t.Errorf("unexpected output: %q", msg.Output)
}
}
func TestParseStreamMessage_Result(t *testing.T) {
tests := []struct {
name string
line string
wantStatus string
wantMs int64
}{
{
name: "success",
line: `{"type":"result","status":"success","duration_ms":1234}`,
wantStatus: "success",
wantMs: 1234,
},
{
name: "error",
line: `{"type":"result","status":"error","error":"something went wrong"}`,
wantStatus: "error",
wantMs: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
msg, err := ParseStreamMessage([]byte(tt.line))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if msg.Type != StreamMessageResult {
t.Errorf("expected type 'result', got %q", msg.Type)
}
if msg.Status != tt.wantStatus {
t.Errorf("expected status %q, got %q", tt.wantStatus, msg.Status)
}
if msg.DurationMs != tt.wantMs {
t.Errorf("expected duration_ms %d, got %d", tt.wantMs, msg.DurationMs)
}
})
}
}
func TestParseStreamMessage_Invalid(t *testing.T) {
line := []byte(`not valid json`)
_, err := ParseStreamMessage(line)
if err == nil {
t.Error("expected error for invalid JSON")
}
}
func TestStreamMessage_ToAgentEvent_Init(t *testing.T) {
msg := &StreamMessage{
Type: StreamMessageInit,
SessionID: "test-session",
Timestamp: "2024-01-01T12:00:00Z",
}
event := msg.ToAgentEvent()
if event.Type != domain.AgentEventOutput {
t.Errorf("expected type %v, got %v", domain.AgentEventOutput, event.Type)
}
if event.Content != "Session started" {
t.Errorf("expected content 'Session started', got %q", event.Content)
}
if event.Metadata["session_id"] != "test-session" {
t.Errorf("expected session_id in metadata")
}
}
func TestStreamMessage_ToAgentEvent_Message(t *testing.T) {
msg := &StreamMessage{
Type: StreamMessageMessage,
Role: "assistant",
Content: []ContentBlock{
{Type: "text", Text: "Hello from Claude"},
},
}
event := msg.ToAgentEvent()
if event.Type != domain.AgentEventOutput {
t.Errorf("expected type %v, got %v", domain.AgentEventOutput, event.Type)
}
if event.Content != "Hello from Claude" {
t.Errorf("expected content 'Hello from Claude', got %q", event.Content)
}
}
func TestStreamMessage_ToAgentEvent_ToolUse(t *testing.T) {
msg := &StreamMessage{
Type: StreamMessageToolUse,
Name: "Read",
Input: []byte(`{"path":"/workspace/main.go"}`),
}
event := msg.ToAgentEvent()
if event.Type != domain.AgentEventToolUse {
t.Errorf("expected type %v, got %v", domain.AgentEventToolUse, event.Type)
}
if event.ToolName != "Read" {
t.Errorf("expected tool name 'Read', got %q", event.ToolName)
}
if event.ToolInput["path"] != "/workspace/main.go" {
t.Errorf("expected path in tool input")
}
}
func TestStreamMessage_ToAgentEvent_ToolResult(t *testing.T) {
msg := &StreamMessage{
Type: StreamMessageToolResult,
Output: "file contents here",
}
event := msg.ToAgentEvent()
if event.Type != domain.AgentEventToolResult {
t.Errorf("expected type %v, got %v", domain.AgentEventToolResult, event.Type)
}
if event.Content != "file contents here" {
t.Errorf("expected content 'file contents here', got %q", event.Content)
}
}
func TestStreamMessage_ToAgentEvent_ResultSuccess(t *testing.T) {
msg := &StreamMessage{
Type: StreamMessageResult,
Status: "success",
DurationMs: 5000,
}
event := msg.ToAgentEvent()
if event.Type != domain.AgentEventComplete {
t.Errorf("expected type %v, got %v", domain.AgentEventComplete, event.Type)
}
if event.Metadata["status"] != "success" {
t.Errorf("expected status in metadata")
}
if event.Metadata["duration_ms"].(int64) != 5000 {
t.Errorf("expected duration_ms in metadata")
}
}
func TestStreamMessage_ToAgentEvent_ResultError(t *testing.T) {
msg := &StreamMessage{
Type: StreamMessageResult,
Status: "error",
Error: "execution failed",
}
event := msg.ToAgentEvent()
if event.Type != domain.AgentEventError {
t.Errorf("expected type %v, got %v", domain.AgentEventError, event.Type)
}
if event.Content != "execution failed" {
t.Errorf("expected error content, got %q", event.Content)
}
}
func TestStreamMessage_IsTerminal(t *testing.T) {
tests := []struct {
msgType StreamMessageType
terminal bool
}{
{StreamMessageInit, false},
{StreamMessageMessage, false},
{StreamMessageToolUse, false},
{StreamMessageToolResult, false},
{StreamMessageResult, true},
}
for _, tt := range tests {
t.Run(string(tt.msgType), func(t *testing.T) {
msg := &StreamMessage{Type: tt.msgType}
if msg.IsTerminal() != tt.terminal {
t.Errorf("IsTerminal() = %v, want %v", msg.IsTerminal(), tt.terminal)
}
})
}
}
func TestStreamMessage_IsSuccess(t *testing.T) {
tests := []struct {
name string
msg StreamMessage
success bool
}{
{"success result", StreamMessage{Type: StreamMessageResult, Status: "success"}, true},
{"error result", StreamMessage{Type: StreamMessageResult, Status: "error"}, false},
{"non-result", StreamMessage{Type: StreamMessageMessage}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.msg.IsSuccess() != tt.success {
t.Errorf("IsSuccess() = %v, want %v", tt.msg.IsSuccess(), tt.success)
}
})
}
}
func TestParseTimestamp(t *testing.T) {
tests := []struct {
input string
valid bool
}{
{"2024-01-01T12:00:00Z", true},
{"2024-01-01T12:00:00+00:00", true},
{"invalid", false},
{"", false},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := parseTimestamp(tt.input)
if tt.valid {
if result.Year() != 2024 {
t.Errorf("expected year 2024, got %d", result.Year())
}
} else {
// Should return current time for invalid input
if time.Since(result) > time.Second {
t.Error("expected recent time for invalid input")
}
}
})
}
}
func TestExtractTextContent(t *testing.T) {
tests := []struct {
name string
blocks []ContentBlock
want string
}{
{
name: "single text block",
blocks: []ContentBlock{{Type: "text", Text: "hello"}},
want: "hello",
},
{
name: "text after tool_use",
blocks: []ContentBlock{
{Type: "tool_use", Name: "Bash"},
{Type: "text", Text: "result"},
},
want: "result",
},
{
name: "empty blocks",
blocks: []ContentBlock{},
want: "",
},
{
name: "no text blocks",
blocks: []ContentBlock{{Type: "tool_use", Name: "Read"}},
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := extractTextContent(tt.blocks); got != tt.want {
t.Errorf("extractTextContent() = %q, want %q", got, tt.want)
}
})
}
}