383 lines
14 KiB
Go
383 lines
14 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/kandev/kandev/internal/steptelemetry"
|
|
)
|
|
|
|
// fakeTaskCreator records every CreateChildTask invocation.
|
|
type fakeTaskCreator struct {
|
|
calls []struct {
|
|
ParentID string
|
|
Spec ChildTaskSpec
|
|
Attribution steptelemetry.Attribution
|
|
}
|
|
returnedID string
|
|
err error
|
|
}
|
|
|
|
func (f *fakeTaskCreator) CreateChildTask(ctx context.Context, parentID string, spec ChildTaskSpec) (string, error) {
|
|
f.calls = append(f.calls, struct {
|
|
ParentID string
|
|
Spec ChildTaskSpec
|
|
Attribution steptelemetry.Attribution
|
|
}{ParentID: parentID, Spec: spec, Attribution: steptelemetry.FromContext(ctx)})
|
|
if f.err != nil {
|
|
return "", f.err
|
|
}
|
|
if f.returnedID != "" {
|
|
return f.returnedID, nil
|
|
}
|
|
return "child-1", nil
|
|
}
|
|
|
|
func newCreateChildTaskInput(spec *CreateChildTaskAction) ActionInput {
|
|
return ActionInput{
|
|
Trigger: TriggerOnTurnComplete,
|
|
State: MachineState{TaskID: "parent-1", SessionID: "sess-1"},
|
|
Step: StepSpec{ID: "step-work"},
|
|
Action: Action{
|
|
Kind: ActionCreateChildTask,
|
|
CreateChildTask: spec,
|
|
},
|
|
OperationID: "op-create-1",
|
|
}
|
|
}
|
|
|
|
func TestCreateChildTaskCallback_HappyPath(t *testing.T) {
|
|
creator := &fakeTaskCreator{returnedID: "child-1"}
|
|
cb := CreateChildTaskCallback{Creator: creator}
|
|
spec := &CreateChildTaskAction{
|
|
Title: "Fix bug",
|
|
Description: "Investigate and fix the panic in /api/foo",
|
|
WorkflowID: "wf-kanban",
|
|
StepID: "step-1",
|
|
AgentProfileID: "profile-claude",
|
|
}
|
|
if _, err := cb.Execute(context.Background(), newCreateChildTaskInput(spec)); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(creator.calls) != 1 {
|
|
t.Fatalf("expected 1 CreateChildTask call, got %d", len(creator.calls))
|
|
}
|
|
got := creator.calls[0]
|
|
if got.ParentID != "parent-1" {
|
|
t.Errorf("parent_id = %q, want parent-1", got.ParentID)
|
|
}
|
|
if got.Spec.Title != "Fix bug" {
|
|
t.Errorf("title = %q, want Fix bug", got.Spec.Title)
|
|
}
|
|
if got.Spec.WorkflowID != "wf-kanban" {
|
|
t.Errorf("workflow_id = %q, want wf-kanban", got.Spec.WorkflowID)
|
|
}
|
|
if got.Spec.StepID != "step-1" {
|
|
t.Errorf("step_id = %q, want step-1", got.Spec.StepID)
|
|
}
|
|
if got.Spec.AgentProfileID != "profile-claude" {
|
|
t.Errorf("agent_profile_id = %q, want profile-claude", got.Spec.AgentProfileID)
|
|
}
|
|
}
|
|
|
|
// TestCreateChildTaskCallback_AttributesCausalSession proves the fix for
|
|
// Review round 3's must-fix #1: CreateChildTaskCallback is the sibling of
|
|
// SwitchWorkflowCallback in the same file, and had the identical gap round 2
|
|
// fixed for switch_workflow left unfixed here — the bare engine-internal ctx
|
|
// flowed straight to the Creator even though in.State.SessionID was
|
|
// populated, so the new child task's genesis row fell through to the
|
|
// session-less authn seam and recorded actor_kind=system for a session-
|
|
// caused creation. Execute must wrap ctx with the causal session's
|
|
// attribution before calling Creator.CreateChildTask, mirroring
|
|
// SwitchWorkflowCallback.Execute.
|
|
func TestCreateChildTaskCallback_AttributesCausalSession(t *testing.T) {
|
|
creator := &fakeTaskCreator{returnedID: "child-1"}
|
|
cb := CreateChildTaskCallback{Creator: creator}
|
|
spec := &CreateChildTaskAction{Title: "Fix bug", WorkflowID: "wf-kanban"}
|
|
if _, err := cb.Execute(context.Background(), newCreateChildTaskInput(spec)); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(creator.calls) != 1 {
|
|
t.Fatalf("expected 1 CreateChildTask call, got %d", len(creator.calls))
|
|
}
|
|
got := creator.calls[0].Attribution
|
|
if got.ActorKind != steptelemetry.ActorAgent {
|
|
t.Errorf("actor_kind = %q, want %q (the trigger's session caused this creation)", got.ActorKind, steptelemetry.ActorAgent)
|
|
}
|
|
if got.ActorID != "sess-1" {
|
|
t.Errorf("actor_id = %q, want sess-1", got.ActorID)
|
|
}
|
|
if got.SessionID != "sess-1" {
|
|
t.Errorf("session_id = %q, want sess-1", got.SessionID)
|
|
}
|
|
}
|
|
|
|
// TestCreateChildTaskCallback_NoSessionLeavesCtxUnwrapped covers the no-
|
|
// session case (e.g. a future non-session-originated trigger): Execute must
|
|
// not fabricate a session, and the genesis row falls back to its existing
|
|
// default (the authn seam) rather than a guessed agent identity.
|
|
func TestCreateChildTaskCallback_NoSessionLeavesCtxUnwrapped(t *testing.T) {
|
|
creator := &fakeTaskCreator{returnedID: "child-1"}
|
|
cb := CreateChildTaskCallback{Creator: creator}
|
|
spec := &CreateChildTaskAction{Title: "Fix bug", WorkflowID: "wf-kanban"}
|
|
in := newCreateChildTaskInput(spec)
|
|
in.State.SessionID = ""
|
|
if _, err := cb.Execute(context.Background(), in); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(creator.calls) != 1 {
|
|
t.Fatalf("expected 1 CreateChildTask call, got %d", len(creator.calls))
|
|
}
|
|
got := creator.calls[0].Attribution
|
|
if got.ActorKind == steptelemetry.ActorAgent {
|
|
t.Errorf("actor_kind = %q, want anything but agent (no session to attribute to)", got.ActorKind)
|
|
}
|
|
if got.ActorID != "" {
|
|
t.Errorf("actor_id = %q, want empty", got.ActorID)
|
|
}
|
|
}
|
|
|
|
func TestCreateChildTaskCallback_RequiresCreator(t *testing.T) {
|
|
cb := CreateChildTaskCallback{}
|
|
_, err := cb.Execute(context.Background(), newCreateChildTaskInput(&CreateChildTaskAction{Title: "x"}))
|
|
if err == nil {
|
|
t.Fatalf("expected error when creator is nil")
|
|
}
|
|
if !errors.Is(err, ErrActionNotYetWired) {
|
|
t.Errorf("expected ErrActionNotYetWired, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCreateChildTaskCallback_RequiresTitle(t *testing.T) {
|
|
creator := &fakeTaskCreator{}
|
|
cb := CreateChildTaskCallback{Creator: creator}
|
|
_, err := cb.Execute(context.Background(), newCreateChildTaskInput(&CreateChildTaskAction{}))
|
|
if err == nil {
|
|
t.Fatalf("expected error when title is empty")
|
|
}
|
|
if !strings.Contains(err.Error(), "title") {
|
|
t.Errorf("expected title error, got: %v", err)
|
|
}
|
|
if len(creator.calls) != 0 {
|
|
t.Errorf("expected no CreateChildTask calls when title missing, got %d", len(creator.calls))
|
|
}
|
|
}
|
|
|
|
func TestCreateChildTaskCallback_RequiresTaskID(t *testing.T) {
|
|
creator := &fakeTaskCreator{}
|
|
cb := CreateChildTaskCallback{Creator: creator}
|
|
in := newCreateChildTaskInput(&CreateChildTaskAction{Title: "X"})
|
|
in.State.TaskID = ""
|
|
_, err := cb.Execute(context.Background(), in)
|
|
if err == nil {
|
|
t.Fatalf("expected error when state.TaskID is empty")
|
|
}
|
|
}
|
|
|
|
// fakeWorkflowSwitcher records every SwitchTaskWorkflow invocation.
|
|
type fakeWorkflowSwitcher struct {
|
|
calls []struct {
|
|
TaskID, WorkflowID, StepID string
|
|
Attribution steptelemetry.Attribution
|
|
}
|
|
resolvedStep string
|
|
err error
|
|
}
|
|
|
|
func (f *fakeWorkflowSwitcher) SwitchTaskWorkflow(ctx context.Context, taskID, wfID, stepID string) (string, error) {
|
|
f.calls = append(f.calls, struct {
|
|
TaskID, WorkflowID, StepID string
|
|
Attribution steptelemetry.Attribution
|
|
}{TaskID: taskID, WorkflowID: wfID, StepID: stepID, Attribution: steptelemetry.FromContext(ctx)})
|
|
if f.err != nil {
|
|
return "", f.err
|
|
}
|
|
if f.resolvedStep != "" {
|
|
return f.resolvedStep, nil
|
|
}
|
|
if stepID != "" {
|
|
return stepID, nil
|
|
}
|
|
return "first-step", nil
|
|
}
|
|
|
|
// dispatchRecorder captures DispatchTriggerFn invocations for assertions.
|
|
type dispatchRecorder struct {
|
|
calls []struct {
|
|
TaskID, SessionID, OperationID, SourceStepID string
|
|
Trigger Trigger
|
|
}
|
|
err error
|
|
}
|
|
|
|
func (d *dispatchRecorder) fn() DispatchTriggerFn {
|
|
return func(_ context.Context, taskID, sessionID string, trigger Trigger, opID, sourceStepID string) error {
|
|
d.calls = append(d.calls, struct {
|
|
TaskID, SessionID, OperationID, SourceStepID string
|
|
Trigger Trigger
|
|
}{TaskID: taskID, SessionID: sessionID, OperationID: opID, SourceStepID: sourceStepID, Trigger: trigger})
|
|
return d.err
|
|
}
|
|
}
|
|
|
|
func newSwitchWorkflowInput(spec *SwitchWorkflowAction) ActionInput {
|
|
return ActionInput{
|
|
Trigger: TriggerOnTurnComplete,
|
|
State: MachineState{TaskID: "task-1", SessionID: "sess-1"},
|
|
Step: StepSpec{ID: "old-step"},
|
|
Action: Action{
|
|
Kind: ActionSwitchWorkflow,
|
|
SwitchWorkflow: spec,
|
|
},
|
|
OperationID: "op-switch-1",
|
|
}
|
|
}
|
|
|
|
func TestSwitchWorkflowCallback_HappyPath(t *testing.T) {
|
|
switcher := &fakeWorkflowSwitcher{resolvedStep: "new-first-step"}
|
|
rec := &dispatchRecorder{}
|
|
cb := SwitchWorkflowCallback{Switcher: switcher, Dispatch: rec.fn()}
|
|
spec := &SwitchWorkflowAction{WorkflowID: "wf-office"}
|
|
if _, err := cb.Execute(context.Background(), newSwitchWorkflowInput(spec)); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(switcher.calls) != 1 {
|
|
t.Fatalf("expected 1 SwitchTaskWorkflow call, got %d", len(switcher.calls))
|
|
}
|
|
if switcher.calls[0].TaskID != "task-1" || switcher.calls[0].WorkflowID != "wf-office" {
|
|
t.Errorf("switcher call = %+v", switcher.calls[0])
|
|
}
|
|
// Dispatch fires on_exit (before swap) then on_enter (after swap).
|
|
if len(rec.calls) != 2 {
|
|
t.Fatalf("expected 2 dispatch calls (on_exit + on_enter), got %d", len(rec.calls))
|
|
}
|
|
if rec.calls[0].Trigger != TriggerOnExit {
|
|
t.Errorf("first dispatch = %s, want on_exit", rec.calls[0].Trigger)
|
|
}
|
|
if rec.calls[1].Trigger != TriggerOnEnter {
|
|
t.Errorf("second dispatch = %s, want on_enter", rec.calls[1].Trigger)
|
|
}
|
|
if rec.calls[0].TaskID != "task-1" || rec.calls[0].SessionID != "sess-1" {
|
|
t.Errorf("dispatch call missing ids: %+v", rec.calls[0])
|
|
}
|
|
// Idempotency keys must be distinct so on_exit and on_enter both apply.
|
|
if rec.calls[0].OperationID == rec.calls[1].OperationID {
|
|
t.Errorf("on_exit and on_enter share operation id %q", rec.calls[0].OperationID)
|
|
}
|
|
if rec.calls[0].SourceStepID != "old-step" || rec.calls[1].SourceStepID != "old-step" {
|
|
t.Errorf("source step IDs = %q/%q, want old-step on both dispatches", rec.calls[0].SourceStepID, rec.calls[1].SourceStepID)
|
|
}
|
|
}
|
|
|
|
// TestSwitchWorkflowCallback_AttributesCausalSession proves the fix for
|
|
// Review round 2's must-fix #1: Execute validates in.State.SessionID is
|
|
// non-empty before doing anything, so a switch_workflow action is always
|
|
// genuinely caused by that session's turn. It must wrap ctx with that
|
|
// session as the actor before calling Switcher.SwitchTaskWorkflow, rather
|
|
// than leaving the switcher to fall back to the (session-less) authn seam
|
|
// and silently record actor_kind=system.
|
|
func TestSwitchWorkflowCallback_AttributesCausalSession(t *testing.T) {
|
|
switcher := &fakeWorkflowSwitcher{resolvedStep: "new-first-step"}
|
|
cb := SwitchWorkflowCallback{Switcher: switcher}
|
|
spec := &SwitchWorkflowAction{WorkflowID: "wf-office"}
|
|
in := newSwitchWorkflowInput(spec)
|
|
in.State.SessionID = "sess-causal"
|
|
if _, err := cb.Execute(context.Background(), in); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(switcher.calls) != 1 {
|
|
t.Fatalf("expected 1 SwitchTaskWorkflow call, got %d", len(switcher.calls))
|
|
}
|
|
got := switcher.calls[0].Attribution
|
|
if got.Trigger != steptelemetry.TriggerWorkflowAttached {
|
|
t.Errorf("trigger = %q, want %q", got.Trigger, steptelemetry.TriggerWorkflowAttached)
|
|
}
|
|
if got.ActorKind != steptelemetry.ActorAgent {
|
|
t.Errorf("actor_kind = %q, want %q (the trigger's session is causal, not the authn seam)", got.ActorKind, steptelemetry.ActorAgent)
|
|
}
|
|
if got.ActorID != "sess-causal" {
|
|
t.Errorf("actor_id = %q, want sess-causal", got.ActorID)
|
|
}
|
|
if got.SessionID != "sess-causal" {
|
|
t.Errorf("session_id = %q, want sess-causal", got.SessionID)
|
|
}
|
|
}
|
|
|
|
func TestSwitchWorkflowCallback_RequiresSwitcher(t *testing.T) {
|
|
cb := SwitchWorkflowCallback{}
|
|
_, err := cb.Execute(context.Background(), newSwitchWorkflowInput(&SwitchWorkflowAction{WorkflowID: "wf"}))
|
|
if err == nil || !errors.Is(err, ErrActionNotYetWired) {
|
|
t.Fatalf("expected ErrActionNotYetWired, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSwitchWorkflowCallback_RequiresWorkflowID(t *testing.T) {
|
|
switcher := &fakeWorkflowSwitcher{}
|
|
cb := SwitchWorkflowCallback{Switcher: switcher}
|
|
_, err := cb.Execute(context.Background(), newSwitchWorkflowInput(&SwitchWorkflowAction{}))
|
|
if err == nil {
|
|
t.Fatalf("expected error when workflow_id is empty")
|
|
}
|
|
if len(switcher.calls) != 0 {
|
|
t.Errorf("expected no switcher calls when workflow_id empty")
|
|
}
|
|
}
|
|
|
|
func TestSwitchWorkflowCallback_OmitsDispatchWhenNil(t *testing.T) {
|
|
switcher := &fakeWorkflowSwitcher{resolvedStep: "step"}
|
|
cb := SwitchWorkflowCallback{Switcher: switcher, Dispatch: nil}
|
|
if _, err := cb.Execute(context.Background(), newSwitchWorkflowInput(&SwitchWorkflowAction{WorkflowID: "wf"})); err != nil {
|
|
t.Fatalf("unexpected error with nil dispatch: %v", err)
|
|
}
|
|
if len(switcher.calls) != 1 {
|
|
t.Errorf("expected switch to still happen even without dispatch, got %d calls", len(switcher.calls))
|
|
}
|
|
}
|
|
|
|
// TestEngine_CreateChildTaskWiredViaOption asserts the Option/getter
|
|
// surface lets a caller probe whether the engine has a TaskCreator wired.
|
|
func TestEngine_CreateChildTaskWiredViaOption(t *testing.T) {
|
|
creator := &fakeTaskCreator{}
|
|
store := &fakeStore{state: MachineState{}, applied: map[string]bool{}}
|
|
eng := New(store, MapRegistry{}, WithTaskCreator(creator))
|
|
if eng.TaskCreatorAdapter() != creator {
|
|
t.Errorf("WithTaskCreator did not wire creator")
|
|
}
|
|
}
|
|
|
|
// TestEngine_SwitchWorkflowWiredViaOption asserts the WorkflowSwitcher
|
|
// option round-trips through the engine accessor.
|
|
func TestEngine_SwitchWorkflowWiredViaOption(t *testing.T) {
|
|
switcher := &fakeWorkflowSwitcher{}
|
|
store := &fakeStore{state: MachineState{}, applied: map[string]bool{}}
|
|
eng := New(store, MapRegistry{}, WithWorkflowSwitcher(switcher))
|
|
if eng.WorkflowSwitcherAdapter() != switcher {
|
|
t.Errorf("WithWorkflowSwitcher did not wire switcher")
|
|
}
|
|
}
|
|
|
|
// TestPayload_ChildSummaryCarriesPRLinks: the engine ChildSummary type
|
|
// has been extended in Phase 8 to carry PR URLs joined from
|
|
// github_task_prs. Lock the field on the type so subscriber code can
|
|
// rely on it.
|
|
func TestPayload_ChildSummaryCarriesPRLinks(t *testing.T) {
|
|
payload := OnChildrenCompletedPayload{
|
|
ChildSummaries: []ChildSummary{
|
|
{
|
|
TaskID: "child-1",
|
|
Status: "completed",
|
|
Summary: "Implemented feature",
|
|
PRLinks: []string{"https://github.com/owner/repo/pull/42"},
|
|
},
|
|
},
|
|
}
|
|
if len(payload.ChildSummaries) != 1 {
|
|
t.Fatalf("expected 1 child summary")
|
|
}
|
|
if got := payload.ChildSummaries[0].PRLinks; len(got) != 1 || got[0] == "" {
|
|
t.Errorf("expected PRLinks populated, got %#v", got)
|
|
}
|
|
}
|