- 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.
965 lines
29 KiB
Go
965 lines
29 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",
|
|
}
|
|
execStore := store.NewExecutionStore()
|
|
_ = 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",
|
|
}
|
|
execStore := store.NewExecutionStore()
|
|
_ = 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",
|
|
}
|
|
execStore := store.NewExecutionStore()
|
|
_ = 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",
|
|
}
|
|
execStore := store.NewExecutionStore()
|
|
_ = 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",
|
|
Tasks: []types.Task{
|
|
{ID: "existing-1", Description: "Existing"},
|
|
},
|
|
}
|
|
execStore := store.NewExecutionStore()
|
|
_ = 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",
|
|
}
|
|
execStore := store.NewExecutionStore()
|
|
_ = 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",
|
|
}
|
|
execStore := store.NewExecutionStore()
|
|
_ = 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",
|
|
}
|
|
execStore := store.NewExecutionStore()
|
|
_ = 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",
|
|
}
|
|
execStore := store.NewExecutionStore()
|
|
_ = 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",
|
|
}
|
|
execStore := store.NewExecutionStore()
|
|
_ = 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,
|
|
}
|
|
_ = 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,
|
|
}
|
|
_ = 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")
|
|
})
|
|
}
|