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/store" "github.com/yaoapp/yao/agent/robot/types" "github.com/yaoapp/yao/agent/testutils" ) // ============================================================================ // Resume method tests (R1-R10) // ============================================================================ func TestResume(t *testing.T) { // R1: Resume with empty execID returns error t.Run("R1: Resume with empty execID returns error", func(t *testing.T) { e := standard.New() ctx := types.NewContext(context.Background(), testAuth()) err := e.Resume(ctx, "", "some reply") require.Error(t, err) assert.Contains(t, err.Error(), "empty") }) // R2: Resume with non-existent execID returns error (requires DB) t.Run("R2: Resume with non-existent execID returns error", func(t *testing.T) { if testing.Short() { t.Skip("Requires database") } testutils.Prepare(t) defer testutils.Clean(t) e := standard.New() ctx := types.NewContext(context.Background(), testAuth()) err := e.Resume(ctx, "non-existent-exec-id-12345", "reply") require.Error(t, err) assert.Contains(t, err.Error(), "execution not found") }) // R3: Resume with execution not in waiting status returns error (requires DB) t.Run("R3: Resume with execution not in waiting status returns error", func(t *testing.T) { if testing.Short() { t.Skip("Requires database") } testutils.Prepare(t) defer testutils.Clean(t) ctx := types.NewContext(context.Background(), testAuth()) robot := createResumeTestRobot(t) exec := createResumeTestExecution(robot) exec.Status = types.ExecRunning // Not waiting record := store.FromExecution(exec) require.NoError(t, store.NewExecutionStore().Save(ctx.Context, record)) // Save robot to robot store for Resume to load robotRecord := store.FromRobot(robot) require.NoError(t, store.NewRobotStore().Save(ctx.Context, robotRecord)) e := standard.New() err := e.Resume(ctx, exec.ID, "reply") require.Error(t, err) assert.Contains(t, err.Error(), "not in waiting status") }) // R4: Verify Resume loads execution from store (requires DB) t.Run("R4: Resume loads execution from store", func(t *testing.T) { if testing.Short() { t.Skip("Requires database") } testutils.Prepare(t) defer testutils.Clean(t) ctx := types.NewContext(context.Background(), testAuth()) robot := createResumeTestRobot(t) exec := createSuspendedResumeTestExecution(robot) execStore := store.NewExecutionStore() robotStore := store.NewRobotStore() record := store.FromExecution(exec) require.NoError(t, execStore.Save(ctx.Context, record)) robotRecord := store.FromRobot(robot) require.NoError(t, robotStore.Save(ctx.Context, robotRecord)) e := standard.New() err := e.Resume(ctx, exec.ID, "User provided answer") require.NoError(t, err) // Verify execution was loaded and completed loaded, err := execStore.Get(ctx.Context, exec.ID) require.NoError(t, err) require.NotNil(t, loaded) assert.Equal(t, types.ExecCompleted, loaded.Status) }) // R5: Resume restores robot from execution record (requires DB) t.Run("R5: Resume restores robot from execution record", func(t *testing.T) { if testing.Short() { t.Skip("Requires database") } testutils.Prepare(t) defer testutils.Clean(t) ctx := types.NewContext(context.Background(), testAuth()) robot := createResumeTestRobot(t) exec := createSuspendedResumeTestExecution(robot) execStore := store.NewExecutionStore() robotStore := store.NewRobotStore() record := store.FromExecution(exec) require.NoError(t, execStore.Save(ctx.Context, record)) robotRecord := store.FromRobot(robot) require.NoError(t, robotStore.Save(ctx.Context, robotRecord)) e := standard.New() err := e.Resume(ctx, exec.ID, "Answer for the question") require.NoError(t, err) // If we get here without "robot not found", Resume successfully restored robot }) // R6: Resume with __skip__ reply marks task as skipped (requires DB) t.Run("R6: Resume with __skip__ reply marks task as skipped", func(t *testing.T) { if testing.Short() { t.Skip("Requires database") } testutils.Prepare(t) defer testutils.Clean(t) ctx := types.NewContext(context.Background(), testAuth()) robot := createResumeTestRobot(t) exec := createSuspendedResumeTestExecution(robot) // Ensure we have a task at index 0 that is waiting exec.Tasks[0].Status = types.TaskWaitingInput exec.ResumeContext = &types.ResumeContext{ TaskIndex: 0, PreviousResults: []types.TaskResult{}, } execStore := store.NewExecutionStore() robotStore := store.NewRobotStore() record := store.FromExecution(exec) require.NoError(t, execStore.Save(ctx.Context, record)) robotRecord := store.FromRobot(robot) require.NoError(t, robotStore.Save(ctx.Context, robotRecord)) e := standard.New() err := e.Resume(ctx, exec.ID, "__skip__") require.NoError(t, err) loaded, err := execStore.Get(ctx.Context, exec.ID) require.NoError(t, err) require.NotNil(t, loaded) require.Len(t, loaded.Tasks, 1) assert.Equal(t, types.TaskSkipped, loaded.Tasks[0].Status) }) // R7: Resume sends ErrExecutionSuspended when execution suspends again (requires DB) t.Run("R7: Resume sends ErrExecutionSuspended when execution suspends again", func(t *testing.T) { if testing.Short() { t.Skip("Requires database") } testutils.Prepare(t) defer testutils.Clean(t) // Use robot-need-input assistant that suspends ctx := types.NewContext(context.Background(), testAuth()) robot := createResumeNeedInputRobot(t) exec := createSuspendedResumeNeedInputExecution(robot) execStore := store.NewExecutionStore() robotStore := store.NewRobotStore() record := store.FromExecution(exec) require.NoError(t, execStore.Save(ctx.Context, record)) robotRecord := store.FromRobot(robot) require.NoError(t, robotStore.Save(ctx.Context, robotRecord)) e := standard.New() err := e.Resume(ctx, exec.ID, "some reply") // May return ErrExecutionSuspended if assistant suspends again if err != nil { assert.ErrorIs(t, err, types.ErrExecutionSuspended) } }) // R8: Resume increments exec counter t.Run("R8: Resume increments exec counter", func(t *testing.T) { if testing.Short() { t.Skip("Requires database") } testutils.Prepare(t) defer testutils.Clean(t) ctx := types.NewContext(context.Background(), testAuth()) robot := createResumeTestRobot(t) exec := createSuspendedResumeTestExecution(robot) execStore := store.NewExecutionStore() robotStore := store.NewRobotStore() record := store.FromExecution(exec) require.NoError(t, execStore.Save(ctx.Context, record)) robotRecord := store.FromRobot(robot) require.NoError(t, robotStore.Save(ctx.Context, robotRecord)) e := standard.New() e.Reset() before := e.CurrentCount() err := e.Resume(ctx, exec.ID, "answer") after := e.CurrentCount() require.NoError(t, err) // During Resume, currentCount was incremented; after completion it's decremented assert.Equal(t, before, after, "currentCount should be back to original after Resume completes") }) // R9: Resume decrements exec counter on completion t.Run("R9: Resume decrements exec counter on completion", func(t *testing.T) { if testing.Short() { t.Skip("Requires database") } testutils.Prepare(t) defer testutils.Clean(t) ctx := types.NewContext(context.Background(), testAuth()) robot := createResumeTestRobot(t) exec := createSuspendedResumeTestExecution(robot) execStore := store.NewExecutionStore() robotStore := store.NewRobotStore() record := store.FromExecution(exec) require.NoError(t, execStore.Save(ctx.Context, record)) robotRecord := store.FromRobot(robot) require.NoError(t, robotStore.Save(ctx.Context, robotRecord)) e := standard.New() e.Reset() err := e.Resume(ctx, exec.ID, "reply") require.NoError(t, err) // After Resume completes, currentCount should be 0 (no leak) assert.Equal(t, 0, e.CurrentCount()) }) // R10: Resume with nil context returns error t.Run("R10: Resume with nil context returns error", func(t *testing.T) { e := standard.NewWithConfig(executortypes.Config{SkipPersistence: true}) err := e.Resume(nil, "some-exec-id", "reply") require.Error(t, err) assert.Contains(t, err.Error(), "context") }) } // ============================================================================ // Helpers for Resume tests // ============================================================================ func createResumeTestRobot(t *testing.T) *types.Robot { t.Helper() return &types.Robot{ MemberID: "test-robot-resume", TeamID: "test-team-1", DisplayName: "Resume Test Robot", SystemPrompt: "You are a helpful assistant.", Config: &types.Config{ Identity: &types.Identity{ Role: "Test", Duties: []string{"Execute tasks"}, }, Resources: &types.Resources{ Phases: map[types.Phase]string{ types.PhaseDelivery: "robot.delivery", types.PhaseLearning: "robot.learning", }, Agents: []string{"experts.text-writer"}, }, Quota: &types.Quota{Max: 5}, }, } } func createResumeTestExecution(robot *types.Robot) *types.Execution { exec := &types.Execution{ ID: "test-exec-resume-1", MemberID: robot.MemberID, TeamID: robot.TeamID, TriggerType: types.TriggerClock, StartTime: time.Now(), Status: types.ExecRunning, Phase: types.PhaseRun, Goals: &types.Goals{Content: "## Goals\n\n1. Test resume"}, 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.TaskPending, }, }, ChatID: "robot_test-robot-resume_test-exec-resume-1", } exec.SetRobot(robot) return exec } func createSuspendedResumeTestExecution(robot *types.Robot) *types.Execution { exec := createResumeTestExecution(robot) exec.Status = types.ExecWaiting exec.WaitingTaskID = "task-001" exec.WaitingQuestion = "What should we do?" now := time.Now() exec.WaitingSince = &now exec.ResumeContext = &types.ResumeContext{ TaskIndex: 0, PreviousResults: []types.TaskResult{}, } exec.Tasks[0].Status = types.TaskWaitingInput return exec } func createResumeNeedInputRobot(t *testing.T) *types.Robot { t.Helper() return &types.Robot{ MemberID: "test-robot-resume-need-input", TeamID: "test-team-1", DisplayName: "Resume Need Input Robot", SystemPrompt: "You are a helpful assistant.", Config: &types.Config{ Identity: &types.Identity{ Role: "Test", Duties: []string{"Execute tasks"}, }, Resources: &types.Resources{ Phases: map[types.Phase]string{ types.PhaseDelivery: "robot.delivery", types.PhaseLearning: "robot.learning", }, Agents: []string{"tests.robot-need-input"}, }, Quota: &types.Quota{Max: 5}, }, } } func createSuspendedResumeNeedInputExecution(robot *types.Robot) *types.Execution { exec := &types.Execution{ ID: "test-exec-resume-need-input-1", MemberID: robot.MemberID, TeamID: robot.TeamID, TriggerType: types.TriggerClock, StartTime: time.Now(), Status: types.ExecWaiting, Phase: types.PhaseRun, Goals: &types.Goals{Content: "## Goals\n\n1. Test need input"}, Tasks: []types.Task{ { ID: "task-001", ExecutorType: types.ExecutorAssistant, ExecutorID: "tests.robot-need-input", Messages: []agentcontext.Message{ {Role: agentcontext.RoleUser, Content: "Need input test"}, }, Order: 0, Status: types.TaskWaitingInput, }, }, ChatID: "robot_test-robot-resume-need-input_test-exec-resume-need-input-1", WaitingTaskID: "task-001", WaitingQuestion: "What period?", ResumeContext: &types.ResumeContext{ TaskIndex: 0, PreviousResults: []types.TaskResult{}, }, } now := time.Now() exec.WaitingSince = &now exec.SetRobot(robot) return exec }