| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520 |
- 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")
- }
- }
|