bugfix_test.go 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. package workflow_test
  2. import (
  3. "context"
  4. "encoding/json"
  5. "testing"
  6. "workflow"
  7. )
  8. // ---------------------------------------------------------------------------
  9. // BUG-1: StepOutput string shorthand — "out": "$var" must deserialise to
  10. // {"$var": "=_result"} (spec §5.2.4)
  11. // ---------------------------------------------------------------------------
  12. // TestOutStringShorthandUnmarshal verifies that a JSON string value for "out"
  13. // is accepted and expanded to the canonical map form.
  14. func TestOutStringShorthandUnmarshal(t *testing.T) {
  15. raw := `{"id":"LLM_Answer","out":"$answer","next":"Stop_End"}`
  16. var step workflow.Step
  17. if err := json.Unmarshal([]byte(raw), &step); err != nil {
  18. t.Fatalf("unexpected unmarshal error: %v", err)
  19. }
  20. if len(step.Out) != 1 {
  21. t.Fatalf("expected Out to have 1 entry, got %d", len(step.Out))
  22. }
  23. if step.Out["$answer"] != "=_result" {
  24. t.Errorf("expected Out[\"$answer\"] == \"=_result\", got %q", step.Out["$answer"])
  25. }
  26. }
  27. // TestOutStringShorthandEndToEnd verifies that a workflow whose "out" field uses
  28. // the string shorthand actually runs and stores _result into the target variable.
  29. func TestOutStringShorthandEndToEnd(t *testing.T) {
  30. wf := &workflow.Workflow{
  31. Version: "3.13",
  32. Name: "BUG1 Out Shorthand E2E",
  33. Registry: workflow.Registry{
  34. Vars: []string{"$answer(STRING)"},
  35. },
  36. Steps: []workflow.Step{
  37. {
  38. ID: "LLM_Answer",
  39. In: workflow.StepInput{
  40. "messages": []interface{}{
  41. map[string]interface{}{"role": "user", "content": "hi"},
  42. },
  43. },
  44. Out: workflow.StepOutput{"$answer": "=_result"}, // canonical form
  45. Next: "Stop_End",
  46. },
  47. {ID: "Stop_End"},
  48. },
  49. }
  50. // Replicate the shorthand by round-tripping through JSON
  51. jsonBytes, err := json.Marshal(wf.Steps[0])
  52. if err != nil {
  53. t.Fatalf("marshal: %v", err)
  54. }
  55. // Replace the canonical out object with its string shorthand
  56. var raw map[string]interface{}
  57. if err := json.Unmarshal(jsonBytes, &raw); err != nil {
  58. t.Fatalf("unmarshal to raw: %v", err)
  59. }
  60. raw["out"] = "$answer"
  61. patchedBytes, _ := json.Marshal(raw)
  62. var patchedStep workflow.Step
  63. if err := json.Unmarshal(patchedBytes, &patchedStep); err != nil {
  64. t.Fatalf("unmarshal patched step: %v", err)
  65. }
  66. wf.Steps[0] = patchedStep
  67. adapters := createTestAdapters()
  68. llm := adapters.LLM.(*workflow.DefaultLLMAdapter)
  69. llm.SetHandler(func(ctx context.Context, params map[string]interface{}, stream chan<- string) (map[string]interface{}, error) {
  70. return map[string]interface{}{
  71. "content": "hello world",
  72. "finish_reason": "stop",
  73. "model": "test-model",
  74. }, nil
  75. })
  76. eng, err := workflow.NewEngine(wf)
  77. if err != nil {
  78. t.Fatalf("NewEngine: %v", err)
  79. }
  80. result, err := eng.Execute(context.Background(), nil, adapters)
  81. if err != nil {
  82. t.Fatalf("Execute: %v", err)
  83. }
  84. for range result.RunEventStream {
  85. }
  86. vars := result.Context.Variables
  87. if vars["$answer"] != "hello world" {
  88. t.Errorf("expected $answer == \"hello world\", got %v", vars["$answer"])
  89. }
  90. }
  91. // ---------------------------------------------------------------------------
  92. // BUG-2: Branch → Stop_* must not trigger a second workflow_done
  93. // ---------------------------------------------------------------------------
  94. // makeBug2Workflow builds the workflow described in the bug report:
  95. //
  96. // Branch_Go
  97. // case true → Stop_A (the branch that fires)
  98. // ELSE → Stop_B (never reached)
  99. // next → Stop_C (must NOT be reached after Stop_A fires)
  100. func makeBug2Workflow() *workflow.Workflow {
  101. return &workflow.Workflow{
  102. Version: "3.13",
  103. Name: "BUG2 Double WorkflowDone",
  104. Registry: workflow.Registry{
  105. Vars: []string{"$x(STRING)"},
  106. },
  107. Steps: []workflow.Step{
  108. {
  109. ID: "Branch_Go",
  110. Cases: [][]string{
  111. {"true", "Stop_A"},
  112. {"ELSE", "Stop_B"},
  113. },
  114. Next: "Stop_C",
  115. },
  116. {ID: "Stop_A"},
  117. {ID: "Stop_B"},
  118. {ID: "Stop_C"},
  119. },
  120. }
  121. }
  122. // TestBranchStopNoDoubleWorkflowDone verifies that when a Branch case directly
  123. // targets a Stop_* node exactly one workflow_done event is emitted and the
  124. // Branch's next pointer is never followed.
  125. func TestBranchStopNoDoubleWorkflowDone(t *testing.T) {
  126. wf := makeBug2Workflow()
  127. eng, err := workflow.NewEngine(wf)
  128. if err != nil {
  129. t.Fatalf("NewEngine: %v", err)
  130. }
  131. result, err := eng.Execute(context.Background(), nil, createTestAdapters())
  132. if err != nil {
  133. t.Fatalf("Execute: %v", err)
  134. }
  135. var events []workflow.RunEvent
  136. for ev := range result.RunEventStream {
  137. events = append(events, ev)
  138. }
  139. // Count workflow_done events — must be exactly 1
  140. doneCount := 0
  141. var doneStopID string
  142. for _, ev := range events {
  143. if ev.Type == workflow.RunEventWorkflowDone {
  144. doneCount++
  145. if sid, ok := ev.Payload["stop_id"].(string); ok {
  146. doneStopID = sid
  147. }
  148. }
  149. }
  150. if doneCount != 1 {
  151. t.Errorf("expected exactly 1 workflow_done event, got %d", doneCount)
  152. for _, ev := range events {
  153. t.Logf(" seq=%d type=%s step_id=%v payload=%v", ev.Seq, ev.Type, ev.StepID, ev.Payload)
  154. }
  155. }
  156. if doneStopID != "Stop_A" {
  157. t.Errorf("expected workflow_done stop_id=Stop_A, got %q", doneStopID)
  158. }
  159. // Stop_C must never have started
  160. for _, ev := range events {
  161. if ev.Type == workflow.RunEventStepStart && ev.StepID != nil && *ev.StepID == "Stop_C" {
  162. t.Errorf("Stop_C was started — it should have been suppressed after Stop_A fired")
  163. }
  164. }
  165. }