yao/agent/robot/executor/standard/inspiration_test.go
Max c4696380f4 feat(robot): implement global phase agent resolution for improved agent configuration
- Introduced a new global phase agent resolver to streamline agent ID retrieval for various robot pipeline phases, enhancing flexibility in agent configuration.
- Updated existing phase agent retrieval logic to prioritize per-robot configurations, falling back to global settings when necessary.
- Enhanced error handling to provide clearer messages when no agent is configured for specific phases.
- Added tests to validate the new resolution logic and ensure proper functionality across different configurations.
2026-03-24 12:11:55 +08:00

368 lines
11 KiB
Go

package standard_test
import (
"context"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent/robot/executor/standard"
"github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/agent/testutils"
)
// ============================================================================
// P0 Inspiration Phase Tests
// ============================================================================
func TestRunInspirationBasic(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), testAuth())
t.Run("generates inspiration report with clock context", func(t *testing.T) {
// Create robot with inspiration agent configured
robot := createTestRobot(t, "robot.inspiration")
// Create executor and execution
exec := createTestExecution(robot, types.TriggerClock)
// Run inspiration phase
e := standard.New()
err := e.RunInspiration(ctx, exec, nil)
require.NoError(t, err)
require.NotNil(t, exec.Inspiration)
assert.NotEmpty(t, exec.Inspiration.Content)
assert.NotNil(t, exec.Inspiration.Clock)
})
t.Run("includes expected markdown sections", func(t *testing.T) {
robot := createTestRobot(t, "robot.inspiration")
exec := createTestExecution(robot, types.TriggerClock)
e := standard.New()
err := e.RunInspiration(ctx, exec, nil)
require.NoError(t, err)
content := exec.Inspiration.Content
// Verify expected sections in markdown output
// Note: LLM output is non-deterministic, so we check for likely sections
hasSection := strings.Contains(content, "##") ||
strings.Contains(content, "Summary") ||
strings.Contains(content, "Highlight") ||
strings.Contains(content, "Recommend")
assert.True(t, hasSection, "should contain markdown sections, got: %s", content)
})
}
func TestRunInspirationClockContext(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), testAuth())
t.Run("uses clock from trigger input", func(t *testing.T) {
robot := createTestRobot(t, "robot.inspiration")
exec := createTestExecution(robot, types.TriggerClock)
// Set specific clock context
specificTime := time.Date(2024, 12, 31, 17, 0, 0, 0, time.UTC)
exec.Input = &types.TriggerInput{
Clock: types.NewClockContext(specificTime, "UTC"),
}
e := standard.New()
err := e.RunInspiration(ctx, exec, nil)
require.NoError(t, err)
require.NotNil(t, exec.Inspiration.Clock)
// Clock should match input
assert.Equal(t, specificTime.Year(), exec.Inspiration.Clock.Year)
assert.Equal(t, int(specificTime.Month()), exec.Inspiration.Clock.Month)
assert.Equal(t, specificTime.Day(), exec.Inspiration.Clock.DayOfMonth)
})
t.Run("creates clock context when not provided", func(t *testing.T) {
robot := createTestRobot(t, "robot.inspiration")
exec := createTestExecution(robot, types.TriggerClock)
exec.Input = nil // No input
e := standard.New()
err := e.RunInspiration(ctx, exec, nil)
require.NoError(t, err)
require.NotNil(t, exec.Inspiration.Clock)
// Clock should be current time (approximately)
now := time.Now()
assert.Equal(t, now.Year(), exec.Inspiration.Clock.Year)
})
}
func TestRunInspirationRobotIdentity(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), testAuth())
t.Run("robot identity influences output", func(t *testing.T) {
// Create robot with specific identity
robot := &types.Robot{
MemberID: "test-robot-1",
TeamID: "test-team-1",
DisplayName: "Sales Assistant",
Config: &types.Config{
Identity: &types.Identity{
Role: "Sales Assistant",
Duties: []string{"Track sales metrics", "Prepare weekly reports"},
Rules: []string{"Focus on actionable insights"},
},
Resources: &types.Resources{
Phases: map[types.Phase]string{
types.PhaseInspiration: "robot.inspiration",
},
},
},
}
exec := createTestExecution(robot, types.TriggerClock)
e := standard.New()
err := e.RunInspiration(ctx, exec, nil)
require.NoError(t, err)
assert.NotEmpty(t, exec.Inspiration.Content)
// The content should be influenced by robot identity
// (exact content varies due to LLM non-determinism)
})
}
func TestRunInspirationErrorHandling(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), testAuth())
t.Run("returns error when robot is nil", func(t *testing.T) {
exec := &types.Execution{
ID: "test-exec-1",
TriggerType: types.TriggerClock,
}
// Don't set robot
e := standard.New()
err := e.RunInspiration(ctx, exec, nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "robot not found")
})
t.Run("returns error when agent not found", func(t *testing.T) {
robot := &types.Robot{
MemberID: "test-robot-1",
TeamID: "test-team-1",
Config: &types.Config{
Identity: &types.Identity{Role: "Test"},
Resources: &types.Resources{
Phases: map[types.Phase]string{
types.PhaseInspiration: "non.existent.agent",
},
},
},
}
exec := createTestExecution(robot, types.TriggerClock)
e := standard.New()
err := e.RunInspiration(ctx, exec, nil)
// Real AgentCaller returns error for non-existent agent
assert.Error(t, err)
assert.Contains(t, err.Error(), "call failed")
})
}
func TestRunInspirationWithDefaultAgent(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), testAuth())
t.Run("uses global Uses config when per-robot resources not set", func(t *testing.T) {
robot := &types.Robot{
MemberID: "test-robot-1",
TeamID: "test-team-1",
Config: &types.Config{
Identity: &types.Identity{Role: "Test Robot"},
// No Resources configured — falls back to global uses.inspiration
},
}
exec := createTestExecution(robot, types.TriggerClock)
e := standard.New()
err := e.RunInspiration(ctx, exec, nil)
// With global Uses configured (agent.yml: uses.inspiration = "robot.inspiration"),
// the call should succeed. Without global config, it would error.
if err != nil {
assert.Contains(t, err.Error(), "call failed")
}
})
}
// ============================================================================
// InputFormatter Tests for P0
// ============================================================================
func TestInputFormatterClockContext(t *testing.T) {
t.Run("formats clock context correctly", func(t *testing.T) {
formatter := standard.NewInputFormatter()
// Create a specific clock context
clock := types.NewClockContext(
time.Date(2024, 12, 31, 17, 30, 0, 0, time.UTC),
"UTC",
)
robot := &types.Robot{
Config: &types.Config{
Identity: &types.Identity{
Role: "Sales Assistant",
Duties: []string{"Track metrics", "Send reports"},
},
},
}
content := formatter.FormatClockContext(clock, robot)
// Verify time context
assert.Contains(t, content, "Current Time Context")
assert.Contains(t, content, "2024")
assert.Contains(t, content, "12")
assert.Contains(t, content, "31")
assert.Contains(t, content, "Tuesday") // Dec 31, 2024 is Tuesday
// Verify robot identity
assert.Contains(t, content, "Robot Identity")
assert.Contains(t, content, "Sales Assistant")
assert.Contains(t, content, "Track metrics")
})
t.Run("handles nil clock", func(t *testing.T) {
formatter := standard.NewInputFormatter()
content := formatter.FormatClockContext(nil, nil)
assert.Empty(t, content)
})
t.Run("handles nil robot", func(t *testing.T) {
formatter := standard.NewInputFormatter()
clock := types.NewClockContext(time.Now(), "")
content := formatter.FormatClockContext(clock, nil)
// Should have time context but no robot identity
assert.Contains(t, content, "Current Time Context")
assert.NotContains(t, content, "Robot Identity")
})
t.Run("includes time markers", func(t *testing.T) {
formatter := standard.NewInputFormatter()
// Create a weekend + month start clock context
// Jan 1, 2028 is Saturday (weekend + month start)
clock := types.NewClockContext(
time.Date(2028, 1, 1, 10, 0, 0, 0, time.UTC),
"UTC",
)
content := formatter.FormatClockContext(clock, nil)
assert.Contains(t, content, "Weekend")
assert.Contains(t, content, "Month Start")
})
}
// ============================================================================
// Helper Functions
// ============================================================================
// createTestRobot creates a test robot with specified inspiration agent
// Includes available expert agents so the Inspiration Agent knows what resources are available
//
// Note: The agent IDs listed in Resources.Agents must exist in yao-dev-app/assistants/experts/
// Current available experts: data-analyst, summarizer, text-writer, web-reader
func createTestRobot(t *testing.T, agentID string) *types.Robot {
t.Helper()
return &types.Robot{
MemberID: "test-robot-1",
TeamID: "test-team-1",
DisplayName: "Test Robot",
Config: &types.Config{
Identity: &types.Identity{
Role: "Test Assistant",
Duties: []string{"Testing", "Data Analysis", "Report Generation"},
},
Resources: &types.Resources{
Phases: map[types.Phase]string{
types.PhaseInspiration: agentID,
},
// Available expert agents that can be delegated to
// These IDs correspond to assistants in yao-dev-app/assistants/experts/
Agents: []string{
"experts.data-analyst", // Data analysis and insights
"experts.summarizer", // Content summarization
"experts.text-writer", // Report and document generation
"experts.web-reader", // Web content extraction
},
},
// Knowledge base collections (if any)
KB: &types.KB{
Collections: []string{"test-knowledge"},
},
},
}
}
// createTestExecution creates a test execution for a robot
func createTestExecution(robot *types.Robot, trigger types.TriggerType) *types.Execution {
exec := &types.Execution{
ID: "test-exec-1",
MemberID: robot.MemberID,
TeamID: robot.TeamID,
TriggerType: trigger,
StartTime: time.Now(),
Status: types.ExecRunning,
Phase: types.PhaseInspiration,
Input: &types.TriggerInput{
Clock: types.NewClockContext(time.Now(), ""),
},
}
exec.SetRobot(robot)
return exec
}