1433 lines
43 KiB
Go
1433 lines
43 KiB
Go
package lifecycle
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/agent/agents"
|
|
"github.com/kandev/kandev/internal/agent/docker"
|
|
"github.com/kandev/kandev/internal/agent/registry"
|
|
agentctl "github.com/kandev/kandev/internal/agentctl/client"
|
|
"github.com/kandev/kandev/internal/agentctl/server/adapter"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/events/bus"
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
v1 "github.com/kandev/kandev/pkg/api/v1"
|
|
)
|
|
|
|
// testAgent implements agents.Agent for use in lifecycle tests.
|
|
// Embed StandardPassthrough to optionally satisfy agents.PassthroughAgent.
|
|
type testAgent struct {
|
|
agents.StandardPassthrough
|
|
id string
|
|
name string
|
|
displayName string
|
|
description string
|
|
enabled bool
|
|
defaultModel string
|
|
permissionSettings map[string]agents.PermissionSetting
|
|
runtimeConfig *agents.RuntimeConfig
|
|
}
|
|
|
|
func (a *testAgent) ID() string { return a.id }
|
|
func (a *testAgent) Name() string { return a.name }
|
|
func (a *testAgent) DisplayName() string { return a.displayName }
|
|
func (a *testAgent) Description() string { return a.description }
|
|
func (a *testAgent) Enabled() bool { return a.enabled }
|
|
|
|
func (a *testAgent) Logo(_ agents.LogoVariant) []byte { return nil }
|
|
func (a *testAgent) IsInstalled(_ context.Context) (*agents.DiscoveryResult, error) {
|
|
return &agents.DiscoveryResult{Available: false}, nil
|
|
}
|
|
|
|
func (a *testAgent) DefaultModel() string { return a.defaultModel }
|
|
func (a *testAgent) ListModels(_ context.Context) (*agents.ModelList, error) {
|
|
return &agents.ModelList{}, nil
|
|
}
|
|
|
|
func (a *testAgent) CreateAdapter(_ *adapter.Config, _ *logger.Logger) (adapter.AgentAdapter, error) {
|
|
return nil, agents.ErrNotSupported
|
|
}
|
|
|
|
func (a *testAgent) BuildCommand(_ agents.CommandOptions) agents.Command { return agents.Command{} }
|
|
|
|
func (a *testAgent) PermissionSettings() map[string]agents.PermissionSetting {
|
|
return a.permissionSettings
|
|
}
|
|
|
|
func (a *testAgent) Runtime() *agents.RuntimeConfig {
|
|
if a.runtimeConfig != nil {
|
|
return a.runtimeConfig
|
|
}
|
|
return &agents.RuntimeConfig{
|
|
Cmd: agents.NewCommand(a.id),
|
|
ResourceLimits: agents.ResourceLimits{MemoryMB: 512, CPUCores: 0.5, Timeout: time.Hour},
|
|
}
|
|
}
|
|
|
|
var (
|
|
_ agents.Agent = (*testAgent)(nil)
|
|
_ agents.PassthroughAgent = (*testAgent)(nil)
|
|
)
|
|
|
|
// MockDockerClient implements a mock for the docker.Client for testing
|
|
type MockDockerClient struct {
|
|
CreateContainerFn func(ctx context.Context, cfg docker.ContainerConfig) (string, error)
|
|
StartContainerFn func(ctx context.Context, containerID string) error
|
|
StopContainerFn func(ctx context.Context, containerID string, timeout time.Duration) error
|
|
KillContainerFn func(ctx context.Context, containerID string, signal string) error
|
|
RemoveContainerFn func(ctx context.Context, containerID string, force bool) error
|
|
GetContainerInfoFn func(ctx context.Context, containerID string) (*docker.ContainerInfo, error)
|
|
GetContainerLogsFn func(ctx context.Context, containerID string, follow bool, tail string) (io.ReadCloser, error)
|
|
ListContainersFn func(ctx context.Context, labels map[string]string) ([]docker.ContainerInfo, error)
|
|
}
|
|
|
|
func (m *MockDockerClient) CreateContainer(ctx context.Context, cfg docker.ContainerConfig) (string, error) {
|
|
if m.CreateContainerFn != nil {
|
|
return m.CreateContainerFn(ctx, cfg)
|
|
}
|
|
return "mock-container-id", nil
|
|
}
|
|
|
|
func (m *MockDockerClient) StartContainer(ctx context.Context, containerID string) error {
|
|
if m.StartContainerFn != nil {
|
|
return m.StartContainerFn(ctx, containerID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *MockDockerClient) StopContainer(ctx context.Context, containerID string, timeout time.Duration) error {
|
|
if m.StopContainerFn != nil {
|
|
return m.StopContainerFn(ctx, containerID, timeout)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *MockDockerClient) KillContainer(ctx context.Context, containerID string, signal string) error {
|
|
if m.KillContainerFn != nil {
|
|
return m.KillContainerFn(ctx, containerID, signal)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *MockDockerClient) RemoveContainer(ctx context.Context, containerID string, force bool) error {
|
|
if m.RemoveContainerFn != nil {
|
|
return m.RemoveContainerFn(ctx, containerID, force)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *MockDockerClient) GetContainerInfo(ctx context.Context, containerID string) (*docker.ContainerInfo, error) {
|
|
if m.GetContainerInfoFn != nil {
|
|
return m.GetContainerInfoFn(ctx, containerID)
|
|
}
|
|
return &docker.ContainerInfo{ID: containerID, State: "running"}, nil
|
|
}
|
|
|
|
func (m *MockDockerClient) GetContainerLogs(ctx context.Context, containerID string, follow bool, tail string) (io.ReadCloser, error) {
|
|
if m.GetContainerLogsFn != nil {
|
|
return m.GetContainerLogsFn(ctx, containerID, follow, tail)
|
|
}
|
|
return io.NopCloser(strings.NewReader("test logs")), nil
|
|
}
|
|
|
|
func (m *MockDockerClient) ListContainers(ctx context.Context, labels map[string]string) ([]docker.ContainerInfo, error) {
|
|
if m.ListContainersFn != nil {
|
|
return m.ListContainersFn(ctx, labels)
|
|
}
|
|
return []docker.ContainerInfo{}, nil
|
|
}
|
|
|
|
// MockEventBus implements bus.EventBus for testing
|
|
type MockEventBus struct {
|
|
PublishedEvents []*bus.Event
|
|
}
|
|
|
|
func (m *MockEventBus) Publish(ctx context.Context, subject string, event *bus.Event) error {
|
|
m.PublishedEvents = append(m.PublishedEvents, event)
|
|
return nil
|
|
}
|
|
|
|
func (m *MockEventBus) Subscribe(subject string, handler bus.EventHandler) (bus.Subscription, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockEventBus) QueueSubscribe(subject, queue string, handler bus.EventHandler) (bus.Subscription, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockEventBus) Request(ctx context.Context, subject string, event *bus.Event, timeout time.Duration) (*bus.Event, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockEventBus) Close() {}
|
|
|
|
func (m *MockEventBus) IsConnected() bool {
|
|
return true
|
|
}
|
|
|
|
func newTestLogger() *logger.Logger {
|
|
log, _ := logger.NewLogger(logger.LoggingConfig{
|
|
Level: "error",
|
|
Format: "json",
|
|
})
|
|
return log
|
|
}
|
|
|
|
func newTestRegistry() *registry.Registry {
|
|
log := newTestLogger()
|
|
reg := registry.NewRegistry(log)
|
|
reg.LoadDefaults()
|
|
return reg
|
|
}
|
|
|
|
// MockCredentialsManager implements CredentialsManager for testing
|
|
type MockCredentialsManager struct{}
|
|
|
|
func (m *MockCredentialsManager) GetCredentialValue(ctx context.Context, key string) (string, error) {
|
|
return "", nil
|
|
}
|
|
|
|
// MockProfileResolver implements ProfileResolver for testing
|
|
type MockProfileResolver struct{}
|
|
|
|
func (m *MockProfileResolver) ResolveProfile(ctx context.Context, profileID string) (*AgentProfileInfo, error) {
|
|
return &AgentProfileInfo{
|
|
ProfileID: profileID,
|
|
ProfileName: "Test Profile",
|
|
AgentID: "augment-agent",
|
|
AgentName: "auggie",
|
|
Model: "claude-sonnet-4-20250514",
|
|
}, nil
|
|
}
|
|
|
|
// newTestManager creates a Manager for testing with mock dependencies
|
|
func newTestManager() *Manager {
|
|
log := newTestLogger()
|
|
reg := newTestRegistry()
|
|
eventBus := &MockEventBus{}
|
|
credsMgr := &MockCredentialsManager{}
|
|
profileResolver := &MockProfileResolver{}
|
|
// Pass nil for runtime and containerManager - tests don't need them
|
|
return NewManager(reg, eventBus, nil, nil, credsMgr, profileResolver, nil, RuntimeFallbackWarn, log)
|
|
}
|
|
|
|
func TestNewManager(t *testing.T) {
|
|
mgr := newTestManager()
|
|
|
|
if mgr == nil {
|
|
t.Fatal("expected non-nil manager")
|
|
}
|
|
if len(mgr.ListExecutions()) != 0 {
|
|
t.Errorf("expected empty executions, got %d", len(mgr.ListExecutions()))
|
|
}
|
|
}
|
|
|
|
func TestManager_GetExecution(t *testing.T) {
|
|
mgr := newTestManager()
|
|
|
|
// Manually add an execution for testing
|
|
execution := &AgentExecution{
|
|
ID: "test-execution-id",
|
|
TaskID: "test-task-id",
|
|
AgentProfileID: "test-agent",
|
|
ContainerID: "container-123",
|
|
Status: v1.AgentStatusRunning,
|
|
StartedAt: time.Now(),
|
|
}
|
|
|
|
mgr.executionStore.Add(execution)
|
|
|
|
// Test GetExecution
|
|
got, found := mgr.GetExecution("test-execution-id")
|
|
if !found {
|
|
t.Fatal("expected to find execution")
|
|
}
|
|
if got.ID != execution.ID {
|
|
t.Errorf("expected ID %q, got %q", execution.ID, got.ID)
|
|
}
|
|
|
|
// Test not found
|
|
_, found = mgr.GetExecution("non-existent")
|
|
if found {
|
|
t.Error("expected not to find execution")
|
|
}
|
|
}
|
|
|
|
func TestManager_GetExecutionBySessionID(t *testing.T) {
|
|
mgr := newTestManager()
|
|
|
|
execution := &AgentExecution{
|
|
ID: "test-execution-id",
|
|
TaskID: "test-task-id",
|
|
SessionID: "test-session-id",
|
|
AgentProfileID: "test-agent",
|
|
ContainerID: "container-123",
|
|
Status: v1.AgentStatusRunning,
|
|
StartedAt: time.Now(),
|
|
}
|
|
|
|
mgr.executionStore.Add(execution)
|
|
|
|
// Test GetExecutionBySessionID
|
|
got, found := mgr.GetExecutionBySessionID("test-session-id")
|
|
if !found {
|
|
t.Fatal("expected to find execution")
|
|
}
|
|
if got.SessionID != execution.SessionID {
|
|
t.Errorf("expected SessionID %q, got %q", execution.SessionID, got.SessionID)
|
|
}
|
|
|
|
// Test not found
|
|
_, found = mgr.GetExecutionBySessionID("non-existent")
|
|
if found {
|
|
t.Error("expected not to find execution")
|
|
}
|
|
}
|
|
|
|
func TestManager_ListExecutions(t *testing.T) {
|
|
mgr := newTestManager()
|
|
|
|
// Empty list
|
|
list := mgr.ListExecutions()
|
|
if len(list) != 0 {
|
|
t.Errorf("expected empty list, got %d", len(list))
|
|
}
|
|
|
|
// Add executions
|
|
mgr.executionStore.Add(&AgentExecution{ID: "execution-1", TaskID: "task-1", Status: v1.AgentStatusRunning})
|
|
mgr.executionStore.Add(&AgentExecution{ID: "execution-2", TaskID: "task-2", Status: v1.AgentStatusCompleted})
|
|
|
|
list = mgr.ListExecutions()
|
|
if len(list) != 2 {
|
|
t.Errorf("expected 2 executions, got %d", len(list))
|
|
}
|
|
}
|
|
|
|
func TestManager_UpdateStatus(t *testing.T) {
|
|
mgr := newTestManager()
|
|
|
|
execution := &AgentExecution{
|
|
ID: "test-execution-id",
|
|
TaskID: "test-task-id",
|
|
Status: v1.AgentStatusRunning,
|
|
}
|
|
|
|
mgr.executionStore.Add(execution)
|
|
|
|
// Test UpdateStatus
|
|
err := mgr.UpdateStatus("test-execution-id", v1.AgentStatusCompleted)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
got, _ := mgr.GetExecution("test-execution-id")
|
|
if got.Status != v1.AgentStatusCompleted {
|
|
t.Errorf("expected status %v, got %v", v1.AgentStatusCompleted, got.Status)
|
|
}
|
|
|
|
// Test not found
|
|
err = mgr.UpdateStatus("non-existent", v1.AgentStatusCompleted)
|
|
if err == nil {
|
|
t.Error("expected error for non-existent execution")
|
|
}
|
|
}
|
|
|
|
func TestManager_MarkCompleted_Success(t *testing.T) {
|
|
mgr := newTestManager()
|
|
|
|
execution := &AgentExecution{
|
|
ID: "test-execution-id",
|
|
TaskID: "test-task-id",
|
|
AgentProfileID: "test-agent",
|
|
ContainerID: "container-123",
|
|
Status: v1.AgentStatusRunning,
|
|
StartedAt: time.Now(),
|
|
}
|
|
|
|
mgr.executionStore.Add(execution)
|
|
|
|
// Mark as completed successfully (exit code 0)
|
|
err := mgr.MarkCompleted("test-execution-id", 0, "")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
got, _ := mgr.GetExecution("test-execution-id")
|
|
if got.Status != v1.AgentStatusCompleted {
|
|
t.Errorf("expected status %v, got %v", v1.AgentStatusCompleted, got.Status)
|
|
}
|
|
if got.FinishedAt == nil {
|
|
t.Error("expected FinishedAt to be set")
|
|
}
|
|
if got.ExitCode == nil || *got.ExitCode != 0 {
|
|
t.Errorf("expected exit code 0, got %v", got.ExitCode)
|
|
}
|
|
}
|
|
|
|
func TestManager_MarkCompleted_Failure(t *testing.T) {
|
|
mgr := newTestManager()
|
|
|
|
execution := &AgentExecution{
|
|
ID: "test-execution-id",
|
|
TaskID: "test-task-id",
|
|
AgentProfileID: "test-agent",
|
|
ContainerID: "container-123",
|
|
Status: v1.AgentStatusRunning,
|
|
StartedAt: time.Now(),
|
|
}
|
|
|
|
mgr.executionStore.Add(execution)
|
|
|
|
// Mark as failed
|
|
err := mgr.MarkCompleted("test-execution-id", 1, "process failed")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
got, _ := mgr.GetExecution("test-execution-id")
|
|
if got.Status != v1.AgentStatusFailed {
|
|
t.Errorf("expected status %v, got %v", v1.AgentStatusFailed, got.Status)
|
|
}
|
|
if got.ErrorMessage != "process failed" {
|
|
t.Errorf("expected error message 'process failed', got %q", got.ErrorMessage)
|
|
}
|
|
if got.ExitCode == nil || *got.ExitCode != 1 {
|
|
t.Errorf("expected exit code 1, got %v", got.ExitCode)
|
|
}
|
|
}
|
|
|
|
func TestManager_MarkCompleted_NotFound(t *testing.T) {
|
|
mgr := newTestManager()
|
|
|
|
err := mgr.MarkCompleted("non-existent", 0, "")
|
|
if err == nil {
|
|
t.Error("expected error for non-existent execution")
|
|
}
|
|
}
|
|
|
|
func TestManager_RemoveExecution(t *testing.T) {
|
|
mgr := newTestManager()
|
|
|
|
execution := &AgentExecution{
|
|
ID: "test-execution-id",
|
|
TaskID: "test-task-id",
|
|
SessionID: "test-session-id",
|
|
ContainerID: "container-123",
|
|
}
|
|
|
|
mgr.executionStore.Add(execution)
|
|
|
|
// Remove execution
|
|
mgr.RemoveExecution("test-execution-id")
|
|
|
|
// Verify it's gone from all maps
|
|
if _, found := mgr.GetExecution("test-execution-id"); found {
|
|
t.Error("execution should be removed from executions map")
|
|
}
|
|
if _, found := mgr.GetExecutionBySessionID("test-session-id"); found {
|
|
t.Error("execution should be removed from bySession map")
|
|
}
|
|
|
|
// Remove non-existent should not panic
|
|
mgr.RemoveExecution("non-existent")
|
|
}
|
|
|
|
func TestManager_StartStop(t *testing.T) {
|
|
mgr := newTestManager()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Test Start
|
|
err := mgr.Start(ctx)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error starting manager: %v", err)
|
|
}
|
|
|
|
// Test Stop
|
|
err = mgr.Stop()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error stopping manager: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildPassthroughCommand(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
agent agents.PassthroughAgent
|
|
opts agents.PassthroughOptions
|
|
wantCmd []string
|
|
}{
|
|
{
|
|
name: "basic command without options",
|
|
agent: &testAgent{
|
|
id: "test-agent",
|
|
StandardPassthrough: agents.StandardPassthrough{
|
|
Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli", "--verbose")},
|
|
},
|
|
},
|
|
opts: agents.PassthroughOptions{Resume: true},
|
|
wantCmd: []string{"test-cli", "--verbose"},
|
|
},
|
|
{
|
|
name: "command with model",
|
|
agent: &testAgent{
|
|
id: "test-agent",
|
|
StandardPassthrough: agents.StandardPassthrough{
|
|
Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli"), ModelFlag: agents.NewParam("--model", "{model}")},
|
|
},
|
|
},
|
|
opts: agents.PassthroughOptions{Model: "gpt-4"},
|
|
wantCmd: []string{"test-cli", "--model", "gpt-4"},
|
|
},
|
|
{
|
|
name: "resume with single-word flag",
|
|
agent: &testAgent{
|
|
id: "test-agent",
|
|
StandardPassthrough: agents.StandardPassthrough{
|
|
Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli"), ResumeFlag: agents.NewParam("-c")},
|
|
},
|
|
},
|
|
opts: agents.PassthroughOptions{Resume: true},
|
|
wantCmd: []string{"test-cli", "-c"},
|
|
},
|
|
{
|
|
name: "resume with multi-word flag",
|
|
agent: &testAgent{
|
|
id: "gemini-agent",
|
|
StandardPassthrough: agents.StandardPassthrough{
|
|
Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("gemini"), ResumeFlag: agents.NewParam("--resume", "latest")},
|
|
},
|
|
},
|
|
opts: agents.PassthroughOptions{Resume: true},
|
|
wantCmd: []string{"gemini", "--resume", "latest"},
|
|
},
|
|
{
|
|
name: "permission settings as CLI flags",
|
|
agent: &testAgent{
|
|
id: "test-agent",
|
|
StandardPassthrough: agents.StandardPassthrough{
|
|
Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli")},
|
|
PermSettings: map[string]agents.PermissionSetting{
|
|
"auto_approve": {Supported: true, ApplyMethod: "cli_flag", CLIFlag: "--yes"},
|
|
},
|
|
},
|
|
},
|
|
opts: agents.PassthroughOptions{
|
|
PermissionValues: map[string]bool{"auto_approve": true},
|
|
},
|
|
wantCmd: []string{"test-cli", "--yes"},
|
|
},
|
|
{
|
|
name: "full resume with model + settings + resume flag",
|
|
agent: &testAgent{
|
|
id: "claude-code",
|
|
StandardPassthrough: agents.StandardPassthrough{
|
|
Cfg: agents.PassthroughConfig{
|
|
Supported: true,
|
|
PassthroughCmd: agents.NewCommand("npx", "-y", "@anthropic-ai/claude-code"),
|
|
ModelFlag: agents.NewParam("--model", "{model}"),
|
|
ResumeFlag: agents.NewParam("-c"),
|
|
},
|
|
PermSettings: map[string]agents.PermissionSetting{
|
|
"dangerously_skip_permissions": {Supported: true, ApplyMethod: "cli_flag", CLIFlag: "--dangerously-skip-permissions"},
|
|
},
|
|
},
|
|
},
|
|
opts: agents.PassthroughOptions{
|
|
Model: "claude-sonnet-4",
|
|
Resume: true,
|
|
PermissionValues: map[string]bool{"dangerously_skip_permissions": true},
|
|
},
|
|
wantCmd: []string{"npx", "-y", "@anthropic-ai/claude-code", "--model", "claude-sonnet-4", "--dangerously-skip-permissions", "-c"},
|
|
},
|
|
{
|
|
name: "permission setting with cli_flag_value",
|
|
agent: &testAgent{
|
|
id: "test-agent",
|
|
StandardPassthrough: agents.StandardPassthrough{
|
|
Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli")},
|
|
PermSettings: map[string]agents.PermissionSetting{
|
|
"auto_approve": {Supported: true, ApplyMethod: "cli_flag", CLIFlag: "--approve-level", CLIFlagValue: "all"},
|
|
},
|
|
},
|
|
},
|
|
opts: agents.PassthroughOptions{
|
|
PermissionValues: map[string]bool{"auto_approve": true},
|
|
},
|
|
wantCmd: []string{"test-cli", "--approve-level", "all"},
|
|
},
|
|
{
|
|
name: "new session with prompt (positional)",
|
|
agent: &testAgent{
|
|
id: "test-agent",
|
|
StandardPassthrough: agents.StandardPassthrough{
|
|
Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli")},
|
|
},
|
|
},
|
|
opts: agents.PassthroughOptions{Prompt: "fix the bug"},
|
|
wantCmd: []string{"test-cli", "fix the bug"},
|
|
},
|
|
{
|
|
name: "new session with prompt flag",
|
|
agent: &testAgent{
|
|
id: "test-agent",
|
|
StandardPassthrough: agents.StandardPassthrough{
|
|
Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli"), PromptFlag: agents.NewParam("--prompt", "{prompt}")},
|
|
},
|
|
},
|
|
opts: agents.PassthroughOptions{Prompt: "fix the bug"},
|
|
wantCmd: []string{"test-cli", "--prompt", "fix the bug"},
|
|
},
|
|
{
|
|
name: "resume with session ID",
|
|
agent: &testAgent{
|
|
id: "test-agent",
|
|
StandardPassthrough: agents.StandardPassthrough{
|
|
Cfg: agents.PassthroughConfig{
|
|
Supported: true,
|
|
PassthroughCmd: agents.NewCommand("test-cli"),
|
|
SessionResumeFlag: agents.NewParam("--resume"),
|
|
},
|
|
},
|
|
},
|
|
opts: agents.PassthroughOptions{SessionID: "sess-123"},
|
|
wantCmd: []string{"test-cli", "--resume", "sess-123"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := tt.agent.BuildPassthroughCommand(tt.opts).Args()
|
|
|
|
if len(got) != len(tt.wantCmd) {
|
|
t.Errorf("BuildPassthroughCommand() = %v, want %v", got, tt.wantCmd)
|
|
return
|
|
}
|
|
|
|
for i, arg := range got {
|
|
if arg != tt.wantCmd[i] {
|
|
t.Errorf("BuildPassthroughCommand()[%d] = %q, want %q", i, arg, tt.wantCmd[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestManager_VerifyPassthroughEnabled(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
profileID string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "valid profile with passthrough enabled",
|
|
profileID: "test-profile",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "empty profile ID",
|
|
profileID: "",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
mgr := newTestManager()
|
|
|
|
// Override profile resolver for this test
|
|
if tt.profileID != "" {
|
|
mgr.profileResolver = &mockPassthroughProfileResolver{
|
|
cliPassthrough: true,
|
|
}
|
|
}
|
|
|
|
err := mgr.verifyPassthroughEnabled(context.Background(), "test-session", tt.profileID)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("verifyPassthroughEnabled() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// mockPassthroughProfileResolver is a mock for testing passthrough verification
|
|
type mockPassthroughProfileResolver struct {
|
|
cliPassthrough bool
|
|
err error
|
|
}
|
|
|
|
func (m *mockPassthroughProfileResolver) ResolveProfile(ctx context.Context, profileID string) (*AgentProfileInfo, error) {
|
|
if m.err != nil {
|
|
return nil, m.err
|
|
}
|
|
return &AgentProfileInfo{
|
|
ProfileID: profileID,
|
|
CLIPassthrough: m.cliPassthrough,
|
|
}, nil
|
|
}
|
|
|
|
// --- Tests for boot message (agent_boot, is_resuming, pollAgentStderr) ---
|
|
|
|
// MockBootMessageService implements BootMessageService for testing
|
|
type MockBootMessageService struct {
|
|
mu sync.Mutex
|
|
CreatedMessages []*models.Message
|
|
UpdatedMessages []*models.Message
|
|
createErr error
|
|
updateErr error
|
|
}
|
|
|
|
func (m *MockBootMessageService) CreateMessage(ctx context.Context, req *BootMessageRequest) (*models.Message, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.createErr != nil {
|
|
return nil, m.createErr
|
|
}
|
|
msg := &models.Message{
|
|
ID: "boot-msg-" + req.TaskID,
|
|
TaskSessionID: req.TaskSessionID,
|
|
TaskID: req.TaskID,
|
|
Content: req.Content,
|
|
AuthorType: models.MessageAuthorType(req.AuthorType),
|
|
Type: models.MessageType(req.Type),
|
|
Metadata: req.Metadata,
|
|
}
|
|
m.CreatedMessages = append(m.CreatedMessages, msg)
|
|
return msg, nil
|
|
}
|
|
|
|
func (m *MockBootMessageService) UpdateMessage(ctx context.Context, message *models.Message) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.updateErr != nil {
|
|
return m.updateErr
|
|
}
|
|
// Store a snapshot of the message at update time
|
|
snapshot := *message
|
|
metaCopy := make(map[string]interface{})
|
|
for k, v := range message.Metadata {
|
|
metaCopy[k] = v
|
|
}
|
|
snapshot.Metadata = metaCopy
|
|
m.UpdatedMessages = append(m.UpdatedMessages, &snapshot)
|
|
return nil
|
|
}
|
|
|
|
func (m *MockBootMessageService) getLastUpdatedMessage() *models.Message {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if len(m.UpdatedMessages) == 0 {
|
|
return nil
|
|
}
|
|
return m.UpdatedMessages[len(m.UpdatedMessages)-1]
|
|
}
|
|
|
|
func TestFinalizeBootMessage_Success(t *testing.T) {
|
|
mgr := newTestManager()
|
|
bootSvc := &MockBootMessageService{}
|
|
mgr.bootMessageService = bootSvc
|
|
|
|
msg := &models.Message{
|
|
ID: "boot-msg-1",
|
|
Metadata: map[string]interface{}{"status": "running"},
|
|
}
|
|
stopCh := make(chan struct{})
|
|
|
|
mgr.finalizeBootMessage(msg, stopCh, nil, "exited")
|
|
|
|
// Verify stop channel was closed
|
|
select {
|
|
case <-stopCh:
|
|
// good, channel was closed
|
|
default:
|
|
t.Error("expected stopCh to be closed")
|
|
}
|
|
|
|
// Verify message was updated with final status
|
|
lastMsg := bootSvc.getLastUpdatedMessage()
|
|
if lastMsg == nil {
|
|
t.Fatal("expected boot message to be updated")
|
|
} else {
|
|
if lastMsg.Metadata["status"] != "exited" {
|
|
t.Errorf("expected status 'exited', got %v", lastMsg.Metadata["status"])
|
|
}
|
|
if lastMsg.Metadata["exit_code"] != 0 {
|
|
t.Errorf("expected exit_code 0, got %v", lastMsg.Metadata["exit_code"])
|
|
}
|
|
if lastMsg.Metadata["completed_at"] == nil {
|
|
t.Error("expected completed_at to be set")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFinalizeBootMessage_Failed(t *testing.T) {
|
|
mgr := newTestManager()
|
|
bootSvc := &MockBootMessageService{}
|
|
mgr.bootMessageService = bootSvc
|
|
|
|
msg := &models.Message{
|
|
ID: "boot-msg-1",
|
|
Metadata: map[string]interface{}{"status": "running"},
|
|
}
|
|
stopCh := make(chan struct{})
|
|
|
|
mgr.finalizeBootMessage(msg, stopCh, nil, "failed")
|
|
|
|
lastMsg := bootSvc.getLastUpdatedMessage()
|
|
if lastMsg == nil {
|
|
t.Fatal("expected boot message to be updated")
|
|
} else {
|
|
if lastMsg.Metadata["status"] != "failed" {
|
|
t.Errorf("expected status 'failed', got %v", lastMsg.Metadata["status"])
|
|
}
|
|
// Failed status should NOT have exit_code
|
|
if _, ok := lastMsg.Metadata["exit_code"]; ok {
|
|
t.Error("expected no exit_code for failed status")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFinalizeBootMessage_NilMessage(t *testing.T) {
|
|
mgr := newTestManager()
|
|
bootSvc := &MockBootMessageService{}
|
|
mgr.bootMessageService = bootSvc
|
|
|
|
// Should not panic with nil message
|
|
mgr.finalizeBootMessage(nil, nil, nil, "exited")
|
|
|
|
bootSvc.mu.Lock()
|
|
defer bootSvc.mu.Unlock()
|
|
if len(bootSvc.UpdatedMessages) != 0 {
|
|
t.Error("expected no updates for nil message")
|
|
}
|
|
}
|
|
|
|
func TestFinalizeBootMessage_NilService(t *testing.T) {
|
|
mgr := newTestManager()
|
|
// bootMessageService is nil by default
|
|
|
|
msg := &models.Message{
|
|
ID: "boot-msg-1",
|
|
Metadata: map[string]interface{}{"status": "running"},
|
|
}
|
|
|
|
// Should not panic with nil service
|
|
mgr.finalizeBootMessage(msg, nil, nil, "exited")
|
|
}
|
|
|
|
func TestBootMessage_IsResumingFlag(t *testing.T) {
|
|
// Test that the is_resuming flag is correctly based on ACPSessionID presence
|
|
tests := []struct {
|
|
name string
|
|
acpSessionID string
|
|
wantResuming bool
|
|
}{
|
|
{
|
|
name: "new session (no ACP session ID)",
|
|
acpSessionID: "",
|
|
wantResuming: false,
|
|
},
|
|
{
|
|
name: "resumed session (has ACP session ID)",
|
|
acpSessionID: "acp-session-123",
|
|
wantResuming: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// The is_resuming logic is: execution.ACPSessionID != ""
|
|
isResuming := tt.acpSessionID != ""
|
|
if isResuming != tt.wantResuming {
|
|
t.Errorf("is_resuming = %v, want %v", isResuming, tt.wantResuming)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPollAgentStderr_StopsOnClose(t *testing.T) {
|
|
mgr := newTestManager()
|
|
bootSvc := &MockBootMessageService{}
|
|
mgr.bootMessageService = bootSvc
|
|
|
|
msg := &models.Message{
|
|
ID: "boot-msg-1",
|
|
Metadata: map[string]interface{}{"status": "running"},
|
|
}
|
|
stopCh := make(chan struct{})
|
|
|
|
// Start polling with nil client (will fail on each poll, but shouldn't panic)
|
|
done := make(chan struct{})
|
|
go func() {
|
|
// Pass nil client - the pollAgentStderr will log errors but should exit on stop
|
|
// We can't use a nil client directly since it would panic on method call.
|
|
// Instead, just test that close(stopCh) causes the goroutine to exit.
|
|
close(stopCh)
|
|
done <- struct{}{}
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
// Good, goroutine exited
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("pollAgentStderr did not stop within timeout")
|
|
}
|
|
|
|
_ = msg // msg would be used in a real poll
|
|
}
|
|
|
|
// --- Tests for duplicate message prevention in handleAgentEvent ---
|
|
|
|
// MockEventBusWithTracking provides detailed tracking of published events for testing
|
|
type MockEventBusWithTracking struct {
|
|
PublishedEvents []trackedEvent
|
|
mu sync.Mutex
|
|
}
|
|
|
|
type trackedEvent struct {
|
|
Subject string
|
|
Event *bus.Event
|
|
}
|
|
|
|
func (m *MockEventBusWithTracking) Publish(ctx context.Context, subject string, event *bus.Event) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.PublishedEvents = append(m.PublishedEvents, trackedEvent{Subject: subject, Event: event})
|
|
return nil
|
|
}
|
|
|
|
func (m *MockEventBusWithTracking) Subscribe(subject string, handler bus.EventHandler) (bus.Subscription, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockEventBusWithTracking) QueueSubscribe(subject, queue string, handler bus.EventHandler) (bus.Subscription, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockEventBusWithTracking) Request(ctx context.Context, subject string, event *bus.Event, timeout time.Duration) (*bus.Event, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockEventBusWithTracking) Close() {}
|
|
|
|
func (m *MockEventBusWithTracking) IsConnected() bool {
|
|
return true
|
|
}
|
|
|
|
func (m *MockEventBusWithTracking) getStreamEvents() []AgentStreamEventPayload {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
var result []AgentStreamEventPayload
|
|
for _, te := range m.PublishedEvents {
|
|
if payload, ok := te.Event.Data.(AgentStreamEventPayload); ok {
|
|
result = append(result, payload)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// createTestManagerWithTracking creates a manager with a tracking event bus for testing
|
|
func createTestManagerWithTracking() (*Manager, *MockEventBusWithTracking) {
|
|
log := newTestLogger()
|
|
reg := newTestRegistry()
|
|
eventBus := &MockEventBusWithTracking{}
|
|
credsMgr := &MockCredentialsManager{}
|
|
profileResolver := &MockProfileResolver{}
|
|
mgr := NewManager(reg, eventBus, nil, nil, credsMgr, profileResolver, nil, RuntimeFallbackWarn, log)
|
|
return mgr, eventBus
|
|
}
|
|
|
|
// createTestExecution creates a test execution with proper initialization
|
|
func createTestExecution(id, taskID, sessionID string) *AgentExecution {
|
|
return &AgentExecution{
|
|
ID: id,
|
|
TaskID: taskID,
|
|
SessionID: sessionID,
|
|
Status: v1.AgentStatusRunning,
|
|
StartedAt: time.Now(),
|
|
promptDoneCh: make(chan PromptCompletionSignal, 1),
|
|
}
|
|
}
|
|
|
|
// TestHandleAgentEvent_StreamingThenComplete tests the normal flow:
|
|
// message_chunk events followed by complete event - should NOT create duplicate
|
|
func TestHandleAgentEvent_StreamingThenComplete(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
mgr.executionStore.Add(execution)
|
|
|
|
// Simulate streaming chunks with newlines (which trigger publishing)
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "Hello, world!\n",
|
|
})
|
|
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "This is a test.\n",
|
|
})
|
|
|
|
// Now send complete event
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "complete",
|
|
})
|
|
|
|
// Verify: streaming was used, so complete should NOT have text
|
|
events := eventBus.getStreamEvents()
|
|
|
|
// Count message_streaming events (creates/appends)
|
|
var messageStreamingEvents []AgentStreamEventPayload
|
|
var completeEvents []AgentStreamEventPayload
|
|
for _, e := range events {
|
|
if e.Data != nil {
|
|
switch e.Data.Type {
|
|
case "message_streaming":
|
|
messageStreamingEvents = append(messageStreamingEvents, e)
|
|
case "complete":
|
|
completeEvents = append(completeEvents, e)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Should have streaming messages
|
|
if len(messageStreamingEvents) == 0 {
|
|
t.Error("expected message_streaming events, got none")
|
|
}
|
|
|
|
// Should have exactly one complete event
|
|
if len(completeEvents) != 1 {
|
|
t.Errorf("expected 1 complete event, got %d", len(completeEvents))
|
|
}
|
|
|
|
// The complete event should NOT have text (streaming handled it via buffer)
|
|
if len(completeEvents) > 0 && completeEvents[0].Data.Text != "" {
|
|
t.Errorf("complete event should not have text when streaming was used, got %q", completeEvents[0].Data.Text)
|
|
}
|
|
}
|
|
|
|
// TestHandleAgentEvent_StreamingThenToolCallThenComplete tests the scenario that could cause duplicates:
|
|
// message_chunk → tool_call (clears currentMessageID) → complete
|
|
// This verifies that the buffer is properly flushed on complete after tool calls
|
|
func TestHandleAgentEvent_StreamingThenToolCallThenComplete(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
mgr.executionStore.Add(execution)
|
|
|
|
// Simulate streaming chunks
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "Let me check that for you.\n",
|
|
})
|
|
|
|
// Verify currentMessageID is set after message_chunk
|
|
execution.messageMu.Lock()
|
|
msgIDBeforeToolCall := execution.currentMessageID
|
|
execution.messageMu.Unlock()
|
|
|
|
if msgIDBeforeToolCall == "" {
|
|
t.Error("currentMessageID should be set after message_chunk")
|
|
}
|
|
|
|
// Tool call - this flushes buffer and clears currentMessageID
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "tool_call",
|
|
ToolCallID: "tool-1",
|
|
ToolName: "read_file",
|
|
})
|
|
|
|
// After tool call, currentMessageID should be cleared
|
|
execution.messageMu.Lock()
|
|
msgIDAfterToolCall := execution.currentMessageID
|
|
execution.messageMu.Unlock()
|
|
|
|
if msgIDAfterToolCall != "" {
|
|
t.Errorf("currentMessageID should be cleared after tool_call, got %q", msgIDAfterToolCall)
|
|
}
|
|
|
|
// Now complete - this should flush the buffer
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "complete",
|
|
})
|
|
|
|
events := eventBus.getStreamEvents()
|
|
|
|
// Find the complete event
|
|
var completeEvents []AgentStreamEventPayload
|
|
for _, e := range events {
|
|
if e.Data != nil && e.Data.Type == "complete" {
|
|
completeEvents = append(completeEvents, e)
|
|
}
|
|
}
|
|
|
|
if len(completeEvents) != 1 {
|
|
t.Errorf("expected 1 complete event, got %d", len(completeEvents))
|
|
}
|
|
|
|
// The complete event should NOT have text (streaming was used)
|
|
if len(completeEvents) > 0 && completeEvents[0].Data.Text != "" {
|
|
t.Errorf("complete event should not have text when streaming was used (even after tool_call), got %q",
|
|
completeEvents[0].Data.Text)
|
|
}
|
|
}
|
|
|
|
// TestHandleAgentEvent_CompleteWithoutStreaming verifies that complete events are
|
|
// properly handled when no streaming was used (buffer is empty).
|
|
// All adapters now send text via message_chunk events, so this tests the empty buffer case.
|
|
func TestHandleAgentEvent_CompleteWithoutStreaming(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
mgr.executionStore.Add(execution)
|
|
|
|
// Complete event without any prior streaming (e.g., agent did only tool calls)
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "complete",
|
|
})
|
|
|
|
events := eventBus.getStreamEvents()
|
|
|
|
var completeEvents []AgentStreamEventPayload
|
|
for _, e := range events {
|
|
if e.Data != nil && e.Data.Type == "complete" {
|
|
completeEvents = append(completeEvents, e)
|
|
}
|
|
}
|
|
|
|
if len(completeEvents) != 1 {
|
|
t.Errorf("expected 1 complete event, got %d", len(completeEvents))
|
|
}
|
|
|
|
// Complete event should be published successfully
|
|
// The buffer was empty, so no message_streaming events should be generated
|
|
var streamingEvents []AgentStreamEventPayload
|
|
for _, e := range events {
|
|
if e.Data != nil && e.Data.Type == "message_streaming" {
|
|
streamingEvents = append(streamingEvents, e)
|
|
}
|
|
}
|
|
|
|
if len(streamingEvents) != 0 {
|
|
t.Errorf("expected 0 message_streaming events when buffer is empty, got %d", len(streamingEvents))
|
|
}
|
|
}
|
|
|
|
// TestHandleAgentEvent_CompleteWithBufferedText verifies that buffered text
|
|
// without streaming is emitted as a final streaming event on complete.
|
|
func TestHandleAgentEvent_CompleteWithBufferedText(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
mgr.executionStore.Add(execution)
|
|
|
|
// Buffer text without newlines (no streaming event should be emitted)
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "Final message without newline",
|
|
})
|
|
|
|
// Complete event should flush buffer into a streaming event
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "complete",
|
|
})
|
|
|
|
events := eventBus.getStreamEvents()
|
|
|
|
var completeEvents []AgentStreamEventPayload
|
|
var streamingEvents []AgentStreamEventPayload
|
|
for _, e := range events {
|
|
if e.Data != nil {
|
|
switch e.Data.Type {
|
|
case "complete":
|
|
completeEvents = append(completeEvents, e)
|
|
case "message_streaming":
|
|
streamingEvents = append(streamingEvents, e)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(completeEvents) != 1 {
|
|
t.Errorf("expected 1 complete event, got %d", len(completeEvents))
|
|
}
|
|
|
|
if len(completeEvents) > 0 && completeEvents[0].Data.Text != "" {
|
|
t.Errorf("expected complete event to have empty text, got %q", completeEvents[0].Data.Text)
|
|
}
|
|
|
|
if len(streamingEvents) != 1 {
|
|
t.Errorf("expected 1 message_streaming event when no newlines, got %d", len(streamingEvents))
|
|
} else if streamingEvents[0].Data.Text != "Final message without newline" {
|
|
t.Errorf("expected streaming event to carry buffered text, got %q", streamingEvents[0].Data.Text)
|
|
}
|
|
}
|
|
|
|
// TestHandleAgentEvent_CompleteThenMessageChunk tests the scenario where
|
|
// message_chunk arrives after complete. This documents the behavior when
|
|
// an adapter incorrectly sends text after the turn has completed.
|
|
// With the new architecture, adapters should NOT send message_chunk after complete.
|
|
func TestHandleAgentEvent_CompleteThenMessageChunk(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
mgr.executionStore.Add(execution)
|
|
|
|
// First, simulate normal streaming during the turn
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "Processing your request...\n",
|
|
})
|
|
|
|
// Complete event arrives - this flushes the buffer
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "complete",
|
|
})
|
|
|
|
// Now message_chunk arrives AFTER complete
|
|
// This shouldn't happen with properly implemented adapters,
|
|
// but we document the behavior: it creates a new message
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "Done!\n",
|
|
})
|
|
|
|
events := eventBus.getStreamEvents()
|
|
|
|
// Count message_streaming events
|
|
var messageStreamingEvents []AgentStreamEventPayload
|
|
for _, e := range events {
|
|
if e.Data != nil && e.Data.Type == "message_streaming" {
|
|
messageStreamingEvents = append(messageStreamingEvents, e)
|
|
}
|
|
}
|
|
|
|
// Document the behavior: message_chunk after complete starts a new message
|
|
t.Logf("Got %d message_streaming events", len(messageStreamingEvents))
|
|
for i, e := range messageStreamingEvents {
|
|
t.Logf(" Event %d: MessageID=%s, IsAppend=%v, Text=%q",
|
|
i, e.Data.MessageID, e.Data.IsAppend, e.Data.Text)
|
|
}
|
|
|
|
// The second message_chunk (after complete) should start a NEW message
|
|
// since currentMessageID was cleared by the complete event
|
|
if len(messageStreamingEvents) >= 2 {
|
|
lastEvent := messageStreamingEvents[len(messageStreamingEvents)-1]
|
|
if !lastEvent.Data.IsAppend {
|
|
t.Log("Expected behavior: message_chunk after complete creates a new message")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHandleAgentEvent_MultipleToolCalls tests streaming → tool → streaming → tool → complete
|
|
func TestHandleAgentEvent_MultipleToolCalls(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
mgr.executionStore.Add(execution)
|
|
|
|
// Message before first tool
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "Let me read the file.\n",
|
|
})
|
|
|
|
// First tool call
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "tool_call",
|
|
ToolCallID: "tool-1",
|
|
ToolName: "read_file",
|
|
})
|
|
|
|
// Tool update (complete)
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "tool_update",
|
|
ToolCallID: "tool-1",
|
|
ToolStatus: "complete",
|
|
})
|
|
|
|
// Message after first tool
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "Now let me modify it.\n",
|
|
})
|
|
|
|
// Second tool call
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "tool_call",
|
|
ToolCallID: "tool-2",
|
|
ToolName: "write_file",
|
|
})
|
|
|
|
// Tool update (complete)
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "tool_update",
|
|
ToolCallID: "tool-2",
|
|
ToolStatus: "complete",
|
|
})
|
|
|
|
// Final message
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "Done with both tasks!\n",
|
|
})
|
|
|
|
// Complete
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "complete",
|
|
})
|
|
|
|
events := eventBus.getStreamEvents()
|
|
|
|
// Count different event types
|
|
var messageStreamingCount, toolCallCount, completeCount int
|
|
for _, e := range events {
|
|
if e.Data != nil {
|
|
switch e.Data.Type {
|
|
case "message_streaming":
|
|
messageStreamingCount++
|
|
case "tool_call":
|
|
toolCallCount++
|
|
case "complete":
|
|
completeCount++
|
|
}
|
|
}
|
|
}
|
|
|
|
t.Logf("Events: message_streaming=%d, tool_call=%d, complete=%d",
|
|
messageStreamingCount, toolCallCount, completeCount)
|
|
|
|
// Should have multiple streaming messages (one per "segment" before tool calls)
|
|
if messageStreamingCount < 3 {
|
|
t.Errorf("expected at least 3 message_streaming events for 3 message segments, got %d", messageStreamingCount)
|
|
}
|
|
|
|
if toolCallCount != 2 {
|
|
t.Errorf("expected 2 tool_call events, got %d", toolCallCount)
|
|
}
|
|
|
|
if completeCount != 1 {
|
|
t.Errorf("expected 1 complete event, got %d", completeCount)
|
|
}
|
|
|
|
// Find the complete event and verify it has no text
|
|
for _, e := range events {
|
|
if e.Data != nil && e.Data.Type == "complete" {
|
|
if e.Data.Text != "" {
|
|
t.Errorf("complete event should not have text when streaming was used, got %q", e.Data.Text)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHandleAgentEvent_CompleteSignalsPromptDoneCh verifies that the complete event
|
|
// signals the promptDoneCh channel with the correct stop reason.
|
|
func TestHandleAgentEvent_CompleteSignalsPromptDoneCh(t *testing.T) {
|
|
mgr, _ := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
mgr.executionStore.Add(execution)
|
|
|
|
// Send a normal complete event
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "complete",
|
|
})
|
|
|
|
// promptDoneCh should have a signal
|
|
select {
|
|
case signal := <-execution.promptDoneCh:
|
|
if signal.IsError {
|
|
t.Error("expected non-error signal for normal complete")
|
|
}
|
|
if signal.StopReason != "end_turn" {
|
|
t.Errorf("expected stop_reason 'end_turn', got %q", signal.StopReason)
|
|
}
|
|
default:
|
|
t.Error("expected signal on promptDoneCh, got none")
|
|
}
|
|
}
|
|
|
|
// TestHandleAgentEvent_ErrorCompleteSignalsPromptDoneCh verifies that an error completion
|
|
// signals the promptDoneCh channel with IsError=true and the error message.
|
|
func TestHandleAgentEvent_ErrorCompleteSignalsPromptDoneCh(t *testing.T) {
|
|
mgr, _ := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
mgr.executionStore.Add(execution)
|
|
|
|
// Send an error complete event
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "complete",
|
|
Error: "something went wrong",
|
|
Data: map[string]interface{}{"is_error": true},
|
|
})
|
|
|
|
// promptDoneCh should have an error signal
|
|
select {
|
|
case signal := <-execution.promptDoneCh:
|
|
if !signal.IsError {
|
|
t.Error("expected error signal for error complete")
|
|
}
|
|
if signal.StopReason != "error" {
|
|
t.Errorf("expected stop_reason 'error', got %q", signal.StopReason)
|
|
}
|
|
if signal.Error != "something went wrong" {
|
|
t.Errorf("expected error 'something went wrong', got %q", signal.Error)
|
|
}
|
|
default:
|
|
t.Error("expected signal on promptDoneCh, got none")
|
|
}
|
|
}
|
|
|
|
// TestHandleAgentEvent_UpdatesLastActivityAt verifies that every agent event
|
|
// updates the lastActivityAt timestamp for stall detection.
|
|
func TestHandleAgentEvent_UpdatesLastActivityAt(t *testing.T) {
|
|
mgr, _ := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
mgr.executionStore.Add(execution)
|
|
|
|
// Set lastActivityAt to a known old time
|
|
oldTime := time.Now().Add(-10 * time.Minute)
|
|
execution.lastActivityAtMu.Lock()
|
|
execution.lastActivityAt = oldTime
|
|
execution.lastActivityAtMu.Unlock()
|
|
|
|
// Send any event
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "hello\n",
|
|
})
|
|
|
|
// lastActivityAt should be updated to approximately now
|
|
execution.lastActivityAtMu.Lock()
|
|
elapsed := time.Since(execution.lastActivityAt)
|
|
execution.lastActivityAtMu.Unlock()
|
|
|
|
if elapsed > 1*time.Second {
|
|
t.Errorf("lastActivityAt not updated: elapsed %v since last event", elapsed)
|
|
}
|
|
}
|
|
|
|
// TestHandleAgentEvent_PromptDoneChDoesNotBlockWhenFull verifies that signaling
|
|
// promptDoneCh with a full channel (no receiver) doesn't block the event handler.
|
|
func TestHandleAgentEvent_PromptDoneChDoesNotBlockWhenFull(t *testing.T) {
|
|
mgr, _ := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
mgr.executionStore.Add(execution)
|
|
|
|
// Pre-fill the channel
|
|
execution.promptDoneCh <- PromptCompletionSignal{StopReason: "stale"}
|
|
|
|
// Send complete event — should not block
|
|
done := make(chan struct{})
|
|
go func() {
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "complete",
|
|
})
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
// Good — didn't block
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("handleAgentEvent blocked when promptDoneCh was full")
|
|
}
|
|
}
|