yao/agent/robot/manager/interact_helpers_test.go
Max 9efa2f5129 Enhance execution record tests with status and phase fields
- Update multiple test cases in interact_helpers_test.go to include Status and Phase fields in ExecutionRecord, ensuring comprehensive coverage for human-triggered actions.
- Replace direct error handling with require.NoError for improved test reliability and clarity in error reporting during execution store saves.
2026-02-25 19:56:08 +08:00

999 lines
31 KiB
Go

package manager
import (
"context"
"encoding/json"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent/robot/cache"
"github.com/yaoapp/yao/agent/robot/store"
"github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/agent/testutils"
)
// mockExecutor is a minimal Executor for unit testing
type mockExecutor struct {
resumeErr error
}
func (m *mockExecutor) ExecuteWithControl(ctx *types.Context, robot *types.Robot, trigger types.TriggerType, data interface{}, execID string, control types.ExecutionControl) (*types.Execution, error) {
return nil, fmt.Errorf("not implemented")
}
func (m *mockExecutor) ExecuteWithID(ctx *types.Context, robot *types.Robot, trigger types.TriggerType, data interface{}, execID string) (*types.Execution, error) {
return nil, fmt.Errorf("not implemented")
}
func (m *mockExecutor) Execute(ctx *types.Context, robot *types.Robot, trigger types.TriggerType, data interface{}) (*types.Execution, error) {
return nil, fmt.Errorf("not implemented")
}
func (m *mockExecutor) Resume(ctx *types.Context, execID string, reply string) error {
return m.resumeErr
}
func (m *mockExecutor) ExecCount() int { return 0 }
func (m *mockExecutor) CurrentCount() int { return 0 }
func (m *mockExecutor) Reset() {}
// HL1: createConfirmingExecution
func TestCreateConfirmingExecution(t *testing.T) {
m := &Manager{}
t.Run("creates record with correct fields", func(t *testing.T) {
if testing.Short() {
t.Skip("Requires database")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
robot := &types.Robot{MemberID: "member-hl1", TeamID: "team-hl1"}
req := &InteractRequest{Message: "do something"}
execStore := store.NewExecutionStore()
record, chatID, err := m.createConfirmingExecution(ctx, robot, req, execStore)
require.NoError(t, err)
assert.NotEmpty(t, record.ExecutionID)
assert.Equal(t, "member-hl1", record.MemberID)
assert.Equal(t, "team-hl1", record.TeamID)
assert.Equal(t, types.ExecConfirming, record.Status)
assert.Equal(t, types.TriggerHuman, record.TriggerType)
assert.Equal(t, types.PhaseGoals, record.Phase)
assert.Contains(t, chatID, "robot_member-hl1_")
assert.Equal(t, chatID, record.ChatID)
assert.NotNil(t, record.Input)
assert.Equal(t, types.ActionTaskAdd, record.Input.Action)
assert.Len(t, record.Input.Messages, 1)
assert.Equal(t, "do something", record.Input.Messages[0].Content)
assert.NotNil(t, record.StartTime)
})
t.Run("UserID empty when auth is nil", func(t *testing.T) {
if testing.Short() {
t.Skip("Requires database")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
robot := &types.Robot{MemberID: "member-hl1b", TeamID: "team-hl1b"}
req := &InteractRequest{Message: "test"}
execStore := store.NewExecutionStore()
record, _, err := m.createConfirmingExecution(ctx, robot, req, execStore)
require.NoError(t, err)
assert.Empty(t, record.Input.UserID)
})
}
// HL2-HL4: adjustExecution
func TestAdjustExecution(t *testing.T) {
m := &Manager{}
t.Run("adjusts goals from string", func(t *testing.T) {
if testing.Short() {
t.Skip("Requires database")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
record := &store.ExecutionRecord{
ExecutionID: "exec-hl2",
MemberID: "member-hl2",
TriggerType: types.TriggerHuman,
Status: types.ExecPending,
Phase: types.PhaseInspiration,
}
execStore := store.NewExecutionStore()
require.NoError(t, execStore.Save(ctx.Context, record))
actionData := map[string]interface{}{"goals": "updated goals content"}
err := m.adjustExecution(ctx, record, actionData, execStore)
require.NoError(t, err)
require.NotNil(t, record.Goals)
assert.Equal(t, "updated goals content", record.Goals.Content)
})
t.Run("adjusts tasks from array", func(t *testing.T) {
if testing.Short() {
t.Skip("Requires database")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
record := &store.ExecutionRecord{
ExecutionID: "exec-hl3",
MemberID: "member-hl3",
TriggerType: types.TriggerHuman,
Status: types.ExecPending,
Phase: types.PhaseInspiration,
}
execStore := store.NewExecutionStore()
require.NoError(t, execStore.Save(ctx.Context, record))
tasks := []map[string]interface{}{
{"id": "t1", "name": "Task 1"},
{"id": "t2", "name": "Task 2"},
}
actionData := map[string]interface{}{"tasks": tasks}
err := m.adjustExecution(ctx, record, actionData, execStore)
require.NoError(t, err)
assert.Len(t, record.Tasks, 2)
})
t.Run("nil action data is noop", func(t *testing.T) {
ctx := types.NewContext(context.Background(), nil)
record := &store.ExecutionRecord{}
execStore := store.NewExecutionStore()
err := m.adjustExecution(ctx, record, nil, execStore)
require.NoError(t, err)
assert.Nil(t, record.Goals)
})
t.Run("non-map action data handled gracefully", func(t *testing.T) {
if testing.Short() {
t.Skip("Requires database")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
record := &store.ExecutionRecord{
ExecutionID: "exec-hl4",
MemberID: "member-hl4",
TriggerType: types.TriggerHuman,
Status: types.ExecPending,
Phase: types.PhaseInspiration,
}
execStore := store.NewExecutionStore()
require.NoError(t, execStore.Save(ctx.Context, record))
err := m.adjustExecution(ctx, record, "not a map", execStore)
require.NoError(t, err)
})
}
// HL5-HL6: injectTask
func TestInjectTask(t *testing.T) {
m := &Manager{}
t.Run("appends new task with auto-generated ID", func(t *testing.T) {
if testing.Short() {
t.Skip("Requires database")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
record := &store.ExecutionRecord{
ExecutionID: "exec-hl5",
MemberID: "member-hl5",
TriggerType: types.TriggerHuman,
Status: types.ExecPending,
Phase: types.PhaseInspiration,
}
execStore := store.NewExecutionStore()
require.NoError(t, execStore.Save(ctx.Context, record))
taskData := map[string]interface{}{"name": "New Task"}
err := m.injectTask(ctx, record, taskData, execStore)
require.NoError(t, err)
require.Len(t, record.Tasks, 1)
assert.Contains(t, record.Tasks[0].ID, "injected-")
assert.Equal(t, types.TaskPending, record.Tasks[0].Status)
})
t.Run("preserves existing tasks", func(t *testing.T) {
if testing.Short() {
t.Skip("Requires database")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
record := &store.ExecutionRecord{
ExecutionID: "exec-hl6",
MemberID: "member-hl6",
TriggerType: types.TriggerHuman,
Status: types.ExecPending,
Phase: types.PhaseInspiration,
Tasks: []types.Task{
{ID: "existing-1", Description: "Existing"},
},
}
execStore := store.NewExecutionStore()
require.NoError(t, execStore.Save(ctx.Context, record))
taskData := map[string]interface{}{"name": "Added Task"}
err := m.injectTask(ctx, record, taskData, execStore)
require.NoError(t, err)
assert.Len(t, record.Tasks, 2)
assert.Equal(t, "existing-1", record.Tasks[0].ID)
})
t.Run("nil action data returns error", func(t *testing.T) {
ctx := types.NewContext(context.Background(), nil)
record := &store.ExecutionRecord{}
execStore := store.NewExecutionStore()
err := m.injectTask(ctx, record, nil, execStore)
assert.Error(t, err)
assert.Contains(t, err.Error(), "task data is required")
})
t.Run("respects provided task ID", func(t *testing.T) {
if testing.Short() {
t.Skip("Requires database")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
record := &store.ExecutionRecord{
ExecutionID: "exec-hl6b",
MemberID: "member-hl6b",
TriggerType: types.TriggerHuman,
Status: types.ExecPending,
Phase: types.PhaseInspiration,
}
execStore := store.NewExecutionStore()
require.NoError(t, execStore.Save(ctx.Context, record))
taskData := map[string]interface{}{"id": "custom-id", "name": "Custom"}
err := m.injectTask(ctx, record, taskData, execStore)
require.NoError(t, err)
assert.Equal(t, "custom-id", record.Tasks[0].ID)
})
}
// HL7: callHostAgentForScenario
func TestCallHostAgentForScenario(t *testing.T) {
m := &Manager{}
t.Run("no host agent returns error", func(t *testing.T) {
ctx := types.NewContext(context.Background(), nil)
robot := &types.Robot{MemberID: "member-hl7"}
_, err := m.callHostAgentForScenario(ctx, robot, "assign", "test", nil, "chat-1")
assert.Error(t, err)
assert.Contains(t, err.Error(), "no Host Agent configured")
})
t.Run("robot with nil config returns error", func(t *testing.T) {
ctx := types.NewContext(context.Background(), nil)
robot := &types.Robot{MemberID: "member-hl7b", Config: nil}
_, err := m.callHostAgentForScenario(ctx, robot, "assign", "test", nil, "chat-1")
assert.Error(t, err)
assert.Contains(t, err.Error(), "no Host Agent configured")
})
}
// HL8: directAssign (needs pool — tested in processHostAction)
// HL9-HL10: directResume (needs executor — tested in processHostAction)
// Updated buildRobotStatusSnapshot tests
func TestBuildRobotStatusSnapshotV2(t *testing.T) {
m := &Manager{}
t.Run("nil robot returns nil", func(t *testing.T) {
snap := m.buildRobotStatusSnapshot(nil)
assert.Nil(t, snap)
})
t.Run("populates MemberID and Status", func(t *testing.T) {
robot := &types.Robot{
MemberID: "member-snap",
Status: types.RobotWorking,
}
snap := m.buildRobotStatusSnapshot(robot)
require.NotNil(t, snap)
assert.Equal(t, "member-snap", snap.MemberID)
assert.Equal(t, types.RobotWorking, snap.Status)
})
t.Run("uses ActiveCount and WaitingCount", func(t *testing.T) {
robot := &types.Robot{MemberID: "member-snap2"}
exec1 := &types.Execution{ID: "e1", Status: types.ExecRunning}
exec2 := &types.Execution{ID: "e2", Status: types.ExecWaiting}
robot.AddExecution(exec1)
robot.AddExecution(exec2)
snap := m.buildRobotStatusSnapshot(robot)
require.NotNil(t, snap)
assert.Equal(t, 1, snap.ActiveCount)
assert.Equal(t, 1, snap.WaitingCount)
})
t.Run("populates ActiveExecs briefs", func(t *testing.T) {
robot := &types.Robot{MemberID: "member-snap3"}
exec := &types.Execution{ID: "e-brief", Status: types.ExecRunning, Name: "Test Exec"}
robot.AddExecution(exec)
snap := m.buildRobotStatusSnapshot(robot)
require.NotNil(t, snap)
require.Len(t, snap.ActiveExecs, 1)
assert.Equal(t, "e-brief", snap.ActiveExecs[0].ID)
})
t.Run("uses robot MaxQuota", func(t *testing.T) {
robot := &types.Robot{
MemberID: "member-snap4",
Config: &types.Config{Quota: &types.Quota{Max: 7}},
}
snap := m.buildRobotStatusSnapshot(robot)
require.NotNil(t, snap)
assert.Equal(t, 7, snap.MaxQuota)
})
}
// Test processHostAction — adjust branch
func TestProcessHostActionAdjust(t *testing.T) {
if testing.Short() {
t.Skip("Requires database")
}
testutils.Prepare(t)
defer testutils.Clean(t)
m := &Manager{}
ctx := types.NewContext(context.Background(), nil)
robot := &types.Robot{MemberID: "member-pa-adj"}
t.Run("adjust with goals", func(t *testing.T) {
record := &store.ExecutionRecord{
ExecutionID: "exec-pa2",
MemberID: "member-pa-adj",
TriggerType: types.TriggerHuman,
Status: types.ExecConfirming,
Phase: types.PhaseInspiration,
}
execStore := store.NewExecutionStore()
require.NoError(t, execStore.Save(ctx.Context, record))
output := &types.HostOutput{
Reply: "Plan adjusted",
Action: types.HostActionAdjust,
ActionData: map[string]interface{}{"goals": "new goals"},
}
resp, err := m.processHostAction(ctx, robot, record, output, execStore)
require.NoError(t, err)
assert.Equal(t, "adjusted", resp.Status)
require.NotNil(t, record.Goals)
assert.Equal(t, "new goals", record.Goals.Content)
})
t.Run("adjust with tasks", func(t *testing.T) {
record := &store.ExecutionRecord{
ExecutionID: "exec-pa3",
MemberID: "member-pa-adj",
TriggerType: types.TriggerHuman,
Status: types.ExecConfirming,
Phase: types.PhaseInspiration,
}
execStore := store.NewExecutionStore()
require.NoError(t, execStore.Save(ctx.Context, record))
tasksJSON := []map[string]interface{}{{"id": "t1", "name": "Adjusted Task"}}
output := &types.HostOutput{
Reply: "Tasks updated",
Action: types.HostActionAdjust,
ActionData: map[string]interface{}{"tasks": tasksJSON},
}
resp, err := m.processHostAction(ctx, robot, record, output, execStore)
require.NoError(t, err)
assert.Equal(t, "adjusted", resp.Status)
assert.Len(t, record.Tasks, 1)
})
t.Run("adjust with nil data is noop", func(t *testing.T) {
record := &store.ExecutionRecord{
ExecutionID: "exec-pa4",
MemberID: "member-pa-adj",
TriggerType: types.TriggerHuman,
Status: types.ExecConfirming,
Phase: types.PhaseInspiration,
}
execStore := store.NewExecutionStore()
require.NoError(t, execStore.Save(ctx.Context, record))
output := &types.HostOutput{
Reply: "No changes",
Action: types.HostActionAdjust,
}
resp, err := m.processHostAction(ctx, robot, record, output, execStore)
require.NoError(t, err)
assert.Equal(t, "adjusted", resp.Status)
})
}
// Test processHostAction — add_task branch
func TestProcessHostActionAddTask(t *testing.T) {
if testing.Short() {
t.Skip("Requires database")
}
testutils.Prepare(t)
defer testutils.Clean(t)
m := &Manager{}
ctx := types.NewContext(context.Background(), nil)
robot := &types.Robot{MemberID: "member-pa-at"}
t.Run("add task success", func(t *testing.T) {
record := &store.ExecutionRecord{
ExecutionID: "exec-pa5",
MemberID: "member-pa-at",
TriggerType: types.TriggerHuman,
Status: types.ExecConfirming,
Phase: types.PhaseInspiration,
}
execStore := store.NewExecutionStore()
require.NoError(t, execStore.Save(ctx.Context, record))
output := &types.HostOutput{
Reply: "Task added",
Action: types.HostActionAddTask,
ActionData: map[string]interface{}{"name": "New task"},
}
resp, err := m.processHostAction(ctx, robot, record, output, execStore)
require.NoError(t, err)
assert.Equal(t, "task_added", resp.Status)
assert.Len(t, record.Tasks, 1)
})
t.Run("add task nil data returns error", func(t *testing.T) {
record := &store.ExecutionRecord{
ExecutionID: "exec-pa6",
MemberID: "member-pa-at",
}
execStore := store.NewExecutionStore()
output := &types.HostOutput{
Reply: "Add task",
Action: types.HostActionAddTask,
}
_, err := m.processHostAction(ctx, robot, record, output, execStore)
assert.Error(t, err)
assert.Contains(t, err.Error(), "task data is required")
})
}
// Test processHostAction — skip branch
func TestProcessHostActionSkip(t *testing.T) {
m := &Manager{}
ctx := types.NewContext(context.Background(), nil)
robot := &types.Robot{MemberID: "member-pa-skip"}
t.Run("skip without waiting task returns error", func(t *testing.T) {
record := &store.ExecutionRecord{
ExecutionID: "exec-pa8",
MemberID: "member-pa-skip",
}
execStore := store.NewExecutionStore()
output := &types.HostOutput{
Reply: "Skip it",
Action: types.HostActionSkip,
}
_, err := m.processHostAction(ctx, robot, record, output, execStore)
assert.Error(t, err)
assert.Contains(t, err.Error(), "no task is waiting")
})
}
// Test processHostAction — wait_for_more and default
func TestProcessHostActionWaitForMoreAndDefault(t *testing.T) {
m := &Manager{}
ctx := types.NewContext(context.Background(), nil)
robot := &types.Robot{MemberID: "member-pa-wfm"}
t.Run("wait_for_more", func(t *testing.T) {
record := &store.ExecutionRecord{}
execStore := store.NewExecutionStore()
output := &types.HostOutput{
Reply: "More details please",
WaitForMore: true,
}
resp, err := m.processHostAction(ctx, robot, record, output, execStore)
require.NoError(t, err)
assert.Equal(t, "waiting_for_more", resp.Status)
assert.Equal(t, "More details please", resp.Reply)
assert.True(t, resp.WaitForMore)
})
t.Run("unknown action returns acknowledged", func(t *testing.T) {
record := &store.ExecutionRecord{}
execStore := store.NewExecutionStore()
output := &types.HostOutput{
Reply: "OK",
Action: "unknown_action",
}
resp, err := m.processHostAction(ctx, robot, record, output, execStore)
require.NoError(t, err)
assert.Equal(t, "acknowledged", resp.Status)
assert.Equal(t, "OK", resp.Message)
})
}
// Test processHostAction — cancel branch
func TestProcessHostActionCancel(t *testing.T) {
if testing.Short() {
t.Skip("Requires database")
}
testutils.Prepare(t)
defer testutils.Clean(t)
t.Run("cancel waiting execution", func(t *testing.T) {
// Cannot fully test without a started manager; verify the error path
m := &Manager{started: false}
ctx := types.NewContext(context.Background(), nil)
robot := &types.Robot{MemberID: "member-pa-cancel"}
record := &store.ExecutionRecord{
ExecutionID: "exec-pa11",
MemberID: "member-pa-cancel",
}
execStore := store.NewExecutionStore()
output := &types.HostOutput{
Reply: "Cancel it",
Action: types.HostActionCancel,
}
_, err := m.processHostAction(ctx, robot, record, output, execStore)
assert.Error(t, err)
assert.Contains(t, err.Error(), "manager not started")
})
}
// Test HandleInteract validation
func TestHandleInteractValidationExtended(t *testing.T) {
t.Run("manager not started returns error", func(t *testing.T) {
m := &Manager{started: false}
_, err := m.HandleInteract(types.NewContext(context.Background(), nil), "member-1", &InteractRequest{Message: "test"})
assert.Error(t, err)
assert.Contains(t, err.Error(), "manager not started")
})
t.Run("empty member_id returns error", func(t *testing.T) {
m := &Manager{started: true}
_, err := m.HandleInteract(types.NewContext(context.Background(), nil), "", &InteractRequest{Message: "test"})
assert.Error(t, err)
assert.Contains(t, err.Error(), "member_id is required")
})
t.Run("nil request returns error", func(t *testing.T) {
m := &Manager{started: true}
_, err := m.HandleInteract(types.NewContext(context.Background(), nil), "member-1", nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "message is required")
})
t.Run("empty message returns error", func(t *testing.T) {
m := &Manager{started: true}
_, err := m.HandleInteract(types.NewContext(context.Background(), nil), "member-1", &InteractRequest{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "message is required")
})
t.Run("non-interactable status returns error", func(t *testing.T) {
if testing.Short() {
t.Skip("Requires database and cache")
}
testutils.Prepare(t)
defer testutils.Clean(t)
// Would require a full Manager with cache — tested via E2E
})
}
// Test CancelExecution validation
func TestCancelExecutionValidationExtended(t *testing.T) {
t.Run("manager not started", func(t *testing.T) {
m := &Manager{started: false}
err := m.CancelExecution(types.NewContext(context.Background(), nil), "exec-1")
assert.Error(t, err)
assert.Contains(t, err.Error(), "manager not started")
})
}
// Test buildHostContext JSON output
func TestBuildHostContextJSON(t *testing.T) {
m := &Manager{}
robot := &types.Robot{MemberID: "member-ctx"}
record := &store.ExecutionRecord{
Goals: &types.Goals{Content: "test goals"},
Tasks: []types.Task{{ID: "t1"}},
WaitingQuestion: "What time?",
}
waitingTask := &types.Task{ID: "t1", Status: types.TaskWaitingInput}
hostCtx := m.buildHostContext(robot, record, waitingTask)
require.NotNil(t, hostCtx)
data, err := json.Marshal(hostCtx)
require.NoError(t, err)
var parsed map[string]interface{}
err = json.Unmarshal(data, &parsed)
require.NoError(t, err)
// Goals is a struct, not a plain string
goalsRaw, ok := parsed["goals"]
require.True(t, ok)
goalsMap, ok := goalsRaw.(map[string]interface{})
require.True(t, ok, "Goals should be a JSON object, not a string")
assert.Equal(t, "test goals", goalsMap["content"])
assert.Equal(t, "What time?", parsed["agent_reply"])
}
// ==================== processHostAction -- confirm branch (PA1) ====================
func TestProcessHostActionConfirmRequiresPool(t *testing.T) {
if testing.Short() {
t.Skip("Requires database")
}
testutils.Prepare(t)
defer testutils.Clean(t)
m := &Manager{started: false}
ctx := types.NewContext(context.Background(), nil)
robot := &types.Robot{MemberID: "member-pa1"}
record := &store.ExecutionRecord{
ExecutionID: "exec-pa1",
MemberID: "member-pa1",
Status: types.ExecConfirming,
}
execStore := store.NewExecutionStore()
output := &types.HostOutput{
Reply: "Confirmed",
Action: types.HostActionConfirm,
}
assert.Panics(t, func() {
m.processHostAction(ctx, robot, record, output, execStore)
}, "should panic because pool/executor are nil")
}
// ==================== processHostAction -- inject_ctx branch (PA9-PA10) ====================
func TestProcessHostActionInjectCtx(t *testing.T) {
t.Run("nil executor panics", func(t *testing.T) {
m := &Manager{}
ctx := types.NewContext(context.Background(), nil)
robot := &types.Robot{MemberID: "member-pa9"}
record := &store.ExecutionRecord{
ExecutionID: "exec-pa9",
MemberID: "member-pa9",
Status: types.ExecWaiting,
}
execStore := store.NewExecutionStore()
output := &types.HostOutput{
Reply: "Here's context",
Action: types.HostActionInjectCtx,
ActionData: "additional context data",
}
assert.Panics(t, func() {
m.processHostAction(ctx, robot, record, output, execStore)
})
})
t.Run("with mock executor delegates resume", func(t *testing.T) {
mockExec := &mockExecutor{resumeErr: fmt.Errorf("mock error")}
m := &Manager{executor: mockExec}
ctx := types.NewContext(context.Background(), nil)
robot := &types.Robot{MemberID: "member-pa10"}
record := &store.ExecutionRecord{
ExecutionID: "exec-pa10",
MemberID: "member-pa10",
}
execStore := store.NewExecutionStore()
output := &types.HostOutput{
Reply: "Resume with data",
Action: types.HostActionInjectCtx,
ActionData: map[string]interface{}{"reply": "detailed info"},
}
_, err := m.processHostAction(ctx, robot, record, output, execStore)
assert.Error(t, err)
assert.Contains(t, err.Error(), "mock error")
})
t.Run("ErrExecutionSuspended returns waiting status", func(t *testing.T) {
mockExec := &mockExecutor{resumeErr: types.ErrExecutionSuspended}
m := &Manager{executor: mockExec}
ctx := types.NewContext(context.Background(), nil)
robot := &types.Robot{MemberID: "member-pa10b"}
record := &store.ExecutionRecord{
ExecutionID: "exec-pa10b",
MemberID: "member-pa10b",
}
execStore := store.NewExecutionStore()
output := &types.HostOutput{
Reply: "Resume",
Action: types.HostActionInjectCtx,
ActionData: "context",
}
resp, err := m.processHostAction(ctx, robot, record, output, execStore)
require.NoError(t, err)
assert.Equal(t, "waiting", resp.Status)
})
}
// ==================== HandleInteract routing (HI5-HI8) ====================
func TestHandleInteractRouting(t *testing.T) {
t.Run("HI5: non-existent execution_id returns error", func(t *testing.T) {
if testing.Short() {
t.Skip("Requires database and cache")
}
testutils.Prepare(t)
defer testutils.Clean(t)
m := &Manager{started: true, cache: cache.New()}
ctx := types.NewContext(context.Background(), nil)
_, err := m.HandleInteract(ctx, "member-hi5", &InteractRequest{
ExecutionID: "nonexistent-exec",
Message: "test",
})
assert.Error(t, err)
})
t.Run("HI6: non-existent robot returns error", func(t *testing.T) {
if testing.Short() {
t.Skip("Requires database and cache")
}
testutils.Prepare(t)
defer testutils.Clean(t)
m := &Manager{started: true, cache: cache.New()}
ctx := types.NewContext(context.Background(), nil)
_, err := m.HandleInteract(ctx, "nonexistent-robot", &InteractRequest{
Message: "test",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "robot not found")
})
}
// ==================== CancelExecution validation (CE2-CE5) ====================
func TestCancelExecutionStatusValidation(t *testing.T) {
if testing.Short() {
t.Skip("Requires database")
}
testutils.Prepare(t)
defer testutils.Clean(t)
t.Run("CE2: non-existent execution returns error", func(t *testing.T) {
m := &Manager{started: true}
ctx := types.NewContext(context.Background(), nil)
err := m.CancelExecution(ctx, "nonexistent-exec")
assert.Error(t, err)
assert.Contains(t, err.Error(), "execution not found")
})
t.Run("CE3: running execution cannot be cancelled", func(t *testing.T) {
m := &Manager{started: true}
ctx := types.NewContext(context.Background(), nil)
execStore := store.NewExecutionStore()
record := &store.ExecutionRecord{
ExecutionID: "exec-ce3",
MemberID: "member-ce3",
Status: types.ExecRunning,
TriggerType: types.TriggerHuman,
Phase: types.PhaseInspiration,
}
require.NoError(t, execStore.Save(ctx.Context, record))
err := m.CancelExecution(ctx, "exec-ce3")
assert.Error(t, err)
assert.Contains(t, err.Error(), "only waiting/confirming can be cancelled")
})
t.Run("CE4: completed execution cannot be cancelled", func(t *testing.T) {
m := &Manager{started: true}
ctx := types.NewContext(context.Background(), nil)
execStore := store.NewExecutionStore()
record := &store.ExecutionRecord{
ExecutionID: "exec-ce4",
MemberID: "member-ce4",
Status: types.ExecCompleted,
TriggerType: types.TriggerHuman,
Phase: types.PhaseInspiration,
}
require.NoError(t, execStore.Save(ctx.Context, record))
err := m.CancelExecution(ctx, "exec-ce4")
assert.Error(t, err)
assert.Contains(t, err.Error(), "only waiting/confirming can be cancelled")
})
}
// ==================== InteractRequest/InteractResponse struct validation ====================
func TestInteractRequestStructFields(t *testing.T) {
req := &InteractRequest{
ExecutionID: "exec-1",
TaskID: "task-1",
Source: types.InteractSourceUI,
Message: "do something",
Action: "confirm",
}
assert.Equal(t, "exec-1", req.ExecutionID)
assert.Equal(t, "task-1", req.TaskID)
assert.Equal(t, types.InteractSourceUI, req.Source)
assert.Equal(t, "do something", req.Message)
assert.Equal(t, "confirm", req.Action)
}
func TestInteractResponseStructFields(t *testing.T) {
resp := &InteractResponse{
ExecutionID: "exec-1",
Status: "confirmed",
Message: "Done",
ChatID: "chat-1",
Reply: "I'll do it",
WaitForMore: true,
}
assert.Equal(t, "exec-1", resp.ExecutionID)
assert.Equal(t, "confirmed", resp.Status)
assert.Equal(t, "Done", resp.Message)
assert.Equal(t, "chat-1", resp.ChatID)
assert.Equal(t, "I'll do it", resp.Reply)
assert.True(t, resp.WaitForMore)
}
// ==================== executeResume helper ====================
func TestExecuteResumeNilExecutor(t *testing.T) {
m := &Manager{}
ctx := types.NewContext(context.Background(), nil)
assert.Panics(t, func() {
_ = m.executeResume(ctx, "exec-test", "reply")
})
}
func TestExecuteResumeWithMock(t *testing.T) {
t.Run("delegates to executor Resume", func(t *testing.T) {
mockExec := &mockExecutor{resumeErr: nil}
m := &Manager{executor: mockExec}
ctx := types.NewContext(context.Background(), nil)
err := m.executeResume(ctx, "exec-test", "reply")
assert.NoError(t, err)
})
t.Run("propagates error", func(t *testing.T) {
mockExec := &mockExecutor{resumeErr: fmt.Errorf("resume failed")}
m := &Manager{executor: mockExec}
ctx := types.NewContext(context.Background(), nil)
err := m.executeResume(ctx, "exec-test", "reply")
assert.Error(t, err)
assert.Contains(t, err.Error(), "resume failed")
})
t.Run("propagates ErrExecutionSuspended", func(t *testing.T) {
mockExec := &mockExecutor{resumeErr: types.ErrExecutionSuspended}
m := &Manager{executor: mockExec}
ctx := types.NewContext(context.Background(), nil)
err := m.executeResume(ctx, "exec-test", "reply")
assert.Equal(t, types.ErrExecutionSuspended, err)
})
}
// ==================== skipWaitingTask and directResume with mock ====================
func TestSkipWaitingTaskWithMock(t *testing.T) {
t.Run("no waiting task returns error", func(t *testing.T) {
mockExec := &mockExecutor{}
m := &Manager{executor: mockExec}
ctx := types.NewContext(context.Background(), nil)
record := &store.ExecutionRecord{
ExecutionID: "exec-skip",
}
execStore := store.NewExecutionStore()
err := m.skipWaitingTask(ctx, record, execStore)
assert.Error(t, err)
assert.Contains(t, err.Error(), "no task is waiting")
})
t.Run("marks waiting task as skipped and resumes", func(t *testing.T) {
mockExec := &mockExecutor{resumeErr: nil}
m := &Manager{executor: mockExec}
ctx := types.NewContext(context.Background(), nil)
record := &store.ExecutionRecord{
ExecutionID: "exec-skip2",
WaitingTaskID: "task-w",
Tasks: []types.Task{
{ID: "task-w", Status: types.TaskWaitingInput},
},
}
execStore := store.NewExecutionStore()
err := m.skipWaitingTask(ctx, record, execStore)
assert.NoError(t, err)
assert.Equal(t, types.TaskSkipped, record.Tasks[0].Status)
})
}
func TestDirectResumeWithMock(t *testing.T) {
t.Run("successful resume", func(t *testing.T) {
mockExec := &mockExecutor{resumeErr: nil}
m := &Manager{executor: mockExec}
ctx := types.NewContext(context.Background(), nil)
record := &store.ExecutionRecord{
ExecutionID: "exec-dr",
ChatID: "chat-dr",
}
req := &InteractRequest{Message: "continue"}
resp, err := m.directResume(ctx, record, req)
require.NoError(t, err)
assert.Equal(t, "resumed", resp.Status)
assert.Equal(t, "exec-dr", resp.ExecutionID)
assert.Equal(t, "chat-dr", resp.ChatID)
})
t.Run("suspended again", func(t *testing.T) {
mockExec := &mockExecutor{resumeErr: types.ErrExecutionSuspended}
m := &Manager{executor: mockExec}
ctx := types.NewContext(context.Background(), nil)
record := &store.ExecutionRecord{
ExecutionID: "exec-dr2",
ChatID: "chat-dr2",
}
req := &InteractRequest{Message: "continue"}
resp, err := m.directResume(ctx, record, req)
require.NoError(t, err)
assert.Equal(t, "waiting", resp.Status)
})
t.Run("error propagated", func(t *testing.T) {
mockExec := &mockExecutor{resumeErr: fmt.Errorf("resume failed")}
m := &Manager{executor: mockExec}
ctx := types.NewContext(context.Background(), nil)
record := &store.ExecutionRecord{
ExecutionID: "exec-dr3",
}
req := &InteractRequest{Message: "continue"}
_, err := m.directResume(ctx, record, req)
assert.Error(t, err)
assert.Contains(t, err.Error(), "resume failed")
})
}