- Implement V2 execution model in the standard executor, simplifying task execution to a single call without validation loops. - Introduce support for resuming suspended executions, allowing for human input during task processing. - Enhance event handling by pushing task completion and failure events to the event bus for better tracking and integration. - Update tests to reflect changes in execution flow and ensure robust handling of task statuses and results.
353 lines
10 KiB
Go
353 lines
10 KiB
Go
package standard_test
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
agentcontext "github.com/yaoapp/yao/agent/context"
|
|
"github.com/yaoapp/yao/agent/robot/executor/standard"
|
|
executortypes "github.com/yaoapp/yao/agent/robot/executor/types"
|
|
"github.com/yaoapp/yao/agent/robot/types"
|
|
"github.com/yaoapp/yao/agent/testutils"
|
|
)
|
|
|
|
// ============================================================================
|
|
// RunExecution with ResumeContext tests
|
|
// ============================================================================
|
|
|
|
func TestRunExecutionResumeContext(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("resumes from task index with previous results", func(t *testing.T) {
|
|
robot := createRunTestRobot(t)
|
|
exec := createRunTestExecution(robot)
|
|
|
|
exec.Tasks = []types.Task{
|
|
{
|
|
ID: "task-001",
|
|
ExecutorType: types.ExecutorAssistant,
|
|
ExecutorID: "experts.text-writer",
|
|
Messages: []agentcontext.Message{
|
|
{Role: agentcontext.RoleUser, Content: "Write 'hello'"},
|
|
},
|
|
Order: 0,
|
|
Status: types.TaskCompleted,
|
|
},
|
|
{
|
|
ID: "task-002",
|
|
ExecutorType: types.ExecutorAssistant,
|
|
ExecutorID: "experts.text-writer",
|
|
Messages: []agentcontext.Message{
|
|
{Role: agentcontext.RoleUser, Content: "Write 'world'"},
|
|
},
|
|
Order: 1,
|
|
Status: types.TaskPending,
|
|
},
|
|
{
|
|
ID: "task-003",
|
|
ExecutorType: types.ExecutorAssistant,
|
|
ExecutorID: "experts.text-writer",
|
|
Messages: []agentcontext.Message{
|
|
{Role: agentcontext.RoleUser, Content: "Write '!'"},
|
|
},
|
|
Order: 2,
|
|
Status: types.TaskPending,
|
|
},
|
|
}
|
|
|
|
// Simulate resume: task-001 already completed, resume from task-002
|
|
previousResult := types.TaskResult{
|
|
TaskID: "task-001",
|
|
Success: true,
|
|
Output: "hello",
|
|
Duration: 100,
|
|
}
|
|
exec.ResumeContext = &types.ResumeContext{
|
|
TaskIndex: 1,
|
|
PreviousResults: []types.TaskResult{previousResult},
|
|
}
|
|
|
|
e := standard.New()
|
|
err := e.RunExecution(ctx, exec, nil)
|
|
|
|
require.NoError(t, err)
|
|
// Should have 3 results: 1 from previous + 2 new
|
|
require.Len(t, exec.Results, 3)
|
|
|
|
assert.Equal(t, "task-001", exec.Results[0].TaskID)
|
|
assert.True(t, exec.Results[0].Success)
|
|
assert.Equal(t, "hello", exec.Results[0].Output)
|
|
|
|
assert.Equal(t, "task-002", exec.Results[1].TaskID)
|
|
assert.True(t, exec.Results[1].Success)
|
|
|
|
assert.Equal(t, "task-003", exec.Results[2].TaskID)
|
|
assert.True(t, exec.Results[2].Success)
|
|
|
|
// ResumeContext should be cleared after completion
|
|
assert.Nil(t, exec.ResumeContext)
|
|
|
|
// Only task-002 and task-003 should have been executed (check status)
|
|
assert.Equal(t, types.TaskCompleted, exec.Tasks[1].Status)
|
|
assert.Equal(t, types.TaskCompleted, exec.Tasks[2].Status)
|
|
})
|
|
|
|
t.Run("resumes from last task", func(t *testing.T) {
|
|
robot := createRunTestRobot(t)
|
|
exec := createRunTestExecution(robot)
|
|
|
|
exec.Tasks = []types.Task{
|
|
{
|
|
ID: "task-001",
|
|
ExecutorType: types.ExecutorAssistant,
|
|
ExecutorID: "experts.text-writer",
|
|
Messages: []agentcontext.Message{
|
|
{Role: agentcontext.RoleUser, Content: "Write 'hello'"},
|
|
},
|
|
Order: 0,
|
|
Status: types.TaskCompleted,
|
|
},
|
|
{
|
|
ID: "task-002",
|
|
ExecutorType: types.ExecutorAssistant,
|
|
ExecutorID: "experts.text-writer",
|
|
Messages: []agentcontext.Message{
|
|
{Role: agentcontext.RoleUser, Content: "Write 'world'"},
|
|
},
|
|
Order: 1,
|
|
Status: types.TaskWaitingInput,
|
|
},
|
|
}
|
|
|
|
// Resume from the last task
|
|
exec.ResumeContext = &types.ResumeContext{
|
|
TaskIndex: 1,
|
|
PreviousResults: []types.TaskResult{
|
|
{TaskID: "task-001", Success: true, Output: "hello", Duration: 100},
|
|
},
|
|
}
|
|
|
|
e := standard.New()
|
|
err := e.RunExecution(ctx, exec, nil)
|
|
|
|
require.NoError(t, err)
|
|
require.Len(t, exec.Results, 2)
|
|
assert.True(t, exec.Results[1].Success)
|
|
assert.Equal(t, types.TaskCompleted, exec.Tasks[1].Status)
|
|
})
|
|
}
|
|
|
|
// ============================================================================
|
|
// Suspend method tests (using Executor directly)
|
|
// ============================================================================
|
|
|
|
func TestSuspendExecution(t *testing.T) {
|
|
t.Run("suspend sets waiting fields and returns ErrExecutionSuspended", func(t *testing.T) {
|
|
robot := &types.Robot{
|
|
MemberID: "test-robot-suspend",
|
|
TeamID: "test-team-1",
|
|
DisplayName: "Suspend Test Robot",
|
|
}
|
|
|
|
exec := &types.Execution{
|
|
ID: "exec-suspend-001",
|
|
MemberID: robot.MemberID,
|
|
TeamID: robot.TeamID,
|
|
Status: types.ExecRunning,
|
|
Phase: types.PhaseRun,
|
|
Tasks: []types.Task{
|
|
{ID: "task-001", Status: types.TaskRunning},
|
|
{ID: "task-002", Status: types.TaskPending},
|
|
},
|
|
Results: []types.TaskResult{},
|
|
}
|
|
exec.SetRobot(robot)
|
|
|
|
e := standard.NewWithConfig(executortypes.Config{SkipPersistence: true})
|
|
err := e.Suspend(
|
|
types.NewContext(context.Background(), nil),
|
|
exec, 0, "What time range?",
|
|
)
|
|
|
|
assert.ErrorIs(t, err, types.ErrExecutionSuspended)
|
|
assert.Equal(t, types.ExecWaiting, exec.Status)
|
|
assert.Equal(t, "task-001", exec.WaitingTaskID)
|
|
assert.Equal(t, "What time range?", exec.WaitingQuestion)
|
|
assert.NotNil(t, exec.WaitingSince)
|
|
assert.NotNil(t, exec.ResumeContext)
|
|
assert.Equal(t, 0, exec.ResumeContext.TaskIndex)
|
|
assert.Equal(t, types.TaskWaitingInput, exec.Tasks[0].Status)
|
|
})
|
|
|
|
t.Run("suspend with out of range taskIndex is safe", func(t *testing.T) {
|
|
robot := &types.Robot{
|
|
MemberID: "test-robot-suspend-2",
|
|
TeamID: "test-team-1",
|
|
}
|
|
exec := &types.Execution{
|
|
ID: "exec-suspend-002",
|
|
MemberID: robot.MemberID,
|
|
TeamID: robot.TeamID,
|
|
Status: types.ExecRunning,
|
|
Tasks: []types.Task{},
|
|
Results: []types.TaskResult{},
|
|
}
|
|
exec.SetRobot(robot)
|
|
|
|
e := standard.NewWithConfig(executortypes.Config{SkipPersistence: true})
|
|
err := e.Suspend(
|
|
types.NewContext(context.Background(), nil),
|
|
exec, 5, "some question",
|
|
)
|
|
|
|
assert.ErrorIs(t, err, types.ErrExecutionSuspended)
|
|
assert.Equal(t, types.ExecWaiting, exec.Status)
|
|
assert.Empty(t, exec.WaitingTaskID)
|
|
})
|
|
}
|
|
|
|
// ============================================================================
|
|
// ExecuteWithControl handles ErrExecutionSuspended
|
|
// ============================================================================
|
|
|
|
func TestExecuteWithControlSuspend(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test")
|
|
}
|
|
|
|
testutils.Prepare(t)
|
|
defer testutils.Clean(t)
|
|
|
|
t.Run("returns ErrExecutionSuspended without marking as failed", func(t *testing.T) {
|
|
// This test requires a robot-need-input assistant that returns need_input.
|
|
// Since we don't have one yet (Stage 6), we test the suspend path indirectly
|
|
// by verifying that when RunExecution returns ErrExecutionSuspended,
|
|
// ExecuteWithControl propagates it correctly.
|
|
//
|
|
// Full E2E test with real assistant will be in Stage 6.
|
|
|
|
robot := &types.Robot{
|
|
MemberID: "test-robot-suspend-exec",
|
|
TeamID: "test-team-1",
|
|
DisplayName: "Suspend Exec Test",
|
|
Config: &types.Config{
|
|
Identity: &types.Identity{
|
|
Role: "Test",
|
|
},
|
|
Resources: &types.Resources{
|
|
Phases: map[types.Phase]string{},
|
|
Agents: []string{"experts.text-writer"},
|
|
},
|
|
Quota: &types.Quota{Max: 5},
|
|
},
|
|
}
|
|
|
|
ctx := types.NewContext(context.Background(), testAuth())
|
|
e := standard.New()
|
|
|
|
// Execute normally (no need_input expected from text-writer)
|
|
exec, err := e.Execute(ctx, robot, types.TriggerHuman, "Write a greeting")
|
|
if err == types.ErrExecutionSuspended {
|
|
// If somehow suspended, verify state
|
|
assert.Equal(t, types.ExecWaiting, exec.Status)
|
|
assert.NotEmpty(t, exec.WaitingQuestion)
|
|
} else {
|
|
// Normal completion
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, exec)
|
|
}
|
|
})
|
|
}
|
|
|
|
// ============================================================================
|
|
// ResumeContext data structure tests
|
|
// ============================================================================
|
|
|
|
func TestResumeContext(t *testing.T) {
|
|
t.Run("stores task index and previous results", func(t *testing.T) {
|
|
rc := &types.ResumeContext{
|
|
TaskIndex: 2,
|
|
PreviousResults: []types.TaskResult{
|
|
{TaskID: "t1", Success: true, Output: "out1"},
|
|
{TaskID: "t2", Success: false, Error: "some error"},
|
|
},
|
|
}
|
|
assert.Equal(t, 2, rc.TaskIndex)
|
|
assert.Len(t, rc.PreviousResults, 2)
|
|
assert.True(t, rc.PreviousResults[0].Success)
|
|
assert.False(t, rc.PreviousResults[1].Success)
|
|
})
|
|
}
|
|
|
|
// ============================================================================
|
|
// NeedInput in TaskResult
|
|
// ============================================================================
|
|
|
|
func TestTaskResultNeedInput(t *testing.T) {
|
|
t.Run("NeedInput fields are populated correctly", func(t *testing.T) {
|
|
result := types.TaskResult{
|
|
TaskID: "task-001",
|
|
Success: true,
|
|
Output: "some output",
|
|
NeedInput: true,
|
|
InputQuestion: "What time range?",
|
|
}
|
|
assert.True(t, result.NeedInput)
|
|
assert.Equal(t, "What time range?", result.InputQuestion)
|
|
})
|
|
}
|
|
|
|
// ============================================================================
|
|
// Execution status transitions for suspend/resume
|
|
// ============================================================================
|
|
|
|
func TestExecutionStatusTransitions(t *testing.T) {
|
|
t.Run("ExecWaiting is a valid status", func(t *testing.T) {
|
|
exec := &types.Execution{
|
|
ID: "exec-001",
|
|
Status: types.ExecWaiting,
|
|
}
|
|
assert.Equal(t, types.ExecStatus("waiting"), exec.Status)
|
|
})
|
|
|
|
t.Run("TaskWaitingInput is a valid task status", func(t *testing.T) {
|
|
task := types.Task{
|
|
ID: "task-001",
|
|
Status: types.TaskWaitingInput,
|
|
}
|
|
assert.Equal(t, types.TaskStatus("waiting_input"), task.Status)
|
|
})
|
|
|
|
t.Run("Execution V2 fields are accessible", func(t *testing.T) {
|
|
now := time.Now()
|
|
exec := &types.Execution{
|
|
ID: "exec-v2-001",
|
|
ChatID: "robot_member1_exec001",
|
|
WaitingTaskID: "task-002",
|
|
WaitingQuestion: "What period?",
|
|
WaitingSince: &now,
|
|
ResumeContext: &types.ResumeContext{
|
|
TaskIndex: 1,
|
|
PreviousResults: []types.TaskResult{
|
|
{TaskID: "task-001", Success: true},
|
|
},
|
|
},
|
|
}
|
|
assert.Equal(t, "robot_member1_exec001", exec.ChatID)
|
|
assert.Equal(t, "task-002", exec.WaitingTaskID)
|
|
assert.Equal(t, "What period?", exec.WaitingQuestion)
|
|
assert.NotNil(t, exec.WaitingSince)
|
|
assert.NotNil(t, exec.ResumeContext)
|
|
assert.Equal(t, 1, exec.ResumeContext.TaskIndex)
|
|
})
|
|
}
|