package manager_test // Integration tests for the Robot Agent scheduling system // These tests verify the complete end-to-end flow: // Trigger → Manager → Cache → Pool → Worker → Executor → Job // // Test Structure: // - integration_test.go: Core scheduling flow tests // - integration_clock_test.go: Clock trigger mode tests (times/interval/daemon) // - integration_human_test.go: Human intervention trigger tests // - integration_event_test.go: Event trigger tests // - integration_concurrent_test.go: Concurrent execution & quota tests // - integration_control_test.go: Pause/Resume/Stop tests // // Test Data: // All tests use real database records in __yao.member table // Test robot IDs are prefixed with "robot_integ_" for easy cleanup import ( "context" "encoding/json" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yaoapp/gou/model" "github.com/yaoapp/xun/capsule" "github.com/yaoapp/yao/agent/robot/executor" "github.com/yaoapp/yao/agent/robot/manager" "github.com/yaoapp/yao/agent/robot/pool" "github.com/yaoapp/yao/agent/robot/types" "github.com/yaoapp/yao/agent/testutils" ) // ==================== Core Scheduling Flow Tests ==================== // TestIntegrationSchedulingFlow tests the complete scheduling flow: // Create robot → Start manager → Trigger → Verify execution func TestIntegrationSchedulingFlow(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test") } testutils.Prepare(t) defer testutils.Clean(t) cleanupIntegrationRobots(t) defer cleanupIntegrationRobots(t) t.Run("complete clock trigger flow", func(t *testing.T) { // Setup: Create a robot with times mode clock config setupIntegrationRobotTimes(t, "robot_integ_flow_clock", "team_integ_flow") config := &manager.Config{ TickInterval: 10 * time.Second, PoolConfig: &pool.Config{WorkerSize: 5, QueueSize: 50}, Executor: executor.NewDryRun(), } m := manager.NewWithConfig(config) // Start manager err := m.Start() require.NoError(t, err) defer m.Stop() // Verify robot is loaded into cache robot := m.Cache().Get("robot_integ_flow_clock") require.NotNil(t, robot, "Robot should be loaded into cache") assert.Equal(t, "robot_integ_flow_clock", robot.MemberID) assert.Equal(t, types.RobotIdle, robot.Status) // Simulate clock trigger at matching time (03:33 on Wednesday) loc, _ := time.LoadLocation("Asia/Shanghai") triggerTime := time.Date(2025, 1, 15, 3, 33, 0, 0, loc) // Wednesday 03:33 ctx := types.NewContext(context.Background(), nil) err = m.Tick(ctx, triggerTime) assert.NoError(t, err) // Wait for execution to complete time.Sleep(500 * time.Millisecond) // Verify execution happened execCount := m.Executor().ExecCount() assert.GreaterOrEqual(t, execCount, 1, "Should have at least 1 execution") }) t.Run("robot loaded from database", func(t *testing.T) { // Setup: Create multiple robots setupIntegrationRobotTimes(t, "robot_integ_flow_db1", "team_integ_flow") setupIntegrationRobotInterval(t, "robot_integ_flow_db2", "team_integ_flow") m := manager.NewWithConfig(&manager.Config{Executor: executor.NewDryRun()}) err := m.Start() require.NoError(t, err) defer m.Stop() // Verify both robots are in cache robot1 := m.Cache().Get("robot_integ_flow_db1") robot2 := m.Cache().Get("robot_integ_flow_db2") assert.NotNil(t, robot1, "Robot 1 should be loaded") assert.NotNil(t, robot2, "Robot 2 should be loaded") // Verify config is parsed correctly assert.NotNil(t, robot1.Config) assert.NotNil(t, robot1.Config.Clock) assert.Equal(t, types.ClockTimes, robot1.Config.Clock.Mode) assert.NotNil(t, robot2.Config) assert.NotNil(t, robot2.Config.Clock) assert.Equal(t, types.ClockInterval, robot2.Config.Clock.Mode) }) t.Run("inactive robot not loaded", func(t *testing.T) { // Setup: Create an inactive robot setupIntegrationRobotInactive(t, "robot_integ_flow_inactive", "team_integ_flow") m := manager.NewWithConfig(&manager.Config{Executor: executor.NewDryRun()}) err := m.Start() require.NoError(t, err) defer m.Stop() // Inactive robot should not be in cache robot := m.Cache().Get("robot_integ_flow_inactive") assert.Nil(t, robot, "Inactive robot should not be loaded") }) t.Run("robot with autonomous_mode=false is loaded after full cache", func(t *testing.T) { // Setup: Create a robot with autonomous_mode=false setupIntegrationRobotNonAutonomous(t, "robot_integ_flow_nonauto", "team_integ_flow") m := manager.NewWithConfig(&manager.Config{Executor: executor.NewDryRun()}) err := m.Start() require.NoError(t, err) defer m.Stop() // After full-cache load, non-autonomous active robots should also be in cache robot := m.Cache().Get("robot_integ_flow_nonauto") assert.NotNil(t, robot, "Non-autonomous active robot should be loaded in cache after full load") if robot != nil { assert.False(t, robot.AutonomousMode) } }) } // TestIntegrationJobSubmission tests job submission to pool and execution func TestIntegrationJobSubmission(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test") } testutils.Prepare(t) defer testutils.Clean(t) cleanupIntegrationRobots(t) defer cleanupIntegrationRobots(t) t.Run("job submitted to pool and executed", func(t *testing.T) { setupIntegrationRobotTimes(t, "robot_integ_submit", "team_integ_submit") config := &manager.Config{ TickInterval: 100 * time.Millisecond, PoolConfig: &pool.Config{WorkerSize: 3, QueueSize: 20}, } m := manager.NewWithConfig(config) err := m.Start() require.NoError(t, err) defer m.Stop() // Manually trigger execution ctx := types.NewContext(context.Background(), nil) execID, err := m.TriggerManual(ctx, "robot_integ_submit", types.TriggerClock, nil) assert.NoError(t, err) assert.NotEmpty(t, execID, "Should return execution ID") // Wait for execution time.Sleep(500 * time.Millisecond) // Verify execution completed assert.GreaterOrEqual(t, m.Executor().ExecCount(), 1) }) t.Run("multiple jobs queued and executed in order", func(t *testing.T) { setupIntegrationRobotHighQuota(t, "robot_integ_queue", "team_integ_submit") config := &manager.Config{ TickInterval: 100 * time.Millisecond, PoolConfig: &pool.Config{WorkerSize: 2, QueueSize: 50}, } m := manager.NewWithConfig(config) err := m.Start() require.NoError(t, err) defer m.Stop() ctx := types.NewContext(context.Background(), nil) // Submit multiple jobs execIDs := make([]string, 5) for i := 0; i < 5; i++ { execID, err := m.TriggerManual(ctx, "robot_integ_queue", types.TriggerClock, nil) assert.NoError(t, err) execIDs[i] = execID } // All should have valid IDs for i, id := range execIDs { assert.NotEmpty(t, id, "Execution %d should have valid ID", i) } // Wait for all to complete (longer wait for slow execution) time.Sleep(2 * time.Second) // All jobs should have executed execCount := m.Executor().ExecCount() assert.GreaterOrEqual(t, execCount, 5, "Expected at least 5 executions, got %d", execCount) }) } // TestIntegrationPhaseProgression tests that execution progresses through all phases func TestIntegrationPhaseProgression(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test") } testutils.Prepare(t) defer testutils.Clean(t) cleanupIntegrationRobots(t) defer cleanupIntegrationRobots(t) t.Run("clock trigger executes all phases P0-P5", func(t *testing.T) { cleanupIntegrationRobots(t) setupIntegrationRobotTimes(t, "robot_integ_phases_clock", "team_integ_phases") // Track phases executed phasesExecuted := make([]types.Phase, 0) exec := executor.NewDryRunWithConfig(executor.DryRunConfig{ Config: executor.Config{ OnPhaseStart: func(phase types.Phase) { phasesExecuted = append(phasesExecuted, phase) }, }, }) config := &manager.Config{ TickInterval: 10 * time.Second, PoolConfig: &pool.Config{WorkerSize: 2, QueueSize: 20}, Executor: exec, } m := manager.NewWithConfig(config) err := m.Start() require.NoError(t, err) time.Sleep(500 * time.Millisecond) // Trigger execution ctx := types.NewContext(context.Background(), nil) _, err = m.TriggerManual(ctx, "robot_integ_phases_clock", types.TriggerClock, nil) assert.NoError(t, err) // Wait for execution time.Sleep(500 * time.Millisecond) // Stop manager before asserting to prevent ticker from triggering extra executions m.Stop() // Verify all 6 phases executed (P0-P5) assert.Len(t, phasesExecuted, 6, "Should execute all 6 phases for clock trigger") assert.Equal(t, types.PhaseInspiration, phasesExecuted[0], "Should start with P0") assert.Equal(t, types.PhaseLearning, phasesExecuted[5], "Should end with P5") }) t.Run("human trigger skips P0 and executes P1-P5", func(t *testing.T) { cleanupIntegrationRobots(t) setupIntegrationRobotIntervene(t, "robot_integ_phases_human", "team_integ_phases") // Track phases executed phasesExecuted := make([]types.Phase, 0) exec := executor.NewDryRunWithConfig(executor.DryRunConfig{ Config: executor.Config{ OnPhaseStart: func(phase types.Phase) { phasesExecuted = append(phasesExecuted, phase) }, }, }) config := &manager.Config{ TickInterval: 10 * time.Second, PoolConfig: &pool.Config{WorkerSize: 2, QueueSize: 20}, Executor: exec, } m := manager.NewWithConfig(config) err := m.Start() require.NoError(t, err) time.Sleep(500 * time.Millisecond) // Trigger execution via human trigger ctx := types.NewContext(context.Background(), nil) _, err = m.TriggerManual(ctx, "robot_integ_phases_human", types.TriggerHuman, nil) assert.NoError(t, err) // Wait for execution time.Sleep(500 * time.Millisecond) // Stop manager before asserting to prevent ticker from triggering extra executions m.Stop() // Verify 5 phases executed (P1-P5, skipping P0) assert.Len(t, phasesExecuted, 5, "Should execute 5 phases for human trigger") assert.Equal(t, types.PhaseGoals, phasesExecuted[0], "Should start with P1 (Goals)") assert.Equal(t, types.PhaseLearning, phasesExecuted[4], "Should end with P5") }) } // TestIntegrationCacheRefresh tests that cache refresh works correctly func TestIntegrationCacheRefresh(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test") } testutils.Prepare(t) defer testutils.Clean(t) cleanupIntegrationRobots(t) defer cleanupIntegrationRobots(t) t.Run("cache refresh loads new robots", func(t *testing.T) { // Start with one robot setupIntegrationRobotTimes(t, "robot_integ_refresh1", "team_integ_refresh") m := manager.New() err := m.Start() require.NoError(t, err) defer m.Stop() // Verify first robot is loaded robot1 := m.Cache().Get("robot_integ_refresh1") assert.NotNil(t, robot1) // Add another robot to database setupIntegrationRobotTimes(t, "robot_integ_refresh2", "team_integ_refresh") // Manually refresh cache ctx := types.NewContext(context.Background(), nil) err = m.Cache().Load(ctx) assert.NoError(t, err) // Verify new robot is now in cache robot2 := m.Cache().Get("robot_integ_refresh2") assert.NotNil(t, robot2, "New robot should be loaded after refresh") }) } // ==================== Test Data Setup Helpers ==================== // setupIntegrationRobotTimes creates a robot with times mode clock config func setupIntegrationRobotTimes(t *testing.T, memberID, teamID string) { m := model.Select("__yao.member") tableName := m.MetaData.Table.Name qb := capsule.Query() robotConfig := map[string]interface{}{ "identity": map[string]interface{}{ "role": "Integration Test Robot (Times)", "duties": []string{"Test scheduling"}, }, "quota": map[string]interface{}{ "max": 3, "queue": 20, "priority": 5, }, "triggers": map[string]interface{}{ "clock": map[string]interface{}{"enabled": true}, "intervene": map[string]interface{}{"enabled": true}, "event": map[string]interface{}{"enabled": true}, }, "clock": map[string]interface{}{ "mode": "times", "times": []string{"03:33"}, "days": []string{"Mon", "Tue", "Wed", "Thu", "Fri"}, "tz": "Asia/Shanghai", "timeout": "30m", }, } configJSON, _ := json.Marshal(robotConfig) err := qb.Table(tableName).Insert([]map[string]interface{}{ { "member_id": memberID, "team_id": teamID, "member_type": "robot", "display_name": "Test Robot " + memberID, "system_prompt": "You are an integration test robot.", "status": "active", "role_id": "member", "autonomous_mode": true, "robot_status": "idle", "robot_config": string(configJSON), }, }) if err != nil { t.Fatalf("Failed to insert %s: %v", memberID, err) } } // setupIntegrationRobotInterval creates a robot with interval mode clock config func setupIntegrationRobotInterval(t *testing.T, memberID, teamID string) { m := model.Select("__yao.member") tableName := m.MetaData.Table.Name qb := capsule.Query() robotConfig := map[string]interface{}{ "identity": map[string]interface{}{ "role": "Integration Test Robot (Interval)", }, "quota": map[string]interface{}{ "max": 2, "queue": 10, "priority": 5, }, "triggers": map[string]interface{}{ "clock": map[string]interface{}{"enabled": true}, }, "clock": map[string]interface{}{ "mode": "interval", "every": "30m", "timeout": "10m", }, } configJSON, _ := json.Marshal(robotConfig) err := qb.Table(tableName).Insert([]map[string]interface{}{ { "member_id": memberID, "team_id": teamID, "member_type": "robot", "display_name": "Test Robot " + memberID, "status": "active", "role_id": "member", "autonomous_mode": true, "robot_status": "idle", "robot_config": string(configJSON), }, }) if err != nil { t.Fatalf("Failed to insert %s: %v", memberID, err) } } // setupIntegrationRobotHighQuota creates a robot with high quota for queue tests func setupIntegrationRobotHighQuota(t *testing.T, memberID, teamID string) { m := model.Select("__yao.member") tableName := m.MetaData.Table.Name qb := capsule.Query() robotConfig := map[string]interface{}{ "identity": map[string]interface{}{ "role": "Integration Test Robot (High Quota)", }, "quota": map[string]interface{}{ "max": 10, "queue": 50, "priority": 5, }, "triggers": map[string]interface{}{ "clock": map[string]interface{}{"enabled": true}, }, "clock": map[string]interface{}{ "mode": "times", "times": []string{"03:33"}, "tz": "Asia/Shanghai", }, } configJSON, _ := json.Marshal(robotConfig) err := qb.Table(tableName).Insert([]map[string]interface{}{ { "member_id": memberID, "team_id": teamID, "member_type": "robot", "display_name": "Test Robot " + memberID, "status": "active", "role_id": "member", "autonomous_mode": true, "robot_status": "idle", "robot_config": string(configJSON), }, }) if err != nil { t.Fatalf("Failed to insert %s: %v", memberID, err) } } // setupIntegrationRobotIntervene creates a robot with intervene trigger enabled func setupIntegrationRobotIntervene(t *testing.T, memberID, teamID string) { m := model.Select("__yao.member") tableName := m.MetaData.Table.Name qb := capsule.Query() robotConfig := map[string]interface{}{ "identity": map[string]interface{}{ "role": "Integration Test Robot (Intervene)", }, "quota": map[string]interface{}{ "max": 5, "queue": 20, "priority": 5, }, "triggers": map[string]interface{}{ "clock": map[string]interface{}{"enabled": false}, "intervene": map[string]interface{}{"enabled": true}, }, } configJSON, _ := json.Marshal(robotConfig) err := qb.Table(tableName).Insert([]map[string]interface{}{ { "member_id": memberID, "team_id": teamID, "member_type": "robot", "display_name": "Test Robot " + memberID, "status": "active", "role_id": "member", "autonomous_mode": true, "robot_status": "idle", "robot_config": string(configJSON), }, }) if err != nil { t.Fatalf("Failed to insert %s: %v", memberID, err) } } // setupIntegrationRobotInactive creates an inactive robot (should not be loaded) func setupIntegrationRobotInactive(t *testing.T, memberID, teamID string) { m := model.Select("__yao.member") tableName := m.MetaData.Table.Name qb := capsule.Query() robotConfig := map[string]interface{}{ "identity": map[string]interface{}{ "role": "Inactive Robot", }, "triggers": map[string]interface{}{ "clock": map[string]interface{}{"enabled": true}, }, } configJSON, _ := json.Marshal(robotConfig) err := qb.Table(tableName).Insert([]map[string]interface{}{ { "member_id": memberID, "team_id": teamID, "member_type": "robot", "display_name": "Inactive Robot " + memberID, "status": "inactive", // Inactive status "role_id": "member", "autonomous_mode": true, "robot_status": "paused", "robot_config": string(configJSON), }, }) if err != nil { t.Fatalf("Failed to insert %s: %v", memberID, err) } } // setupIntegrationRobotNonAutonomous creates a robot with autonomous_mode=false func setupIntegrationRobotNonAutonomous(t *testing.T, memberID, teamID string) { m := model.Select("__yao.member") tableName := m.MetaData.Table.Name qb := capsule.Query() robotConfig := map[string]interface{}{ "identity": map[string]interface{}{ "role": "Non-Autonomous Robot", }, } configJSON, _ := json.Marshal(robotConfig) err := qb.Table(tableName).Insert([]map[string]interface{}{ { "member_id": memberID, "team_id": teamID, "member_type": "robot", "display_name": "Non-Autonomous Robot " + memberID, "status": "active", "role_id": "member", "autonomous_mode": false, // Not autonomous "robot_status": "idle", "robot_config": string(configJSON), }, }) if err != nil { t.Fatalf("Failed to insert %s: %v", memberID, err) } } // cleanupIntegrationRobots removes all integration test robots and their // non-terminal execution records to prevent recovery interference. func cleanupIntegrationRobots(t *testing.T) { qb := capsule.Query() // Clean up execution records for integration robots to prevent // recoverExecutions from picking them up during Start(). execModel := model.Select("__yao.agent.execution") if execModel != nil { _, err := qb.Table(execModel.MetaData.Table.Name). Where("member_id", "like", "robot_integ_%"). WhereIn("status", []interface{}{"running", "paused", "pending", "waiting", "confirming"}). Delete() if err != nil { t.Logf("Warning: execution cleanup error: %v", err) } } m := model.Select("__yao.member") tableName := m.MetaData.Table.Name _, err := qb.Table(tableName).Where("member_id", "like", "robot_integ_%").Delete() if err != nil { t.Logf("Warning: cleanup error: %v", err) } }