package workflow_test import ( "context" "fmt" "testing" "workflow" ) // ── helpers ─────────────────────────────────────────────────────────────────── func intPtr(n int) *int { return &n } // toInt converts various numeric types to int for test assertions. func toInt(v interface{}) (int, error) { switch n := v.(type) { case int: return n, nil case int64: return int(n), nil case float64: return int(n), nil case int32: return int(n), nil default: return 0, fmt.Errorf("not a number: %T %v", v, v) } } func v316Registry(vars ...string) workflow.Registry { return workflow.Registry{ Services: []string{}, Components: []string{}, Vars: vars, } } func v316Adapters() *workflow.Adapters { return &workflow.Adapters{ Service: workflow.NewDefaultServiceAdapter(), Component: workflow.NewDefaultComponentAdapter(), LLM: workflow.NewDefaultLLMAdapter(), } } func mustEngineV316(t *testing.T, wf *workflow.Workflow) *workflow.Engine { t.Helper() eng, err := workflow.NewEngine(wf) if err != nil { t.Fatalf("NewEngine: %v", err) } return eng } // ── Validation tests ───────────────────────────────────────────────────────── func TestV316_Validate_WhileAndSourceMutuallyExclusive(t *testing.T) { wf := &workflow.Workflow{ Version: "3.16", Name: "while+source", Registry: v316Registry("$x(INT)"), Steps: []workflow.Step{ {ID: "Loop_test", Source: "=$x", While: "=true", MaxIterations: intPtr(3), Mode: "serial", Children: []string{"Stop_End"}, Next: "Stop_End"}, {ID: "Stop_End"}, }, } _, err := workflow.NewEngine(wf) if err == nil { t.Fatal("expected error for while + source, got nil") } t.Logf("OK: %v", err) } func TestV316_Validate_WhileRequiresMaxIterations(t *testing.T) { wf := &workflow.Workflow{ Version: "3.16", Name: "while-no-max", Registry: v316Registry(), Steps: []workflow.Step{ {ID: "Loop_test", While: "=true", Mode: "serial", Children: []string{"Noop_body"}, Next: "Stop_End"}, {ID: "Noop_body", Next: "RETURN"}, {ID: "Stop_End"}, }, } _, err := workflow.NewEngine(wf) if err == nil { t.Fatal("expected error for while without maxIterations, got nil") } t.Logf("OK: %v", err) } func TestV316_Validate_MaxIterationsLessThan1(t *testing.T) { wf := &workflow.Workflow{ Version: "3.16", Name: "max-iter-zero", Registry: v316Registry(), Steps: []workflow.Step{ {ID: "Loop_test", While: "=true", MaxIterations: intPtr(0), Mode: "serial", Children: []string{"Noop_body"}, Next: "Stop_End"}, {ID: "Noop_body", Next: "RETURN"}, {ID: "Stop_End"}, }, } _, err := workflow.NewEngine(wf) if err == nil { t.Fatal("expected error for maxIterations < 1, got nil") } t.Logf("OK: %v", err) } func TestV316_Validate_WhileRequiresSerialMode(t *testing.T) { wf := &workflow.Workflow{ Version: "3.16", Name: "while-parallel", Registry: v316Registry(), Steps: []workflow.Step{ {ID: "Loop_test", While: "=true", MaxIterations: intPtr(5), Mode: "parallel", Children: []string{"Noop_body"}, Next: "Stop_End"}, {ID: "Noop_body", Next: "RETURN"}, {ID: "Stop_End"}, }, } _, err := workflow.NewEngine(wf) if err == nil { t.Fatal("expected error for while + parallel, got nil") } t.Logf("OK: %v", err) } func TestV316_Validate_BreakOutsideLoop(t *testing.T) { wf := &workflow.Workflow{ Version: "3.16", Name: "break-outside", Registry: v316Registry(), Steps: []workflow.Step{ {ID: "Set_x", Target: "$x", Value: "1", Next: "BREAK"}, {ID: "Stop_End"}, }, } _, err := workflow.NewEngine(wf) if err == nil { t.Fatal("expected error for BREAK outside loop, got nil") } t.Logf("OK: %v", err) } func TestV316_Validate_BreakAsStepID(t *testing.T) { wf := &workflow.Workflow{ Version: "3.16", Name: "break-stepid", Registry: v316Registry(), Steps: []workflow.Step{ {ID: "BREAK"}, }, } _, err := workflow.NewEngine(wf) if err == nil { t.Fatal("expected error for BREAK as stepId, got nil") } t.Logf("OK: %v", err) } func TestV316_Validate_ModelMalformedSlash(t *testing.T) { wf := &workflow.Workflow{ Version: "3.16", Name: "model-slash", Registry: v316Registry("$a(STRING)"), Steps: []workflow.Step{ { ID: "LLM_test", Model: "openai/", // empty modelId In: workflow.StepInput{"messages": []interface{}{map[string]interface{}{"role": "user", "content": "hi"}}}, Out: workflow.StepOutput{"$a": "=_result"}, Next: "Stop_End", }, {ID: "Stop_End"}, }, } _, err := workflow.NewEngine(wf) if err == nil { t.Fatal("expected error for model with empty modelId, got nil") } t.Logf("OK: %v", err) } func TestV316_Validate_ModelMalformedSlashNoProvider(t *testing.T) { wf := &workflow.Workflow{ Version: "3.16", Name: "model-no-provider", Registry: v316Registry("$a(STRING)"), Steps: []workflow.Step{ { ID: "LLM_test", Model: "/gpt-4.1", // empty provider In: workflow.StepInput{"messages": []interface{}{map[string]interface{}{"role": "user", "content": "hi"}}}, Out: workflow.StepOutput{"$a": "=_result"}, Next: "Stop_End", }, {ID: "Stop_End"}, }, } _, err := workflow.NewEngine(wf) if err == nil { t.Fatal("expected error for model with empty provider, got nil") } t.Logf("OK: %v", err) } func TestV316_Validate_BreakInsideLoopOK(t *testing.T) { wf := &workflow.Workflow{ Version: "3.16", Name: "break-inside-loop", Registry: v316Registry("$items([INT])"), Steps: []workflow.Step{ {ID: "Set_items", Target: "$items", Value: "[1,2,3]", Next: "Loop_x"}, {ID: "Loop_x", Source: "=$items", Mode: "serial", Children: []string{"Set_brk"}, Next: "Stop_End"}, {ID: "Set_brk", Target: "$items", Value: "1", Next: "BREAK"}, {ID: "Stop_End"}, }, } _, err := workflow.NewEngine(wf) if err != nil { t.Fatalf("expected no error for BREAK inside loop, got: %v", err) } } func TestV316_Validate_WhileLoopOK(t *testing.T) { wf := &workflow.Workflow{ Version: "3.16", Name: "while-loop-ok", Registry: v316Registry("$counter(INT)"), Steps: []workflow.Step{ {ID: "Set_init", Target: "$counter", Value: "0", Next: "Loop_w"}, {ID: "Loop_w", While: "=$counter < 5", MaxIterations: intPtr(10), Mode: "serial", Children: []string{"Set_inc"}, Next: "Stop_End"}, {ID: "Set_inc", Target: "$counter", Value: "=$counter + 1", Next: "RETURN"}, {ID: "Stop_End"}, }, } _, err := workflow.NewEngine(wf) if err != nil { t.Fatalf("expected no error for valid while loop, got: %v", err) } } // ── Execution tests ────────────────────────────────────────────────────────── func TestV316_WhileLoop_BasicCounter(t *testing.T) { // While loop counting $counter from 0 to 4 wf := &workflow.Workflow{ Version: "3.16", Name: "while-counter", Registry: v316Registry("$counter(INT)"), Steps: []workflow.Step{ {ID: "Set_init", Target: "$counter", Value: "0", Next: "Loop_w"}, {ID: "Loop_w", While: "=$counter < 5", MaxIterations: intPtr(10), Mode: "serial", Children: []string{"Set_inc"}, Next: "Stop_End"}, {ID: "Set_inc", Target: "$counter", Value: "=$counter + 1", Next: "RETURN"}, {ID: "Stop_End"}, }, } eng := mustEngineV316(t, wf) result, err := eng.Execute(context.Background(), nil, v316Adapters()) if err != nil { t.Fatalf("Execute: %v", err) } events := collectEvents(result.RunEventStream) // Verify counter reached 5 counter, ok := result.Context.Variables["$counter"] if !ok { t.Fatal("$counter not found in variables") } // counter should be 5 (0 + 5 increments) counterVal, err2 := toInt(counter) if err2 != nil { t.Fatalf("$counter: %v", err2) } if counterVal != 5 { t.Errorf("expected $counter=5, got %d", counterVal) } // Count step_start events for Set_inc (should be 5) incCount := 0 for _, ev := range events { if ev.Type == workflow.RunEventStepStart && ev.StepID != nil && *ev.StepID == "Set_inc" { incCount++ } } if incCount != 5 { t.Errorf("expected 5 Set_inc step_start events, got %d", incCount) } } func TestV316_WhileLoop_MaxIterationsForceExit(t *testing.T) { // while=true but maxIterations=3 → exactly 3 iterations wf := &workflow.Workflow{ Version: "3.16", Name: "while-maxiter", Registry: v316Registry("$counter(INT)"), Steps: []workflow.Step{ {ID: "Set_init", Target: "$counter", Value: "0", Next: "Loop_w"}, {ID: "Loop_w", While: "=true", MaxIterations: intPtr(3), Mode: "serial", Children: []string{"Set_inc"}, Next: "Stop_End"}, {ID: "Set_inc", Target: "$counter", Value: "=$counter + 1", Next: "RETURN"}, {ID: "Stop_End"}, }, } eng := mustEngineV316(t, wf) result, err := eng.Execute(context.Background(), nil, v316Adapters()) if err != nil { t.Fatalf("Execute: %v", err) } collectEvents(result.RunEventStream) counter, _ := result.Context.Variables["$counter"] counterVal, err2 := toInt(counter) if err2 != nil { t.Fatalf("$counter: %v", err2) } if counterVal != 3 { t.Errorf("expected $counter=3, got %d", counterVal) } } func TestV316_WhileLoop_ConditionFalseImmediately(t *testing.T) { // while=false from the start → 0 iterations wf := &workflow.Workflow{ Version: "3.16", Name: "while-false", Registry: v316Registry("$counter(INT)"), Steps: []workflow.Step{ {ID: "Set_init", Target: "$counter", Value: "0", Next: "Loop_w"}, {ID: "Loop_w", While: "=false", MaxIterations: intPtr(10), Mode: "serial", Children: []string{"Set_inc"}, Next: "Stop_End"}, {ID: "Set_inc", Target: "$counter", Value: "=$counter + 1", Next: "RETURN"}, {ID: "Stop_End"}, }, } eng := mustEngineV316(t, wf) result, err := eng.Execute(context.Background(), nil, v316Adapters()) if err != nil { t.Fatalf("Execute: %v", err) } events := collectEvents(result.RunEventStream) counter, _ := result.Context.Variables["$counter"] counterVal, err2 := toInt(counter) if err2 != nil { t.Fatalf("$counter: %v", err2) } if counterVal != 0 { t.Errorf("expected $counter=0, got %d", counterVal) } // Set_inc should never fire for _, ev := range events { if ev.Type == workflow.RunEventStepStart && ev.StepID != nil && *ev.StepID == "Set_inc" { t.Error("Set_inc should not have started") } } } func TestV316_Break_SerialLoop(t *testing.T) { // Loop over [1,2,3,4,5], BREAK when _item == 3 wf := &workflow.Workflow{ Version: "3.16", Name: "break-serial", Registry: v316Registry("$items([INT])", "$results([INT])"), Steps: []workflow.Step{ {ID: "Set_items", Target: "$items", Value: "[1, 2, 3, 4, 5]", Next: "Loop_x"}, {ID: "Loop_x", Source: "=$items", Mode: "serial", Children: []string{"Branch_chk"}, Next: "Stop_End"}, {ID: "Branch_chk", Cases: [][]string{{"=_item == 3", "Set_brk"}, {"ELSE", "Set_ok"}}, Next: "RETURN"}, {ID: "Set_brk", Target: "$results[_index]", Value: "=_item", Next: "BREAK"}, {ID: "Set_ok", Target: "$results[_index]", Value: "=_item", Next: "RETURN"}, {ID: "Stop_End"}, }, } eng := mustEngineV316(t, wf) result, err := eng.Execute(context.Background(), nil, v316Adapters()) if err != nil { t.Fatalf("Execute: %v", err) } events := collectEvents(result.RunEventStream) // Should have results for indices 0,1,2 (items 1,2,3) — BREAK at item 3 results, _ := result.Context.Variables["$results"] resultsSlice, ok := results.([]interface{}) if !ok { t.Fatalf("$results not a slice: %T", results) } if len(resultsSlice) != 3 { t.Errorf("expected 3 results, got %d: %v", len(resultsSlice), resultsSlice) } // Items 4 and 5 should NOT have been processed setOkCount := 0 for _, ev := range events { if ev.Type == workflow.RunEventStepStart && ev.StepID != nil && *ev.StepID == "Set_ok" { setOkCount++ } } if setOkCount != 2 { // items 1 and 2 t.Errorf("expected 2 Set_ok executions, got %d", setOkCount) } } func TestV316_Break_WhileLoop(t *testing.T) { // While loop that BREAKs after 2 iterations wf := &workflow.Workflow{ Version: "3.16", Name: "break-while", Registry: v316Registry("$counter(INT)"), Steps: []workflow.Step{ {ID: "Set_init", Target: "$counter", Value: "0", Next: "Loop_w"}, {ID: "Loop_w", While: "=true", MaxIterations: intPtr(10), Mode: "serial", Children: []string{"Branch_chk"}, Next: "Stop_End"}, {ID: "Branch_chk", Cases: [][]string{{"=$counter >= 2", "Set_brk"}, {"ELSE", "Set_inc"}}, Next: "RETURN"}, {ID: "Set_brk", Target: "$counter", Value: "=$counter", Next: "BREAK"}, {ID: "Set_inc", Target: "$counter", Value: "=$counter + 1", Next: "RETURN"}, {ID: "Stop_End"}, }, } eng := mustEngineV316(t, wf) result, err := eng.Execute(context.Background(), nil, v316Adapters()) if err != nil { t.Fatalf("Execute: %v", err) } collectEvents(result.RunEventStream) counter, _ := result.Context.Variables["$counter"] counterVal, err2 := toInt(counter) if err2 != nil { t.Fatalf("$counter: %v", err2) } if counterVal != 2 { t.Errorf("expected $counter=2, got %d", counterVal) } } func TestV316_SourceLoop_MaxIterationsCaps(t *testing.T) { // source has 5 items, maxIterations: 3 → only 3 iterations wf := &workflow.Workflow{ Version: "3.16", Name: "source-maxiter", Registry: v316Registry("$items([INT])", "$results([INT])"), Steps: []workflow.Step{ {ID: "Set_items", Target: "$items", Value: "[10, 20, 30, 40, 50]", Next: "Loop_x"}, {ID: "Loop_x", Source: "=$items", Mode: "serial", MaxIterations: intPtr(3), Children: []string{"Set_col"}, Next: "Stop_End"}, {ID: "Set_col", Target: "$results[_index]", Value: "=_item", Next: "RETURN"}, {ID: "Stop_End"}, }, } eng := mustEngineV316(t, wf) result, err := eng.Execute(context.Background(), nil, v316Adapters()) if err != nil { t.Fatalf("Execute: %v", err) } collectEvents(result.RunEventStream) results, _ := result.Context.Variables["$results"] resultsSlice, ok := results.([]interface{}) if !ok { t.Fatalf("$results not a slice: %T", results) } if len(resultsSlice) != 3 { t.Errorf("expected 3 results, got %d: %v", len(resultsSlice), resultsSlice) } } // ── LLMAdapterRegistry tests ───────────────────────────────────────────────── func TestV316_LLMAdapterRegistry_Resolve(t *testing.T) { mockOpenAI := workflow.NewDefaultLLMAdapter() mockAnthropic := workflow.NewDefaultLLMAdapter() registry := workflow.NewLLMAdapterRegistry(mockOpenAI, "openai") registry.Register("openai", mockOpenAI) registry.Register("anthropic", mockAnthropic) // Empty spec → default adapter adapter, override, err := registry.Resolve("") if err != nil { t.Fatalf("Resolve empty: %v", err) } if adapter != mockOpenAI { t.Error("expected default openai adapter") } if override != "" { t.Errorf("expected no override, got %q", override) } // Provider-only → correct adapter, no override adapter, override, err = registry.Resolve("anthropic") if err != nil { t.Fatalf("Resolve anthropic: %v", err) } if adapter != mockAnthropic { t.Error("expected anthropic adapter") } if override != "" { t.Errorf("expected no override, got %q", override) } // Full spec → correct adapter with model override adapter, override, err = registry.Resolve("openai/gpt-4.1") if err != nil { t.Fatalf("Resolve openai/gpt-4.1: %v", err) } if adapter != mockOpenAI { t.Error("expected openai adapter") } if override != "gpt-4.1" { t.Errorf("expected override 'gpt-4.1', got %q", override) } // Unknown provider → error _, _, err = registry.Resolve("unknown") if err == nil { t.Error("expected error for unknown provider") } _, _, err = registry.Resolve("unknown/model") if err == nil { t.Error("expected error for unknown/model") } }