- 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.
999 lines
31 KiB
Go
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")
|
|
})
|
|
}
|