Files
kandev/apps/backend/internal/workflow/engine/engine_test.go
T

520 lines
16 KiB
Go

package engine
import (
"context"
"errors"
"testing"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
)
type fakeStore struct {
state MachineState
stepsByID map[string]StepSpec
nextSteps map[int]StepSpec // keyed by currentPosition
prevSteps map[int]StepSpec // keyed by currentPosition
persistedData map[string]any
applied map[string]bool
transitionFrom string
transitionTo string
}
func (s *fakeStore) LoadState(_ context.Context, _, _ string) (MachineState, error) {
return s.state, nil
}
func (s *fakeStore) LoadStep(_ context.Context, _, stepID string) (StepSpec, error) {
step, ok := s.stepsByID[stepID]
if !ok {
return StepSpec{}, errors.New("step not found")
}
return step, nil
}
func (s *fakeStore) LoadNextStep(_ context.Context, _ string, currentPosition int) (StepSpec, error) {
step, ok := s.nextSteps[currentPosition]
if !ok {
return StepSpec{}, errors.New("no next step")
}
return step, nil
}
func (s *fakeStore) LoadPreviousStep(_ context.Context, _ string, currentPosition int) (StepSpec, error) {
step, ok := s.prevSteps[currentPosition]
if !ok {
return StepSpec{}, errors.New("no previous step")
}
return step, nil
}
func (s *fakeStore) ApplyTransition(_ context.Context, _, _, fromStepID, toStepID string, _ Trigger) error {
s.transitionFrom = fromStepID
s.transitionTo = toStepID
return nil
}
func (s *fakeStore) ApplyTransitionIfAtStep(
_ context.Context, _, _, expectedStepID, toStepID string, _ Trigger,
) (bool, error) {
if s.state.CurrentStepID != expectedStepID {
return false, nil
}
s.transitionFrom = expectedStepID
s.transitionTo = toStepID
s.state.CurrentStepID = toStepID
return true, nil
}
func (s *fakeStore) PersistData(_ context.Context, _ string, data map[string]any) error {
s.persistedData = data
return nil
}
func (s *fakeStore) IsOperationApplied(_ context.Context, operationID string) (bool, error) {
if operationID == "" {
return false, nil
}
return s.applied[operationID], nil
}
func (s *fakeStore) MarkOperationApplied(_ context.Context, operationID string) error {
if operationID == "" {
return nil
}
s.applied[operationID] = true
return nil
}
type fakeCallback struct {
result ActionResult
executed bool
}
func (c *fakeCallback) Execute(_ context.Context, _ ActionInput) (ActionResult, error) {
c.executed = true
return c.result, nil
}
// recordingCallback captures the ActionInput it was invoked with so tests can
// assert the engine dispatched the right typed action to the callback.
type recordingCallback struct {
calls []ActionInput
}
func (c *recordingCallback) Execute(_ context.Context, in ActionInput) (ActionResult, error) {
c.calls = append(c.calls, in)
return ActionResult{}, nil
}
// TestHandleTrigger_SetSessionMode_InvokesCallback exercises the full engine path
// for the set_session_mode action (issue #1183): a step compiled from a
// set_session_mode on_enter action dispatches to the registered callback with the
// typed mode carried through. This is the engine-level end-to-end of the feature.
func TestHandleTrigger_SetSessionMode_InvokesCallback(t *testing.T) {
compiled := CompileStep(&wfmodels.WorkflowStep{
ID: "step-1", WorkflowID: "wf1",
Events: wfmodels.StepEvents{
OnEnter: []wfmodels.OnEnterAction{
{Type: wfmodels.OnEnterSetSessionMode, Config: map[string]any{"mode": "acceptEdits"}},
},
},
})
store := &fakeStore{
state: MachineState{TaskID: "t1", SessionID: "s1", WorkflowID: "wf1", CurrentStepID: "step-1"},
stepsByID: map[string]StepSpec{"step-1": compiled},
applied: map[string]bool{},
}
cb := &recordingCallback{}
eng := New(store, MapRegistry{ActionSetSessionMode: cb})
if _, err := eng.HandleTrigger(context.Background(), HandleInput{
TaskID: "t1", SessionID: "s1", Trigger: TriggerOnEnter,
}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(cb.calls) != 1 {
t.Fatalf("expected set_session_mode callback to fire once, got %d", len(cb.calls))
}
got := cb.calls[0].Action
if got.Kind != ActionSetSessionMode {
t.Fatalf("unexpected action kind dispatched: %s", got.Kind)
}
if got.SetSessionMode == nil || got.SetSessionMode.Mode != "acceptEdits" {
t.Fatalf("expected dispatched mode acceptEdits, got %+v", got.SetSessionMode)
}
}
// TestHandleTriggerSessionShapedOnly_ExecutesSessionShapedRunsSessionIndependent
// covers the workflow-switch route's use of HandleTriggerSessionShapedOnly:
// it must still execute the session-shaped kinds (auto_start_agent here) —
// that is this route's actual production path for them — while skipping the
// session-independent kinds DispatchStepEntry now owns exclusively.
func TestHandleTriggerSessionShapedOnly_ExecutesSessionShapedRunsSessionIndependent(t *testing.T) {
compiled := CompileStep(&wfmodels.WorkflowStep{
ID: "step-1", WorkflowID: "wf1",
Events: wfmodels.StepEvents{
OnEnter: []wfmodels.OnEnterAction{
{Type: wfmodels.OnEnterAutoStartAgent},
{Type: wfmodels.OnEnterClearDecisions},
},
},
})
store := &fakeStore{
state: MachineState{TaskID: "t1", SessionID: "s1", WorkflowID: "wf1", CurrentStepID: "step-1"},
stepsByID: map[string]StepSpec{"step-1": compiled},
applied: map[string]bool{},
}
shaped := &recordingCallback{}
independent := &recordingCallback{}
eng := New(store, MapRegistry{
ActionAutoStartAgent: shaped,
ActionClearDecisions: independent,
})
if _, err := eng.HandleTriggerSessionShapedOnly(context.Background(), HandleInput{
TaskID: "t1", SessionID: "s1", Trigger: TriggerOnEnter,
}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(shaped.calls) != 1 {
t.Fatalf("expected auto_start_agent (session-shaped) to fire once, got %d", len(shaped.calls))
}
if len(independent.calls) != 0 {
t.Fatalf("expected clear_decisions (session-independent) not to fire, got %d calls", len(independent.calls))
}
}
func TestHandleTrigger_FirstTransitionWins(t *testing.T) {
store := &fakeStore{
state: MachineState{TaskID: "t1", SessionID: "s1", WorkflowID: "wf1", CurrentStepID: "step-1"},
stepsByID: map[string]StepSpec{
"step-1": {
ID: "step-1",
Position: 1,
Events: map[Trigger][]Action{
TriggerOnTurnComplete: {
{Kind: ActionMoveToNext},
{Kind: ActionMoveToStep, MoveToStep: &MoveToStepAction{StepID: "manual-target"}},
},
},
},
},
nextSteps: map[int]StepSpec{1: {ID: "step-2", Position: 2}},
applied: map[string]bool{},
}
eng := New(store, MapRegistry{})
result, err := eng.HandleTrigger(context.Background(), HandleInput{
TaskID: "t1", SessionID: "s1", Trigger: TriggerOnTurnComplete,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.Transitioned {
t.Fatalf("expected transition")
}
if result.ToStepID != "step-2" {
t.Fatalf("expected first transition target step-2, got %q", result.ToStepID)
}
}
func TestHandleTrigger_PersistsDataPatchFromCallbacks(t *testing.T) {
store := &fakeStore{
state: MachineState{TaskID: "t1", SessionID: "s1", WorkflowID: "wf1", CurrentStepID: "step-1"},
stepsByID: map[string]StepSpec{
"step-1": {
ID: "step-1",
Events: map[Trigger][]Action{
TriggerOnEnter: {
{Kind: ActionSetWorkflowData},
},
},
},
},
nextSteps: map[int]StepSpec{},
applied: map[string]bool{},
}
registry := MapRegistry{
ActionSetWorkflowData: &fakeCallback{result: ActionResult{DataPatch: map[string]any{"k": "v"}}},
}
eng := New(store, registry)
result, err := eng.HandleTrigger(context.Background(), HandleInput{
TaskID: "t1", SessionID: "s1", Trigger: TriggerOnEnter,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Transitioned {
t.Fatalf("did not expect transition")
}
if got, ok := store.persistedData["k"]; !ok || got != "v" {
t.Fatalf("expected persisted data patch, got %+v", store.persistedData)
}
}
func TestHandleTrigger_IdempotentByOperationID(t *testing.T) {
store := &fakeStore{
state: MachineState{TaskID: "t1", SessionID: "s1", WorkflowID: "wf1", CurrentStepID: "step-1"},
stepsByID: map[string]StepSpec{
"step-1": {
ID: "step-1",
Position: 1,
Events: map[Trigger][]Action{
TriggerOnTurnComplete: {{Kind: ActionMoveToNext}},
},
},
},
nextSteps: map[int]StepSpec{1: {ID: "step-2", Position: 2}},
applied: map[string]bool{},
}
eng := New(store, MapRegistry{})
first, err := eng.HandleTrigger(context.Background(), HandleInput{
TaskID: "t1", SessionID: "s1", Trigger: TriggerOnTurnComplete, OperationID: "op-1",
})
if err != nil {
t.Fatalf("unexpected first error: %v", err)
}
if !first.Transitioned {
t.Fatalf("expected first call to transition")
}
second, err := eng.HandleTrigger(context.Background(), HandleInput{
TaskID: "t1", SessionID: "s1", Trigger: TriggerOnTurnComplete, OperationID: "op-1",
})
if err != nil {
t.Fatalf("unexpected second error: %v", err)
}
if !second.Idempotent {
t.Fatalf("expected idempotent result on second call")
}
}
func TestHandleTrigger_OperationWithUnregisteredActionIsNotMarked(t *testing.T) {
store := &fakeStore{
state: MachineState{TaskID: "t1", SessionID: "s1", WorkflowID: "wf", CurrentStepID: "step-1"},
stepsByID: map[string]StepSpec{
"step-1": {
Events: map[Trigger][]Action{
TriggerOnAgentError: {{Kind: ActionClearDecisions}},
},
},
},
applied: map[string]bool{},
}
eng := New(store, MapRegistry{})
_, err := eng.HandleTrigger(context.Background(), HandleInput{
TaskID: "t1", SessionID: "s1", Trigger: TriggerOnAgentError, OperationID: "op-1",
})
if err == nil || !errors.Is(err, ErrActionNotYetWired) {
t.Fatalf("expected ErrActionNotYetWired, got %v", err)
}
if store.applied["op-1"] {
t.Fatal("operation was marked applied before its callback was wired")
}
}
func TestHandleTrigger_UnregisteredActionWithoutOperationIDRemainsNoOp(t *testing.T) {
store := &fakeStore{
state: MachineState{TaskID: "t1", SessionID: "s1", WorkflowID: "wf", CurrentStepID: "step-1"},
stepsByID: map[string]StepSpec{
"step-1": {
Events: map[Trigger][]Action{
TriggerOnEnter: {{Kind: ActionRunCodeReview}},
},
},
},
applied: map[string]bool{},
}
eng := New(store, MapRegistry{})
result, err := eng.HandleTrigger(context.Background(), HandleInput{
TaskID: "t1", SessionID: "s1", Trigger: TriggerOnEnter,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.ActionCount != 1 {
t.Fatalf("ActionCount = %d, want 1", result.ActionCount)
}
}
func TestHandleTrigger_DeferOperationMark(t *testing.T) {
store := &fakeStore{
state: MachineState{TaskID: "t1", SessionID: "s1", WorkflowID: "wf", CurrentStepID: "step-1"},
stepsByID: map[string]StepSpec{
"step-1": {
Position: 1,
Events: map[Trigger][]Action{
TriggerOnChildrenCompleted: {{Kind: ActionMoveToNext}},
},
},
},
nextSteps: map[int]StepSpec{1: {ID: "step-2", Position: 2}},
applied: map[string]bool{},
}
eng := New(store, MapRegistry{})
result, err := eng.HandleTrigger(context.Background(), HandleInput{
TaskID: "t1", SessionID: "s1", Trigger: TriggerOnChildrenCompleted,
OperationID: "op-1", EvaluateOnly: true, DeferOperationMark: true,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.Transitioned || result.ToStepID != "step-2" {
t.Fatalf("expected deferred transition result, got %#v", result)
}
if store.applied["op-1"] {
t.Fatal("operation was marked applied before the outer transition committed")
}
}
func TestHandleTrigger_EvaluateOnlySkipsPersistence(t *testing.T) {
store := &fakeStore{
state: MachineState{TaskID: "t1", SessionID: "s1", WorkflowID: "wf1", CurrentStepID: "step-1"},
stepsByID: map[string]StepSpec{
"step-1": {
ID: "step-1",
Position: 1,
Events: map[Trigger][]Action{
TriggerOnTurnComplete: {
{Kind: ActionSetWorkflowData},
{Kind: ActionMoveToNext},
},
},
},
},
nextSteps: map[int]StepSpec{1: {ID: "step-2", Position: 2}},
applied: map[string]bool{},
}
cb := &fakeCallback{result: ActionResult{DataPatch: map[string]any{"k": "v"}}}
registry := MapRegistry{ActionSetWorkflowData: cb}
eng := New(store, registry)
result, err := eng.HandleTrigger(context.Background(), HandleInput{
TaskID: "t1", SessionID: "s1", Trigger: TriggerOnTurnComplete, EvaluateOnly: true,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.Transitioned {
t.Fatalf("expected transition")
}
if result.ToStepID != "step-2" {
t.Fatalf("expected target step-2, got %q", result.ToStepID)
}
// Callbacks should still execute
if !cb.executed {
t.Fatalf("expected callback to execute in evaluate-only mode")
}
// But persistence should NOT happen
if store.persistedData != nil {
t.Fatalf("expected no persisted data in evaluate-only mode, got %+v", store.persistedData)
}
if store.transitionTo != "" {
t.Fatalf("expected no applied transition in evaluate-only mode, got %q", store.transitionTo)
}
}
func TestHandleTrigger_RequiresApprovalSkipsTransition(t *testing.T) {
store := &fakeStore{
state: MachineState{TaskID: "t1", SessionID: "s1", WorkflowID: "wf1", CurrentStepID: "step-1"},
stepsByID: map[string]StepSpec{
"step-1": {
ID: "step-1",
Position: 1,
Events: map[Trigger][]Action{
TriggerOnTurnComplete: {
{Kind: ActionMoveToNext, RequiresApproval: true},
},
},
},
},
nextSteps: map[int]StepSpec{1: {ID: "step-2", Position: 2}},
applied: map[string]bool{},
}
eng := New(store, MapRegistry{})
result, err := eng.HandleTrigger(context.Background(), HandleInput{
TaskID: "t1", SessionID: "s1", Trigger: TriggerOnTurnComplete,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Transitioned {
t.Fatalf("expected no transition when requires_approval is true")
}
}
func TestHandleTrigger_RequiresApprovalFallsToNextAction(t *testing.T) {
store := &fakeStore{
state: MachineState{TaskID: "t1", SessionID: "s1", WorkflowID: "wf1", CurrentStepID: "step-1"},
stepsByID: map[string]StepSpec{
"step-1": {
ID: "step-1",
Position: 1,
Events: map[Trigger][]Action{
TriggerOnTurnComplete: {
{Kind: ActionMoveToNext, RequiresApproval: true},
{Kind: ActionMoveToStep, MoveToStep: &MoveToStepAction{StepID: "step-3"}},
},
},
},
},
nextSteps: map[int]StepSpec{1: {ID: "step-2", Position: 2}},
applied: map[string]bool{},
}
eng := New(store, MapRegistry{})
result, err := eng.HandleTrigger(context.Background(), HandleInput{
TaskID: "t1", SessionID: "s1", Trigger: TriggerOnTurnComplete,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.Transitioned {
t.Fatalf("expected transition to fallback action")
}
if result.ToStepID != "step-3" {
t.Fatalf("expected transition to step-3, got %q", result.ToStepID)
}
}
func TestHandleTrigger_LoadPreviousStep(t *testing.T) {
store := &fakeStore{
state: MachineState{TaskID: "t1", SessionID: "s1", WorkflowID: "wf1", CurrentStepID: "step-2"},
stepsByID: map[string]StepSpec{
"step-2": {
ID: "step-2",
Position: 2,
Events: map[Trigger][]Action{
TriggerOnTurnComplete: {{Kind: ActionMoveToPrevious}},
},
},
},
prevSteps: map[int]StepSpec{2: {ID: "step-1", Position: 1}},
applied: map[string]bool{},
}
eng := New(store, MapRegistry{})
result, err := eng.HandleTrigger(context.Background(), HandleInput{
TaskID: "t1", SessionID: "s1", Trigger: TriggerOnTurnComplete,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.Transitioned {
t.Fatalf("expected transition")
}
if result.ToStepID != "step-1" {
t.Fatalf("expected target step-1, got %q", result.ToStepID)
}
}