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>
351 lines
8.6 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|