Files
kandev/apps/backend/internal/integration/orchestrator_advanced_test.go
T
João Salavisa ba32a44cb4 refactor(backend): drop legacy orchestrator WS handlers superseded by session.launch (#803)
* refactor(backend): drop legacy orchestrator WS handlers superseded by session.launch

The orchestrator exposed six WS handlers (orchestrator.start, orchestrator.trigger,
orchestrator.prompt, orchestrator.complete, task.session.resume, task.session.prepare)
that all delegated to the unified LaunchSession or are no longer reachable from any
client. Drop the handlers, their DTOs, and the dead action constants
(orchestrator.pause, orchestrator.resume) that were never registered. Migrate the
remaining integration tests to the unified session.launch action.

* refactor(backend): drop orphan DTO request types and fix stale doc reference

Address PR review feedback: the DTO types TaskSessionStatusRequest, StopTaskRequest,
and PermissionRespondRequest had no consumers (each was shadowed by an inline
private ws*Request type in the handler). AGENTS.md still referenced the removed
task.start action; switch it to session.launch.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-05 08:29:46 +01:00

621 lines
19 KiB
Go

// Package integration provides end-to-end integration tests for the Kandev backend.
package integration
import (
"context"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/pkg/acp/protocol"
ws "github.com/kandev/kandev/pkg/websocket"
)
func TestOrchestratorTaskPriority(t *testing.T) {
ts := NewOrchestratorTestServer(t)
defer ts.Close()
// Create tasks with different priorities
lowPriorityTask := ts.CreateTestTask(t, "augment-agent", 1)
highPriorityTask := ts.CreateTestTask(t, "augment-agent", 3)
medPriorityTask := ts.CreateTestTask(t, "augment-agent", 2)
client := NewOrchestratorWSClient(t, ts.Server.URL)
defer client.Close()
// Start low priority first
_, err := client.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{
"task_id": lowPriorityTask,
"agent_profile_id": "augment-agent",
})
require.NoError(t, err)
// Then high priority
_, err = client.SendRequest("start-2", ws.ActionSessionLaunch, map[string]interface{}{
"task_id": highPriorityTask,
"agent_profile_id": "augment-agent",
})
require.NoError(t, err)
// Then medium priority
_, err = client.SendRequest("start-3", ws.ActionSessionLaunch, map[string]interface{}{
"task_id": medPriorityTask,
"agent_profile_id": "augment-agent",
})
require.NoError(t, err)
// All should start (we have capacity)
assert.Equal(t, 3, ts.AgentManager.GetLaunchCount())
}
// TestOrchestratorAgentMessagePersistence validates that agent messages are stored
// correctly in the database without missing data or ordering issues.
func TestOrchestratorAgentMessagePersistence(t *testing.T) {
ts := NewOrchestratorTestServer(t)
defer ts.Close()
// This test verifies that message_streaming and log events ARE correctly persisted.
// Progress messages (protocol.MessageTypeProgress) are NOT persisted - only broadcast via WebSocket.
// Log messages (protocol.MessageTypeLog) ARE now persisted to the database.
// We'll manually publish message_streaming and log events to test persistence
ts.AgentManager.SetACPMessageFn(func(taskID, executionID string) []protocol.Message {
// Just emit a simple progress message - won't be stored
return []protocol.Message{
{
Type: protocol.MessageTypeProgress,
TaskID: taskID,
Timestamp: time.Now(),
Data: map[string]interface{}{
"progress": 10,
"stage": "starting",
"message": "Starting...",
},
},
}
})
// Create test task
taskID := ts.CreateTestTask(t, "test-profile-id", 2)
client := NewOrchestratorWSClient(t, ts.Server.URL)
defer client.Close()
// Subscribe to task
_, err := client.SendRequest("sub-1", ws.ActionTaskSubscribe, map[string]string{"task_id": taskID})
require.NoError(t, err)
// Start task execution
startResp, err := client.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{
"task_id": taskID,
"agent_profile_id": "test-profile-id",
})
require.NoError(t, err)
var startPayload map[string]interface{}
require.NoError(t, startResp.ParsePayload(&startPayload))
require.True(t, startPayload["success"].(bool), "Task should start successfully")
sessionID, _ := startPayload["session_id"].(string)
require.NotEmpty(t, sessionID, "Session ID should be returned")
// Subscribe to session for agent stream events
_, err = client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID})
require.NoError(t, err)
// Wait for simulated agent to start
time.Sleep(200 * time.Millisecond)
// Now manually publish message_streaming events which ARE persisted
// Generate unique message IDs for each streaming message
messageIDs := []string{
"msg-" + uuid.New().String()[:8],
"msg-" + uuid.New().String()[:8],
"msg-" + uuid.New().String()[:8],
}
expectedContents := []string{
"I'm analyzing the codebase structure.",
"I found several files that need modification.",
"Here is my recommendation for the changes.",
}
// Log messages to publish
logMessages := []struct {
Level string
Message string
}{
{"info", "Found 10 files to process"},
{"debug", "Processing file: main.go"},
{"warning", "Deprecated API usage detected"},
}
// Publish message_streaming events - these ARE persisted
for i, msgID := range messageIDs {
payload := map[string]interface{}{
"type": "agent/event",
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
"agent_id": "test-agent",
"task_id": taskID,
"session_id": sessionID,
"data": map[string]interface{}{
"type": "message_streaming",
"text": expectedContents[i],
"message_id": msgID,
"is_append": false, // Each is a new message
},
}
event := bus.NewEvent(events.AgentStream, "test", payload)
subject := events.BuildAgentStreamSubject(sessionID)
err := ts.EventBus.Publish(context.Background(), subject, event)
require.NoError(t, err)
time.Sleep(50 * time.Millisecond)
}
// Publish log events - these ARE now persisted
for _, logMsg := range logMessages {
payload := map[string]interface{}{
"type": "agent/event",
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
"agent_id": "test-agent",
"task_id": taskID,
"session_id": sessionID,
"data": map[string]interface{}{
"type": "log",
"data": map[string]interface{}{
"level": logMsg.Level,
"message": logMsg.Message,
},
},
}
event := bus.NewEvent(events.AgentStream, "test", payload)
subject := events.BuildAgentStreamSubject(sessionID)
err := ts.EventBus.Publish(context.Background(), subject, event)
require.NoError(t, err)
time.Sleep(50 * time.Millisecond)
}
// Wait for all messages to be processed and stored
time.Sleep(300 * time.Millisecond)
// Query the database to verify stored messages
storedMessages, err := ts.TaskRepo.ListMessages(context.Background(), sessionID)
require.NoError(t, err)
t.Logf("Found %d total stored messages", len(storedMessages))
type msgEntry struct {
ID string
Type string
Content string
AuthorType string
Metadata map[string]interface{}
CreatedAt time.Time
TaskSessionID string
TurnID string
}
var agentMessages []*msgEntry
for _, msg := range storedMessages {
if string(msg.AuthorType) == "agent" {
agentMessages = append(agentMessages, &msgEntry{
ID: msg.ID,
Type: string(msg.Type),
Content: msg.Content,
AuthorType: string(msg.AuthorType),
Metadata: msg.Metadata,
CreatedAt: msg.CreatedAt,
TaskSessionID: msg.TaskSessionID,
TurnID: msg.TurnID,
})
}
}
t.Logf("Found %d agent messages in database", len(agentMessages))
// Verify messages are stored
require.GreaterOrEqual(t, len(agentMessages), 3, "Should have at least 3 agent messages from streaming events")
// Verify messages are in correct chronological order
for i := 1; i < len(agentMessages); i++ {
prev := agentMessages[i-1]
curr := agentMessages[i]
assert.True(t, !curr.CreatedAt.Before(prev.CreatedAt),
"Messages should be in chronological order: message %d (%s) at %v should not be before message %d (%s) at %v",
i, curr.ID, curr.CreatedAt, i-1, prev.ID, prev.CreatedAt)
}
// Verify no duplicate messages exist (check for duplicate IDs)
seenIDs := make(map[string]bool)
for _, msg := range agentMessages {
assert.False(t, seenIDs[msg.ID], "Duplicate message ID found: %s", msg.ID)
seenIDs[msg.ID] = true
}
// Verify all messages are associated with the correct TaskSessionID
for _, msg := range agentMessages {
assert.Equal(t, sessionID, msg.TaskSessionID,
"Message %s should be associated with session %s", msg.ID, sessionID)
}
// Verify all agent messages have a TurnID
for _, msg := range agentMessages {
assert.NotEmpty(t, msg.TurnID,
"Message %s should have a TurnID", msg.ID)
}
// Verify message content matches expected content
var contentMessages []*msgEntry
for _, msg := range agentMessages {
if msg.Type == "content" {
contentMessages = append(contentMessages, msg)
}
}
require.Len(t, contentMessages, 3, "Should have exactly 3 content messages")
// Verify content is in correct order
for i, expected := range expectedContents {
if i < len(contentMessages) {
assert.Equal(t, expected, contentMessages[i].Content,
"Message %d content should match expected", i)
}
}
// Log summary of stored messages for debugging
t.Logf("Message storage verification summary:")
for i, msg := range agentMessages {
t.Logf(" [%d] ID=%s Type=%s Content=%q CreatedAt=%v TurnID=%s",
i, msg.ID[:8], msg.Type, truncateString(msg.Content, 50), msg.CreatedAt, msg.TurnID[:8])
}
}
// TestOrchestratorAgentMessagePersistenceWithToolCalls validates that tool call messages
// are stored correctly with proper metadata including tool_call_id, title, status, and args.
func TestOrchestratorAgentMessagePersistenceWithToolCalls(t *testing.T) {
ts := NewOrchestratorTestServer(t)
defer ts.Close()
// Configure simulated agent to emit tool call events via direct event publishing
toolCallID1 := "tc-" + uuid.New().String()[:8]
toolCallID2 := "tc-" + uuid.New().String()[:8]
ts.AgentManager.SetACPMessageFn(func(taskID, executionID string) []protocol.Message {
baseTime := time.Now()
return []protocol.Message{
// Initial progress
{
Type: protocol.MessageTypeProgress,
TaskID: taskID,
Timestamp: baseTime,
Data: map[string]interface{}{
"progress": 10,
"stage": "starting",
"message": "Starting tool execution...",
},
},
// Log message
{
Type: protocol.MessageTypeLog,
TaskID: taskID,
Timestamp: baseTime.Add(20 * time.Millisecond),
Data: map[string]interface{}{
"level": "info",
"message": "Executing view tool",
},
},
// Final progress
{
Type: protocol.MessageTypeProgress,
TaskID: taskID,
Timestamp: baseTime.Add(100 * time.Millisecond),
Data: map[string]interface{}{
"progress": 100,
"stage": "completed",
"message": "Tool execution completed",
},
},
}
})
// Create test task
taskID := ts.CreateTestTask(t, "test-profile-id", 2)
client := NewOrchestratorWSClient(t, ts.Server.URL)
defer client.Close()
// Subscribe to task
_, err := client.SendRequest("sub-1", ws.ActionTaskSubscribe, map[string]string{"task_id": taskID})
require.NoError(t, err)
// Start task execution
startResp, err := client.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{
"task_id": taskID,
"agent_profile_id": "test-profile-id",
})
require.NoError(t, err)
var startPayload map[string]interface{}
require.NoError(t, startResp.ParsePayload(&startPayload))
require.True(t, startPayload["success"].(bool))
sessionID, _ := startPayload["session_id"].(string)
require.NotEmpty(t, sessionID)
// Subscribe to session
_, err = client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID})
require.NoError(t, err)
// Now publish tool call events directly to the event bus to simulate real tool usage
// This mimics what the lifecycle manager does when processing agent tool calls
publishToolCallEvent := func(toolCallID, title, status string, args map[string]interface{}) {
payload := map[string]interface{}{
"type": "agent/event",
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
"agent_id": "test-agent",
"task_id": taskID,
"session_id": sessionID,
"data": map[string]interface{}{
"type": "tool_call",
"tool_call_id": toolCallID,
"tool_title": title,
"tool_name": "view",
"tool_status": status,
"tool_args": args,
},
}
event := bus.NewEvent(events.AgentStream, "test", payload)
subject := events.BuildAgentStreamSubject(sessionID)
err := ts.EventBus.Publish(context.Background(), subject, event)
require.NoError(t, err)
}
publishToolUpdateEvent := func(toolCallID, status, result string) {
payload := map[string]interface{}{
"type": "agent/event",
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
"agent_id": "test-agent",
"task_id": taskID,
"session_id": sessionID,
"data": map[string]interface{}{
"type": "tool_update",
"tool_call_id": toolCallID,
"tool_status": status,
"tool_result": result,
},
}
event := bus.NewEvent(events.AgentStream, "test", payload)
subject := events.BuildAgentStreamSubject(sessionID)
err := ts.EventBus.Publish(context.Background(), subject, event)
require.NoError(t, err)
}
// Wait a bit for the initial messages to be processed
time.Sleep(300 * time.Millisecond)
// Publish tool call start events
publishToolCallEvent(toolCallID1, "View main.go", "running", map[string]interface{}{
"path": "main.go",
"kind": "view",
})
time.Sleep(50 * time.Millisecond)
publishToolCallEvent(toolCallID2, "View utils.go", "running", map[string]interface{}{
"path": "utils.go",
"kind": "view",
})
time.Sleep(50 * time.Millisecond)
// Publish tool update events (completion)
publishToolUpdateEvent(toolCallID1, "complete", "File content: package main...")
time.Sleep(50 * time.Millisecond)
publishToolUpdateEvent(toolCallID2, "complete", "File content: package utils...")
time.Sleep(50 * time.Millisecond)
// Wait for processing
time.Sleep(500 * time.Millisecond)
// Query stored messages
storedMessages, err := ts.TaskRepo.ListMessages(context.Background(), sessionID)
require.NoError(t, err)
t.Logf("Found %d total stored messages", len(storedMessages))
// Find tool_call messages
var toolCallMessages []*struct {
ID string
Type string
Content string
Metadata map[string]interface{}
}
for _, msg := range storedMessages {
if string(msg.Type) == "tool_call" {
toolCallMessages = append(toolCallMessages, &struct {
ID string
Type string
Content string
Metadata map[string]interface{}
}{
ID: msg.ID,
Type: string(msg.Type),
Content: msg.Content,
Metadata: msg.Metadata,
})
}
}
t.Logf("Found %d tool_call messages", len(toolCallMessages))
// Verify tool call messages exist and have correct metadata
assert.GreaterOrEqual(t, len(toolCallMessages), 2,
"Should have at least 2 tool_call messages")
// Verify tool call metadata structure
for _, msg := range toolCallMessages {
assert.NotNil(t, msg.Metadata, "Tool call message should have metadata")
if msg.Metadata != nil {
toolCallIDMeta, _ := msg.Metadata["tool_call_id"].(string)
assert.NotEmpty(t, toolCallIDMeta,
"Tool call message should have tool_call_id in metadata")
title, _ := msg.Metadata["title"].(string)
t.Logf("Tool call message: ID=%s, tool_call_id=%s, title=%s, status=%v",
msg.ID[:8], toolCallIDMeta, title, msg.Metadata["status"])
}
}
}
// TestOrchestratorAgentMessageChunkStreaming validates that streaming message chunks
// are correctly aggregated and stored as complete messages.
func TestOrchestratorAgentMessageChunkStreaming(t *testing.T) {
ts := NewOrchestratorTestServer(t)
defer ts.Close()
// Configure minimal ACP messages - we'll manually publish streaming chunks
ts.AgentManager.SetACPMessageFn(func(taskID, executionID string) []protocol.Message {
return []protocol.Message{
{
Type: protocol.MessageTypeProgress,
TaskID: taskID,
Timestamp: time.Now(),
Data: map[string]interface{}{
"progress": 10,
"stage": "starting",
"message": "Starting...",
},
},
}
})
// Create test task
taskID := ts.CreateTestTask(t, "test-profile-id", 2)
client := NewOrchestratorWSClient(t, ts.Server.URL)
defer client.Close()
// Subscribe and start task
_, err := client.SendRequest("sub-1", ws.ActionTaskSubscribe, map[string]string{"task_id": taskID})
require.NoError(t, err)
startResp, err := client.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{
"task_id": taskID,
"agent_profile_id": "test-profile-id",
})
require.NoError(t, err)
var startPayload map[string]interface{}
require.NoError(t, startResp.ParsePayload(&startPayload))
require.True(t, startPayload["success"].(bool))
sessionID, _ := startPayload["session_id"].(string)
require.NotEmpty(t, sessionID)
_, err = client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID})
require.NoError(t, err)
// Wait for initial setup
time.Sleep(300 * time.Millisecond)
// Simulate streaming message chunks by publishing message_streaming events
messageID := uuid.New().String()
chunks := []string{
"Hello, ",
"this is ",
"a streaming ",
"message that ",
"should be ",
"aggregated.",
}
for i, chunk := range chunks {
payload := map[string]interface{}{
"type": "agent/event",
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
"agent_id": "test-agent",
"task_id": taskID,
"session_id": sessionID,
"data": map[string]interface{}{
"type": "message_streaming",
"text": chunk,
"message_id": messageID,
"is_append": i > 0, // First chunk creates, subsequent append
},
}
event := bus.NewEvent(events.AgentStream, "test", payload)
subject := events.BuildAgentStreamSubject(sessionID)
err := ts.EventBus.Publish(context.Background(), subject, event)
require.NoError(t, err)
time.Sleep(30 * time.Millisecond)
}
// Wait for processing
time.Sleep(500 * time.Millisecond)
// Query stored messages
storedMessages, err := ts.TaskRepo.ListMessages(context.Background(), sessionID)
require.NoError(t, err)
// Find the streaming message
var streamingMessage *struct {
ID string
Content string
Type string
}
for _, msg := range storedMessages {
if msg.ID == messageID {
streamingMessage = &struct {
ID string
Content string
Type string
}{
ID: msg.ID,
Content: msg.Content,
Type: string(msg.Type),
}
break
}
}
// Verify the streaming message was created and aggregated
if streamingMessage != nil {
expectedContent := strings.Join(chunks, "")
assert.Equal(t, expectedContent, streamingMessage.Content,
"Streaming message content should be aggregated from all chunks")
t.Logf("Streaming message aggregated correctly: %q", streamingMessage.Content)
} else {
// The message might be stored with agent-generated ID
// Look for content messages from the agent
var agentContentMessages []*struct {
ID string
Content string
Type string
}
for _, msg := range storedMessages {
if string(msg.AuthorType) == "agent" && (string(msg.Type) == "message" || string(msg.Type) == "content") {
agentContentMessages = append(agentContentMessages, &struct {
ID string
Content string
Type string
}{
ID: msg.ID,
Content: msg.Content,
Type: string(msg.Type),
})
}
}
t.Logf("Found %d agent content messages", len(agentContentMessages))
for _, msg := range agentContentMessages {
t.Logf(" ID=%s Type=%s Content=%q", msg.ID[:8], msg.Type, truncateString(msg.Content, 100))
}
}
}
// truncateString truncates a string to the specified length, appending "..." if truncated.
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}