yao/agent/robot/executor/standard/suspend_resume_test.go
Max bc4787f857 Update executor to support V2 execution model and enhance event handling
- 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.
2026-02-25 18:40:48 +08:00

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)
})
}