8898459c0b
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
397 lines
11 KiB
Go
397 lines
11 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)
|
|
}
|
|
}
|
|
|
|
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_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)
|
|
}
|
|
}
|