Files
kandev/apps/backend/internal/workflow/engine/entrydispatch_test.go
T
2026-08-27 22:19:00 +00:00

280 lines
10 KiB
Go

package engine
import (
"context"
"errors"
"go/ast"
"go/parser"
"go/token"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestSessionShapedAndSessionIndependentKindsPartitionCompiledOnEnterKinds
// asserts the two classification maps in this file are exhaustive and
// disjoint over exactly the ActionKind values CompileOnEnterAction (types.go)
// can emit for a single on_enter declaration — the per-kind switch
// compileOnEnter delegates to. It parses that function's source rather than
// hand-listing the kinds, so a kind added to the compiler later without a
// deliberate classification decision fails this test instead of silently
// defaulting into double execution (dispatched from both
// HandleTriggerSessionShapedOnly and DispatchStepEntry) or silence
// (dispatched from neither) — see
// docs/specs/office/system-design/step-entry-sequence-execution.md
// ("The two lists together are exhaustive").
func TestSessionShapedAndSessionIndependentKindsPartitionCompiledOnEnterKinds(t *testing.T) {
compiled, err := actionKindsAssignedInFunc("types.go", "CompileOnEnterAction")
require.NoError(t, err)
require.NotEmpty(t, compiled, "CompileOnEnterAction must assign at least one ActionKind for this test to be meaningful")
for _, kind := range compiled {
shaped := isSessionShapedActionKind(kind)
independent := isSessionIndependentActionKind(kind)
assert.True(t, shaped || independent,
"compiled on_enter kind %q is classified in neither sessionShapedActionKinds nor sessionIndependentActionKinds", kind)
assert.False(t, shaped && independent,
"compiled on_enter kind %q is classified in both sessionShapedActionKinds and sessionIndependentActionKinds", kind)
}
for kind := range sessionShapedActionKinds {
assert.Contains(t, compiled, kind,
"sessionShapedActionKinds lists %q, which CompileOnEnterAction no longer emits", kind)
}
for kind := range sessionIndependentActionKinds {
assert.Contains(t, compiled, kind,
"sessionIndependentActionKinds lists %q, which CompileOnEnterAction no longer emits", kind)
}
}
// actionKindsAssignedInFunc parses file (relative to this package directory)
// and returns every ActionKind identifier assigned to a "Kind" struct field
// inside the named function's body, in source order with duplicates removed.
func actionKindsAssignedInFunc(file, funcName string) ([]ActionKind, error) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, file, nil, 0)
if err != nil {
return nil, err
}
var fn *ast.FuncDecl
for _, decl := range f.Decls {
if d, ok := decl.(*ast.FuncDecl); ok && d.Name.Name == funcName {
fn = d
break
}
}
if fn == nil {
return nil, errors.New("function " + funcName + " not found in " + file)
}
seen := map[ActionKind]bool{}
var kinds []ActionKind
ast.Inspect(fn.Body, func(n ast.Node) bool {
kv, ok := n.(*ast.KeyValueExpr)
if !ok {
return true
}
key, ok := kv.Key.(*ast.Ident)
if !ok || key.Name != "Kind" {
return true
}
ident, ok := kv.Value.(*ast.Ident)
if !ok {
return true
}
kind := ActionKind(ident.Name)
// The Ident's textual name is the ActionKind constant's Go
// identifier (e.g. "ActionEnablePlanMode"), not its string
// value ("enable_plan_mode") — resolve it below.
if resolved, ok := actionKindConstants[kind]; ok {
kind = resolved
}
if !seen[kind] {
seen[kind] = true
kinds = append(kinds, kind)
}
return true
})
return kinds, nil
}
// actionKindConstants maps each ActionKind Go identifier (as it appears in
// source) to its string constant value, so actionKindsAssignedInFunc can
// resolve the identifiers it finds via AST inspection back to real
// ActionKind values without re-parsing types.go's const block.
var actionKindConstants = map[ActionKind]ActionKind{
ActionKind("ActionEnablePlanMode"): ActionEnablePlanMode,
ActionKind("ActionAutoStartAgent"): ActionAutoStartAgent,
ActionKind("ActionResetAgentContext"): ActionResetAgentContext,
ActionKind("ActionSetSessionMode"): ActionSetSessionMode,
ActionKind("ActionRunCodeReview"): ActionRunCodeReview,
ActionKind("ActionClearDecisions"): ActionClearDecisions,
ActionKind("ActionQueueRunForEachParticipant"): ActionQueueRunForEachParticipant,
ActionKind("ActionQueueRun"): ActionQueueRun,
ActionKind("ActionEnsureParticipantSeat"): ActionEnsureParticipantSeat,
}
// fakeEntryDispatchStore is a minimal TransitionStore fake for
// DispatchStepEntry tests: only LoadStep is exercised.
type fakeEntryDispatchStore struct {
step StepSpec
err error
}
func (f *fakeEntryDispatchStore) LoadState(context.Context, string, string) (MachineState, error) {
return MachineState{}, nil
}
func (f *fakeEntryDispatchStore) LoadStep(context.Context, string, string) (StepSpec, error) {
return f.step, f.err
}
func (f *fakeEntryDispatchStore) LoadNextStep(context.Context, string, int) (StepSpec, error) {
return StepSpec{}, nil
}
func (f *fakeEntryDispatchStore) LoadPreviousStep(context.Context, string, int) (StepSpec, error) {
return StepSpec{}, nil
}
func (f *fakeEntryDispatchStore) ApplyTransition(context.Context, string, string, string, string, Trigger) error {
return nil
}
func (f *fakeEntryDispatchStore) ApplyTransitionIfAtStep(context.Context, string, string, string, string, Trigger) (bool, error) {
return false, nil
}
func (f *fakeEntryDispatchStore) PersistData(context.Context, string, map[string]any) error {
return nil
}
func (f *fakeEntryDispatchStore) IsOperationApplied(context.Context, string) (bool, error) {
return false, nil
}
func (f *fakeEntryDispatchStore) MarkOperationApplied(context.Context, string) error { return nil }
// entryRecordingCallback records every ActionInput it was invoked with and
// returns a canned error, letting tests assert both "did every declared
// action run" and "did a failure stop the rest".
type entryRecordingCallback struct {
err error
inputs *[]ActionInput
}
func (c entryRecordingCallback) Execute(_ context.Context, in ActionInput) (ActionResult, error) {
*c.inputs = append(*c.inputs, in)
return ActionResult{}, c.err
}
func TestDispatchStepEntry_ExcludesSessionShapedKinds(t *testing.T) {
var seen []ActionInput
registry := MapRegistry{
ActionEnablePlanMode: entryRecordingCallback{inputs: &seen},
ActionClearDecisions: entryRecordingCallback{inputs: &seen},
}
step := StepSpec{
ID: "step-1",
Events: map[Trigger][]Action{
TriggerOnEnter: {
{Kind: ActionEnablePlanMode},
{Kind: ActionClearDecisions},
},
},
}
e := New(&fakeEntryDispatchStore{step: step}, registry)
results := e.DispatchStepEntry(context.Background(), "task-1", "wf-1", "step-1", "entry-1")
require.Len(t, results, 1, "only the session-independent action should be attempted")
assert.Equal(t, ActionClearDecisions, results[0].Kind)
assert.NoError(t, results[0].Err)
require.Len(t, seen, 1, "only the session-independent callback should have run")
assert.Equal(t, ActionClearDecisions, seen[0].Action.Kind)
assert.Equal(t, "entry-1", seen[0].EntryID)
}
func TestDispatchStepEntry_ContinuesAfterOneActionFails(t *testing.T) {
var seen []ActionInput
failure := errors.New("boom")
registry := MapRegistry{
ActionClearDecisions: entryRecordingCallback{err: failure, inputs: &seen},
ActionQueueRunForEachParticipant: entryRecordingCallback{inputs: &seen},
}
step := StepSpec{
ID: "step-1",
Events: map[Trigger][]Action{
TriggerOnEnter: {
{Kind: ActionClearDecisions},
{Kind: ActionQueueRunForEachParticipant},
},
},
}
e := New(&fakeEntryDispatchStore{step: step}, registry)
results := e.DispatchStepEntry(context.Background(), "task-1", "wf-1", "step-1", "entry-1")
require.Len(t, results, 2, "both declared actions must be attempted despite the first failing")
assert.Equal(t, ActionClearDecisions, results[0].Kind)
assert.ErrorIs(t, results[0].Err, failure)
assert.Equal(t, ActionQueueRunForEachParticipant, results[1].Kind)
assert.NoError(t, results[1].Err)
assert.Len(t, seen, 2)
}
func TestDispatchStepEntry_NoDeclaredActionsIsNoop(t *testing.T) {
step := StepSpec{ID: "step-1"}
e := New(&fakeEntryDispatchStore{step: step}, MapRegistry{})
results := e.DispatchStepEntry(context.Background(), "task-1", "wf-1", "step-1", "entry-1")
assert.Empty(t, results)
}
// TestDispatchStepEntry_RepeatedParticipantSeatRoleDispatchesOnce covers
// AC-OFFICE-REVIEW-SEATS-001.12/004.7: a step that declares
// ensure_participant_seat twice for the same role writes at most one seat
// and emits at most one record for it — DispatchStepEntry must not invoke
// the callback a second time for a role it already dispatched in this call.
func TestDispatchStepEntry_RepeatedParticipantSeatRoleDispatchesOnce(t *testing.T) {
var seen []ActionInput
registry := MapRegistry{
ActionEnsureParticipantSeat: entryRecordingCallback{inputs: &seen},
}
step := StepSpec{
ID: "step-1",
Events: map[Trigger][]Action{
TriggerOnEnter: {
{Kind: ActionEnsureParticipantSeat, EnsureParticipantSeat: &EnsureParticipantSeatAction{Role: "reviewer"}},
{Kind: ActionEnsureParticipantSeat, EnsureParticipantSeat: &EnsureParticipantSeatAction{Role: "reviewer"}},
},
},
}
e := New(&fakeEntryDispatchStore{step: step}, registry)
results := e.DispatchStepEntry(context.Background(), "task-1", "wf-1", "step-1", "entry-1")
require.Len(t, results, 1, "the repeated declaration for the same role must not be dispatched a second time")
assert.Len(t, seen, 1)
}
// TestDispatchStepEntry_DistinctParticipantSeatRolesBothDispatch asserts the
// dedup in TestDispatchStepEntry_RepeatedParticipantSeatRoleDispatchesOnce
// is scoped to the repeated role only — two declarations for different
// roles are unrelated and both must run.
func TestDispatchStepEntry_DistinctParticipantSeatRolesBothDispatch(t *testing.T) {
var seen []ActionInput
registry := MapRegistry{
ActionEnsureParticipantSeat: entryRecordingCallback{inputs: &seen},
}
step := StepSpec{
ID: "step-1",
Events: map[Trigger][]Action{
TriggerOnEnter: {
{Kind: ActionEnsureParticipantSeat, EnsureParticipantSeat: &EnsureParticipantSeatAction{Role: "reviewer"}},
{Kind: ActionEnsureParticipantSeat, EnsureParticipantSeat: &EnsureParticipantSeatAction{Role: "approver"}},
},
},
}
e := New(&fakeEntryDispatchStore{step: step}, registry)
results := e.DispatchStepEntry(context.Background(), "task-1", "wf-1", "step-1", "entry-1")
require.Len(t, results, 2)
assert.Len(t, seen, 2)
}