Files
kandev/apps/backend/internal/agentctl/server/adapter/opencode_adapter_test.go
T
2026-01-24 19:03:31 +00:00

330 lines
8.3 KiB
Go

package adapter
import (
"encoding/json"
"testing"
"time"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/pkg/opencode"
)
func newTestOpenCodeAdapter() *OpenCodeAdapter {
log, _ := logger.NewLogger(logger.LoggingConfig{
Level: "error",
Format: "json",
})
cfg := &Config{
WorkDir: "/tmp/test",
AutoApprove: true,
}
return NewOpenCodeAdapter(cfg, log)
}
// drainEvents reads events from the updates channel with a timeout
func drainEvents(adapter *OpenCodeAdapter, timeout time.Duration) []AgentEvent {
var events []AgentEvent
deadline := time.After(timeout)
for {
select {
case evt := <-adapter.updatesCh:
events = append(events, evt)
case <-deadline:
return events
default:
// Small sleep to avoid busy loop
time.Sleep(10 * time.Millisecond)
select {
case evt := <-adapter.updatesCh:
events = append(events, evt)
case <-deadline:
return events
default:
return events
}
}
}
}
func TestOpenCodeAdapter_TextPartDeduplication(t *testing.T) {
adapter := newTestOpenCodeAdapter()
sessionID := "test-session"
operationID := "test-op"
// Track message role first (assistant message)
adapter.mu.Lock()
adapter.messageRoles = make(map[string]string)
adapter.messageRoles["msg-1"] = "assistant"
adapter.mu.Unlock()
// Simulate streaming events with cumulative text
// The key insight: part.Text is CUMULATIVE, not incremental
events := []struct {
partID string
text string // Cumulative text so far
delta string
}{
{partID: "part-1", text: "Hello", delta: "Hello"},
{partID: "part-1", text: "Hello world", delta: " world"},
{partID: "part-1", text: "Hello world!", delta: "!"},
}
for _, evt := range events {
props := opencode.MessagePartUpdatedProperties{
Part: opencode.Part{
ID: evt.partID,
Type: opencode.PartTypeText,
MessageID: "msg-1",
SessionID: sessionID,
Text: evt.text,
},
Delta: evt.delta,
}
propsJSON, _ := json.Marshal(props)
adapter.handleMessagePartUpdated(propsJSON, sessionID, operationID)
}
// Collect sent events
sentEvents := drainEvents(adapter, 100*time.Millisecond)
// Should have received 3 events with incremental text
if len(sentEvents) != 3 {
t.Fatalf("expected 3 events, got %d", len(sentEvents))
}
// Verify text is incremental (computed from cumulative), not duplicated
expectedTexts := []string{"Hello", " world", "!"}
for i, evt := range sentEvents {
if evt.Text != expectedTexts[i] {
t.Errorf("event %d: expected text %q, got %q", i, expectedTexts[i], evt.Text)
}
}
}
func TestOpenCodeAdapter_TextDeduplicationWithRepeatedDeltas(t *testing.T) {
adapter := newTestOpenCodeAdapter()
sessionID := "test-session"
operationID := "test-op"
// Track message role
adapter.mu.Lock()
adapter.messageRoles = make(map[string]string)
adapter.messageRoles["msg-1"] = "assistant"
adapter.mu.Unlock()
// Simulate the problematic case: same delta sent multiple times
// This can happen when OpenCode sends multiple events with the same delta
// Our fix: we prefer cumulative text over delta
events := []struct {
text string
delta string
}{
{text: "Done", delta: "Done"},
{text: "Done", delta: "Done"}, // Duplicate event with same delta
{text: "Done.", delta: "."},
}
for _, evt := range events {
props := opencode.MessagePartUpdatedProperties{
Part: opencode.Part{
ID: "part-1",
Type: opencode.PartTypeText,
MessageID: "msg-1",
SessionID: sessionID,
Text: evt.text,
},
Delta: evt.delta,
}
propsJSON, _ := json.Marshal(props)
adapter.handleMessagePartUpdated(propsJSON, sessionID, operationID)
}
sentEvents := drainEvents(adapter, 100*time.Millisecond)
// Should only have 2 events (the duplicate should be filtered)
if len(sentEvents) != 2 {
t.Fatalf("expected 2 events (duplicate filtered), got %d", len(sentEvents))
}
// Verify final combined text
combinedText := ""
for _, evt := range sentEvents {
combinedText += evt.Text
}
if combinedText != "Done." {
t.Errorf("expected combined text 'Done.', got %q", combinedText)
}
}
func TestOpenCodeAdapter_FilterUserMessages(t *testing.T) {
adapter := newTestOpenCodeAdapter()
sessionID := "test-session"
operationID := "test-op"
// Track message roles
adapter.mu.Lock()
adapter.messageRoles = make(map[string]string)
adapter.messageRoles["user-msg"] = "user"
adapter.messageRoles["assistant-msg"] = "assistant"
adapter.mu.Unlock()
// Send user message part - should be filtered
userProps := opencode.MessagePartUpdatedProperties{
Part: opencode.Part{
ID: "user-part",
Type: opencode.PartTypeText,
MessageID: "user-msg",
SessionID: sessionID,
Text: "User input that should not appear",
},
}
userJSON, _ := json.Marshal(userProps)
adapter.handleMessagePartUpdated(userJSON, sessionID, operationID)
// Send assistant message part - should be processed
assistantProps := opencode.MessagePartUpdatedProperties{
Part: opencode.Part{
ID: "assistant-part",
Type: opencode.PartTypeText,
MessageID: "assistant-msg",
SessionID: sessionID,
Text: "Assistant response",
},
}
assistantJSON, _ := json.Marshal(assistantProps)
adapter.handleMessagePartUpdated(assistantJSON, sessionID, operationID)
sentEvents := drainEvents(adapter, 100*time.Millisecond)
// Should only have 1 event (assistant message)
if len(sentEvents) != 1 {
t.Fatalf("expected 1 event, got %d", len(sentEvents))
}
if sentEvents[0].Text != "Assistant response" {
t.Errorf("expected assistant response, got %q", sentEvents[0].Text)
}
}
func TestOpenCodeAdapter_MessageRoleTracking(t *testing.T) {
adapter := newTestOpenCodeAdapter()
sessionID := "test-session"
// Simulate message.updated event
props := opencode.MessageUpdatedProperties{
Info: opencode.MessageInfo{
ID: "msg-123",
SessionID: sessionID,
Role: "assistant",
},
}
propsJSON, _ := json.Marshal(props)
adapter.handleMessageUpdated(propsJSON, sessionID)
// Check role was tracked
adapter.mu.RLock()
role := adapter.messageRoles["msg-123"]
adapter.mu.RUnlock()
if role != "assistant" {
t.Errorf("expected role 'assistant', got %q", role)
}
}
func TestOpenCodeAdapter_ClearSessionState(t *testing.T) {
adapter := newTestOpenCodeAdapter()
// Add some state
adapter.mu.Lock()
adapter.textParts = map[string]*textPartState{
"old-part": {lastTextLen: 100},
}
adapter.messageRoles = map[string]string{
"old-msg": "assistant",
}
adapter.seenToolCalls = map[string]bool{
"old-call": true,
}
adapter.mu.Unlock()
// Clear state
adapter.clearSessionState()
// Verify state is cleared
adapter.mu.RLock()
defer adapter.mu.RUnlock()
if len(adapter.textParts) != 0 {
t.Errorf("expected textParts to be empty, got %d entries", len(adapter.textParts))
}
if len(adapter.messageRoles) != 0 {
t.Errorf("expected messageRoles to be empty, got %d entries", len(adapter.messageRoles))
}
if len(adapter.seenToolCalls) != 0 {
t.Errorf("expected seenToolCalls to be empty, got %d entries", len(adapter.seenToolCalls))
}
}
func TestOpenCodeAdapter_ToolCallDeduplication(t *testing.T) {
adapter := newTestOpenCodeAdapter()
sessionID := "test-session"
operationID := "test-op"
// Simulate multiple tool call events for the same tool call ID
for i := range 3 {
var status string
switch i {
case 1:
status = "running"
case 2:
status = "completed"
default:
status = "pending"
}
props := opencode.MessagePartUpdatedProperties{
Part: opencode.Part{
ID: "tool-part-1",
Type: opencode.PartTypeTool,
MessageID: "msg-1",
SessionID: sessionID,
CallID: "call-123",
Tool: "bash",
State: &opencode.ToolStateUpdate{
Status: status,
Title: "Running command",
},
},
}
propsJSON, _ := json.Marshal(props)
adapter.handleMessagePartUpdated(propsJSON, sessionID, operationID)
}
sentEvents := drainEvents(adapter, 100*time.Millisecond)
// Should have 1 tool_call event and 2 tool_update events
toolCallCount := 0
toolUpdateCount := 0
for _, evt := range sentEvents {
switch evt.Type {
case EventTypeToolCall:
toolCallCount++
case EventTypeToolUpdate:
toolUpdateCount++
}
}
if toolCallCount != 1 {
t.Errorf("expected 1 tool_call event, got %d", toolCallCount)
}
if toolUpdateCount != 2 {
t.Errorf("expected 2 tool_update events, got %d", toolUpdateCount)
}
}