package workflow_test import ( "context" "testing" "workflow" ) // makeV313LLMFileWorkflow returns a v3.13 workflow where an LLM step writes files via out mapping. func makeV313LLMFileWorkflow(filePaths []string) *workflow.Workflow { out := workflow.StepOutput{} for _, p := range filePaths { out[p] = "=_result" } return &workflow.Workflow{ Version: "3.13", Name: "V313 File Event Test", Registry: workflow.Registry{ Services: []string{}, Components: []string{}, Vars: []string{}, Files: workflow.FilesRegistry{ Artifacts: []string{"/src/*"}, }, }, Steps: []workflow.Step{ { ID: "LLM_GenCode", In: workflow.StepInput{ "messages": []interface{}{ map[string]interface{}{"role": "user", "content": "generate"}, }, }, Out: out, Next: "Stop_End", }, {ID: "Stop_End"}, }, } } // makeV313WriteStepWorkflow returns a v3.13 workflow with a Write_* step. func makeV313WriteStepWorkflow() *workflow.Workflow { return &workflow.Workflow{ Version: "3.13", Name: "V313 Write Step Test", Registry: workflow.Registry{ Services: []string{}, Components: []string{}, Vars: []string{}, Files: workflow.FilesRegistry{ Artifacts: []string{"/src/*"}, }, }, Steps: []workflow.Step{ { ID: "Write_Output", Target: "/src/output.txt", Value: "hello world", Next: "Stop_End", }, {ID: "Stop_End"}, }, } } // --------------------------------------------------------------------------- // file_start ordering tests (spec 3.13 §13.3) // --------------------------------------------------------------------------- // TestFileStartEmittedAfterStepStart verifies that file_start events are emitted // after step_start and before any llm_token, for an LLM step with file out targets. func TestFileStartEmittedAfterStepStart(t *testing.T) { wf := makeV313LLMFileWorkflow([]string{"/src/Header.tsx"}) adapters := createTestAdapters() llm := adapters.LLM.(*workflow.DefaultLLMAdapter) llm.SetHandler(func(ctx context.Context, params map[string]interface{}, stream chan<- string) (map[string]interface{}, error) { return map[string]interface{}{ "content": "export default function Header() {}", "model": "gpt-4", "finish_reason": "stop", }, nil }) engine, err := workflow.NewEngine(wf) if err != nil { t.Fatalf("NewEngine: %v", err) } result, err := engine.Execute(context.Background(), map[string]interface{}{}, adapters) if err != nil { t.Fatalf("Execute: %v", err) } var runEvents []workflow.RunEvent for ev := range result.RunEventStream { runEvents = append(runEvents, ev) } // Find indices for LLM_GenCode idxStepStart, idxFileStart, idxStepDone := -1, -1, -1 for i, ev := range runEvents { if ev.StepID != nil && *ev.StepID == "LLM_GenCode" { switch ev.Type { case workflow.RunEventStepStart: idxStepStart = i case workflow.RunEventFileStart: if idxFileStart == -1 { idxFileStart = i } case workflow.RunEventStepDone: idxStepDone = i } } } if idxStepStart == -1 { t.Fatal("missing step_start for LLM_GenCode") } if idxFileStart == -1 { t.Fatal("missing file_start for LLM_GenCode") } if idxStepDone == -1 { t.Fatal("missing step_done for LLM_GenCode") } // Order: step_start < file_start < step_done if !(idxStepStart < idxFileStart && idxFileStart < idxStepDone) { t.Errorf("event order wrong: step_start=%d, file_start=%d, step_done=%d (want start < file_start < done)", idxStepStart, idxFileStart, idxStepDone) } } // TestFileStartBeforeLLMToken verifies that file_start events precede any llm_token events. func TestFileStartBeforeLLMToken(t *testing.T) { wf := makeV313LLMFileWorkflow([]string{"/src/Header.tsx"}) // Enable streaming wf.Steps[0].In["stream"] = true adapters := createTestAdapters() llm := adapters.LLM.(*workflow.DefaultLLMAdapter) llm.SetHandler(func(ctx context.Context, params map[string]interface{}, stream chan<- string) (map[string]interface{}, error) { stream <- "chunk1" stream <- "chunk2" return map[string]interface{}{ "content": "chunk1chunk2", "model": "gpt-4", "finish_reason": "stop", }, nil }) engine, err := workflow.NewEngine(wf) if err != nil { t.Fatalf("NewEngine: %v", err) } result, err := engine.Execute(context.Background(), map[string]interface{}{}, adapters) if err != nil { t.Fatalf("Execute: %v", err) } var runEvents []workflow.RunEvent for ev := range result.RunEventStream { runEvents = append(runEvents, ev) } // Find first llm_token and last file_start for LLM_GenCode firstTokenIdx, lastFileStartIdx := -1, -1 for i, ev := range runEvents { if ev.StepID == nil || *ev.StepID != "LLM_GenCode" { continue } if ev.Type == workflow.RunEventLLMToken && firstTokenIdx == -1 { firstTokenIdx = i } if ev.Type == workflow.RunEventFileStart { lastFileStartIdx = i } } if lastFileStartIdx == -1 { t.Fatal("missing file_start for LLM_GenCode") } if firstTokenIdx == -1 { t.Fatal("missing llm_token for LLM_GenCode (streaming)") } // file_start must come before any llm_token if lastFileStartIdx >= firstTokenIdx { t.Errorf("file_start (idx=%d) must precede first llm_token (idx=%d)", lastFileStartIdx, firstTokenIdx) } } // TestFileStartPayloadPathOnly verifies that file_start payload contains only 'path'. func TestFileStartPayloadPathOnly(t *testing.T) { wf := makeV313LLMFileWorkflow([]string{"/src/Header.tsx"}) adapters := createTestAdapters() llm := adapters.LLM.(*workflow.DefaultLLMAdapter) llm.SetHandler(func(ctx context.Context, params map[string]interface{}, stream chan<- string) (map[string]interface{}, error) { return map[string]interface{}{ "content": "code", "model": "gpt-4", "finish_reason": "stop", }, nil }) engine, err := workflow.NewEngine(wf) if err != nil { t.Fatalf("NewEngine: %v", err) } result, err := engine.Execute(context.Background(), map[string]interface{}{}, adapters) if err != nil { t.Fatalf("Execute: %v", err) } var runEvents []workflow.RunEvent for ev := range result.RunEventStream { runEvents = append(runEvents, ev) } for _, ev := range runEvents { if ev.Type != workflow.RunEventFileStart { continue } path, ok := ev.Payload["path"].(string) if !ok || path == "" { t.Errorf("file_start payload missing or empty 'path': %v", ev.Payload) } // path should not have a leading slash if len(path) > 0 && path[0] == '/' { t.Errorf("file_start path should not have leading slash: %q", path) } // Should only have 'path' field (spec says payload carries only path) if len(ev.Payload) != 1 { t.Errorf("file_start payload should contain only 'path', got: %v", ev.Payload) } } } // --------------------------------------------------------------------------- // file_done ordering and payload tests (spec 3.13 §13.3) // --------------------------------------------------------------------------- // TestFileDoneAfterLLMDoneBeforeStepDone verifies file_done ordering for LLM steps. func TestFileDoneAfterLLMDoneBeforeStepDone(t *testing.T) { wf := makeV313LLMFileWorkflow([]string{"/src/Header.tsx"}) adapters := createTestAdapters() llm := adapters.LLM.(*workflow.DefaultLLMAdapter) llm.SetHandler(func(ctx context.Context, params map[string]interface{}, stream chan<- string) (map[string]interface{}, error) { return map[string]interface{}{ "content": "export default function Header() {}", "model": "gpt-4", "finish_reason": "stop", }, nil }) engine, err := workflow.NewEngine(wf) if err != nil { t.Fatalf("NewEngine: %v", err) } result, err := engine.Execute(context.Background(), map[string]interface{}{}, adapters) if err != nil { t.Fatalf("Execute: %v", err) } var runEvents []workflow.RunEvent for ev := range result.RunEventStream { runEvents = append(runEvents, ev) } idxLLMDone, idxFileDone, idxStepDone := -1, -1, -1 for i, ev := range runEvents { if ev.StepID == nil || *ev.StepID != "LLM_GenCode" { continue } switch ev.Type { case workflow.RunEventLLMDone: idxLLMDone = i case workflow.RunEventFileDone: if idxFileDone == -1 { idxFileDone = i } case workflow.RunEventStepDone: idxStepDone = i } } if idxLLMDone == -1 { t.Fatal("missing llm_done for LLM_GenCode") } if idxFileDone == -1 { t.Fatal("missing file_done for LLM_GenCode") } if idxStepDone == -1 { t.Fatal("missing step_done for LLM_GenCode") } // Order: llm_done < file_done < step_done if !(idxLLMDone < idxFileDone && idxFileDone < idxStepDone) { t.Errorf("event order wrong: llm_done=%d, file_done=%d, step_done=%d (want llm_done < file_done < step_done)", idxLLMDone, idxFileDone, idxStepDone) } } // TestFileDonePayload verifies that file_done payload contains 'path' and 'size_bytes'. func TestFileDonePayload(t *testing.T) { const fileContent = "export default function Header() { return null; }" wf := makeV313LLMFileWorkflow([]string{"/src/Header.tsx"}) adapters := createTestAdapters() llm := adapters.LLM.(*workflow.DefaultLLMAdapter) llm.SetHandler(func(ctx context.Context, params map[string]interface{}, stream chan<- string) (map[string]interface{}, error) { return map[string]interface{}{ "content": fileContent, "model": "gpt-4", "finish_reason": "stop", }, nil }) engine, err := workflow.NewEngine(wf) if err != nil { t.Fatalf("NewEngine: %v", err) } result, err := engine.Execute(context.Background(), map[string]interface{}{}, adapters) if err != nil { t.Fatalf("Execute: %v", err) } var runEvents []workflow.RunEvent for ev := range result.RunEventStream { runEvents = append(runEvents, ev) } found := false for _, ev := range runEvents { if ev.Type != workflow.RunEventFileDone { continue } found = true path, ok := ev.Payload["path"].(string) if !ok || path == "" { t.Errorf("file_done payload missing 'path': %v", ev.Payload) } if len(path) > 0 && path[0] == '/' { t.Errorf("file_done path should not have leading slash: %q", path) } sizeBytes, ok := ev.Payload["size_bytes"] if !ok { t.Errorf("file_done payload missing 'size_bytes': %v", ev.Payload) } else { size, ok := sizeBytes.(int) if !ok { t.Errorf("file_done size_bytes should be int, got %T: %v", sizeBytes, sizeBytes) } else if size != len([]byte(fileContent)) { t.Errorf("file_done size_bytes: got %d, want %d", size, len([]byte(fileContent))) } } } if !found { t.Error("expected at least one file_done RunEvent, none found") } } // TestMultipleFilesFileStartAndDone verifies that multiple files all get // file_start and file_done events. func TestMultipleFilesFileStartAndDone(t *testing.T) { wf := makeV313LLMFileWorkflow([]string{"/src/Header.tsx", "/src/Header.module.css"}) adapters := createTestAdapters() llm := adapters.LLM.(*workflow.DefaultLLMAdapter) llm.SetHandler(func(ctx context.Context, params map[string]interface{}, stream chan<- string) (map[string]interface{}, error) { return map[string]interface{}{ "content": "/* styles */", "model": "gpt-4", "finish_reason": "stop", }, nil }) engine, err := workflow.NewEngine(wf) if err != nil { t.Fatalf("NewEngine: %v", err) } result, err := engine.Execute(context.Background(), map[string]interface{}{}, adapters) if err != nil { t.Fatalf("Execute: %v", err) } var runEvents []workflow.RunEvent for ev := range result.RunEventStream { runEvents = append(runEvents, ev) } fileStartCount, fileDoneCount := 0, 0 for _, ev := range runEvents { if ev.StepID == nil || *ev.StepID != "LLM_GenCode" { continue } if ev.Type == workflow.RunEventFileStart { fileStartCount++ } if ev.Type == workflow.RunEventFileDone { fileDoneCount++ } } if fileStartCount != 2 { t.Errorf("expected 2 file_start events for 2 files, got %d", fileStartCount) } if fileDoneCount != 2 { t.Errorf("expected 2 file_done events for 2 files, got %d", fileDoneCount) } // All file_start events must precede all file_done events lastFileStartIdx, firstFileDoneIdx := -1, -1 for i, ev := range runEvents { if ev.StepID == nil || *ev.StepID != "LLM_GenCode" { continue } if ev.Type == workflow.RunEventFileStart { lastFileStartIdx = i } if ev.Type == workflow.RunEventFileDone && firstFileDoneIdx == -1 { firstFileDoneIdx = i } } if lastFileStartIdx == -1 || firstFileDoneIdx == -1 { t.Fatal("missing file_start or file_done events") } if lastFileStartIdx >= firstFileDoneIdx { t.Errorf("all file_start (last=%d) must precede first file_done (idx=%d)", lastFileStartIdx, firstFileDoneIdx) } } // --------------------------------------------------------------------------- // Write_* step file events (spec 3.13 §13.3) // --------------------------------------------------------------------------- // TestWriteStepFileEvents verifies that Write_* steps emit file_start and file_done. func TestWriteStepFileEvents(t *testing.T) { wf := makeV313WriteStepWorkflow() adapters := createTestAdapters() engine, err := workflow.NewEngine(wf) if err != nil { t.Fatalf("NewEngine: %v", err) } result, err := engine.Execute(context.Background(), map[string]interface{}{}, adapters) if err != nil { t.Fatalf("Execute: %v", err) } var runEvents []workflow.RunEvent for ev := range result.RunEventStream { runEvents = append(runEvents, ev) } // Find file_start and file_done for Write_Output var fileStartEvs, fileDoneEvs []workflow.RunEvent idxStepStart, idxStepDone := -1, -1 for i, ev := range runEvents { if ev.StepID == nil || *ev.StepID != "Write_Output" { continue } switch ev.Type { case workflow.RunEventStepStart: idxStepStart = i case workflow.RunEventFileStart: fileStartEvs = append(fileStartEvs, ev) case workflow.RunEventFileDone: fileDoneEvs = append(fileDoneEvs, ev) case workflow.RunEventStepDone: idxStepDone = i } } if len(fileStartEvs) == 0 { t.Fatal("expected file_start for Write_Output, none found") } if len(fileDoneEvs) == 0 { t.Fatal("expected file_done for Write_Output, none found") } // file_start must come after step_start for _, ev := range fileStartEvs { if idxStepStart == -1 || ev.Seq <= runEvents[idxStepStart].Seq { t.Errorf("file_start seq=%d must be after step_start seq=%d", ev.Seq, runEvents[idxStepStart].Seq) } } // file_done must come before step_done for _, ev := range fileDoneEvs { if idxStepDone == -1 || ev.Seq >= runEvents[idxStepDone].Seq { t.Errorf("file_done seq=%d must be before step_done seq=%d", ev.Seq, runEvents[idxStepDone].Seq) } } // Verify file_done has path and size_bytes for _, ev := range fileDoneEvs { if _, ok := ev.Payload["path"]; !ok { t.Errorf("file_done payload missing 'path': %v", ev.Payload) } if _, ok := ev.Payload["size_bytes"]; !ok { t.Errorf("file_done payload missing 'size_bytes': %v", ev.Payload) } } } // TestNoFileEventsWhenNoFilesWritten verifies that no file_start/file_done events // are emitted for steps that don't write files. func TestNoFileEventsWhenNoFilesWritten(t *testing.T) { wf := &workflow.Workflow{ Version: "3.13", Name: "No File Events Test", Registry: workflow.Registry{ Services: []string{}, Components: []string{}, Vars: []string{"$answer(STRING)"}, Files: workflow.FilesRegistry{}, }, Steps: []workflow.Step{ { ID: "LLM_Answer", In: workflow.StepInput{ "messages": []interface{}{ map[string]interface{}{"role": "user", "content": "hi"}, }, }, Out: workflow.StepOutput{"$answer": "=_result"}, Next: "Stop_End", }, {ID: "Stop_End"}, }, } adapters := createTestAdapters() llm := adapters.LLM.(*workflow.DefaultLLMAdapter) llm.SetHandler(func(ctx context.Context, params map[string]interface{}, stream chan<- string) (map[string]interface{}, error) { return map[string]interface{}{"content": "answer", "model": "gpt-4", "finish_reason": "stop"}, nil }) engine, err := workflow.NewEngine(wf) if err != nil { t.Fatalf("NewEngine: %v", err) } result, err := engine.Execute(context.Background(), map[string]interface{}{}, adapters) if err != nil { t.Fatalf("Execute: %v", err) } for ev := range result.RunEventStream { if ev.Type == workflow.RunEventFileStart || ev.Type == workflow.RunEventFileDone { t.Errorf("unexpected %q RunEvent for step with no file outputs", ev.Type) } } } // --------------------------------------------------------------------------- // print / step_print tests (spec 3.13 §5.2.12) // --------------------------------------------------------------------------- // TestStepPrintLiteralMessage verifies that a literal print value emits step_print // with the correct message between file_done and step_done. func TestStepPrintLiteralMessage(t *testing.T) { wf := &workflow.Workflow{ Version: "3.13", Name: "Print Literal Test", Registry: workflow.Registry{ Vars: []string{"$answer(STRING)"}, Files: workflow.FilesRegistry{}, }, Steps: []workflow.Step{ { ID: "LLM_Answer", In: workflow.StepInput{ "messages": []interface{}{ map[string]interface{}{"role": "user", "content": "hi"}, }, }, Out: workflow.StepOutput{"$answer": "=_result"}, Print: "step done", Next: "Stop_End", }, {ID: "Stop_End"}, }, } adapters := createTestAdapters() adapters.LLM.(*workflow.DefaultLLMAdapter).SetHandler(func(ctx context.Context, params map[string]interface{}, stream chan<- string) (map[string]interface{}, error) { return map[string]interface{}{"content": "hello", "model": "gpt-4", "finish_reason": "stop"}, nil }) engine, err := workflow.NewEngine(wf) if err != nil { t.Fatalf("NewEngine: %v", err) } result, err := engine.Execute(context.Background(), map[string]interface{}{}, adapters) if err != nil { t.Fatalf("Execute: %v", err) } var runEvents []workflow.RunEvent for ev := range result.RunEventStream { runEvents = append(runEvents, ev) } // Find step_print event for LLM_Answer var printEv *workflow.RunEvent for i := range runEvents { if runEvents[i].Type == workflow.RunEventStepPrint && runEvents[i].StepID != nil && *runEvents[i].StepID == "LLM_Answer" { printEv = &runEvents[i] break } } if printEv == nil { t.Fatal("expected step_print RunEvent for LLM_Answer, none found") } if printEv.Payload["message"] != "step done" { t.Errorf("step_print message: got %v, want 'step done'", printEv.Payload["message"]) } } // TestStepPrintExpressionWithVariable verifies that print evaluates expressions // and can reference global variables set via out mapping. func TestStepPrintExpressionWithVariable(t *testing.T) { wf := &workflow.Workflow{ Version: "3.13", Name: "Print Expression Test", Registry: workflow.Registry{ Vars: []string{"$answer(STRING)"}, Files: workflow.FilesRegistry{}, }, Steps: []workflow.Step{ { ID: "LLM_Answer", In: workflow.StepInput{ "messages": []interface{}{ map[string]interface{}{"role": "user", "content": "hi"}, }, }, Out: workflow.StepOutput{"$answer": "=_result"}, Print: "=\"Got: \" + $answer", Next: "Stop_End", }, {ID: "Stop_End"}, }, } adapters := createTestAdapters() adapters.LLM.(*workflow.DefaultLLMAdapter).SetHandler(func(ctx context.Context, params map[string]interface{}, stream chan<- string) (map[string]interface{}, error) { return map[string]interface{}{"content": "world", "model": "gpt-4", "finish_reason": "stop"}, nil }) engine, err := workflow.NewEngine(wf) if err != nil { t.Fatalf("NewEngine: %v", err) } result, err := engine.Execute(context.Background(), map[string]interface{}{}, adapters) if err != nil { t.Fatalf("Execute: %v", err) } var runEvents []workflow.RunEvent for ev := range result.RunEventStream { runEvents = append(runEvents, ev) } var printEv *workflow.RunEvent for i := range runEvents { if runEvents[i].Type == workflow.RunEventStepPrint && runEvents[i].StepID != nil && *runEvents[i].StepID == "LLM_Answer" { printEv = &runEvents[i] break } } if printEv == nil { t.Fatal("expected step_print RunEvent, none found") } if printEv.Payload["message"] != "Got: world" { t.Errorf("step_print message: got %v, want 'Got: world'", printEv.Payload["message"]) } } // TestStepPrintOrderingBeforeStepDone verifies order: step_print precedes step_done. func TestStepPrintOrderingBeforeStepDone(t *testing.T) { wf := &workflow.Workflow{ Version: "3.13", Name: "Print Order Test", Registry: workflow.Registry{ Vars: []string{"$answer(STRING)"}, Files: workflow.FilesRegistry{}, }, Steps: []workflow.Step{ { ID: "LLM_Answer", In: workflow.StepInput{ "messages": []interface{}{ map[string]interface{}{"role": "user", "content": "hi"}, }, }, Out: workflow.StepOutput{"$answer": "=_result"}, Print: "done", Next: "Stop_End", }, {ID: "Stop_End"}, }, } adapters := createTestAdapters() adapters.LLM.(*workflow.DefaultLLMAdapter).SetHandler(func(ctx context.Context, params map[string]interface{}, stream chan<- string) (map[string]interface{}, error) { return map[string]interface{}{"content": "ok", "model": "gpt-4", "finish_reason": "stop"}, nil }) engine, err := workflow.NewEngine(wf) if err != nil { t.Fatalf("NewEngine: %v", err) } result, err := engine.Execute(context.Background(), map[string]interface{}{}, adapters) if err != nil { t.Fatalf("Execute: %v", err) } var runEvents []workflow.RunEvent for ev := range result.RunEventStream { runEvents = append(runEvents, ev) } idxPrint, idxDone := -1, -1 for i, ev := range runEvents { if ev.StepID == nil || *ev.StepID != "LLM_Answer" { continue } if ev.Type == workflow.RunEventStepPrint { idxPrint = i } if ev.Type == workflow.RunEventStepDone { idxDone = i } } if idxPrint == -1 { t.Fatal("missing step_print for LLM_Answer") } if idxDone == -1 { t.Fatal("missing step_done for LLM_Answer") } if idxPrint >= idxDone { t.Errorf("step_print (idx=%d) must precede step_done (idx=%d)", idxPrint, idxDone) } } // TestStepPrintNotEmittedWhenSkipped verifies step_print is not emitted when if=false. func TestStepPrintNotEmittedWhenSkipped(t *testing.T) { wf := &workflow.Workflow{ Version: "3.13", Name: "Print Skip Test", Registry: workflow.Registry{ Vars: []string{"$flag(BOOLEAN)"}, Files: workflow.FilesRegistry{}, }, Steps: []workflow.Step{ { ID: "LLM_Skipped", If: "=$flag", In: workflow.StepInput{"messages": []interface{}{map[string]interface{}{"role": "user", "content": "hi"}}}, Print: "should not appear", Next: "Stop_End", }, {ID: "Stop_End"}, }, } adapters := createTestAdapters() engine, err := workflow.NewEngine(wf) if err != nil { t.Fatalf("NewEngine: %v", err) } result, err := engine.Execute(context.Background(), map[string]interface{}{"$flag": false}, adapters) if err != nil { t.Fatalf("Execute: %v", err) } for ev := range result.RunEventStream { if ev.Type == workflow.RunEventStepPrint { t.Errorf("unexpected step_print for skipped step: %v", ev.Payload) } } } // TestStepPrintNotEmittedWhenFailed verifies step_print is not emitted when the step errors. func TestStepPrintNotEmittedWhenFailed(t *testing.T) { wf := &workflow.Workflow{ Version: "3.13", Name: "Print Fail Test", Registry: workflow.Registry{ Vars: []string{"$answer(STRING)"}, Files: workflow.FilesRegistry{}, }, Steps: []workflow.Step{ { ID: "LLM_Fail", In: workflow.StepInput{"messages": []interface{}{map[string]interface{}{"role": "user", "content": "hi"}}}, Out: workflow.StepOutput{"$answer": "=_result"}, Print: "should not appear", Next: "Stop_End", }, {ID: "Stop_End"}, }, } adapters := createTestAdapters() adapters.LLM.(*workflow.DefaultLLMAdapter).SetHandler(func(ctx context.Context, params map[string]interface{}, stream chan<- string) (map[string]interface{}, error) { return nil, &workflow.LLMError{Type: "rate_limit", Message: "limit", Retryable: true} }) engine, err := workflow.NewEngine(wf) if err != nil { t.Fatalf("NewEngine: %v", err) } result, err := engine.Execute(context.Background(), map[string]interface{}{}, adapters) if err != nil { t.Fatalf("Execute: %v", err) } for ev := range result.RunEventStream { if ev.Type == workflow.RunEventStepPrint { t.Errorf("unexpected step_print for failed step: %v", ev.Payload) } } } // TestStepPrintAfterFileDone verifies order when step has both file outputs and print. func TestStepPrintAfterFileDone(t *testing.T) { wf := makeV313LLMFileWorkflow([]string{"/src/Header.tsx"}) wf.Steps[0].Print = "files written" adapters := createTestAdapters() adapters.LLM.(*workflow.DefaultLLMAdapter).SetHandler(func(ctx context.Context, params map[string]interface{}, stream chan<- string) (map[string]interface{}, error) { return map[string]interface{}{"content": "code", "model": "gpt-4", "finish_reason": "stop"}, nil }) engine, err := workflow.NewEngine(wf) if err != nil { t.Fatalf("NewEngine: %v", err) } result, err := engine.Execute(context.Background(), map[string]interface{}{}, adapters) if err != nil { t.Fatalf("Execute: %v", err) } var runEvents []workflow.RunEvent for ev := range result.RunEventStream { runEvents = append(runEvents, ev) } idxFileDone, idxPrint, idxStepDone := -1, -1, -1 for i, ev := range runEvents { if ev.StepID == nil || *ev.StepID != "LLM_GenCode" { continue } switch ev.Type { case workflow.RunEventFileDone: idxFileDone = i case workflow.RunEventStepPrint: idxPrint = i case workflow.RunEventStepDone: idxStepDone = i } } if idxFileDone == -1 { t.Fatal("missing file_done") } if idxPrint == -1 { t.Fatal("missing step_print") } if idxStepDone == -1 { t.Fatal("missing step_done") } // Order: file_done < step_print < step_done if !(idxFileDone < idxPrint && idxPrint < idxStepDone) { t.Errorf("order wrong: file_done=%d, step_print=%d, step_done=%d (want file_done < step_print < step_done)", idxFileDone, idxPrint, idxStepDone) } } // TestNoPrintOnStopStep verifies that Stop_* steps ignore print (no step_print emitted). func TestNoPrintOnStopStep(t *testing.T) { // Stop_* steps can't have print per spec; even if set, the engine should not emit step_print // (the engine guards with stepType != StepTypeStop) // We test this indirectly: no step_print should appear for any Stop_* step. wf := makeV313LLMFileWorkflow([]string{"/src/test.tsx"}) adapters := createTestAdapters() adapters.LLM.(*workflow.DefaultLLMAdapter).SetHandler(func(ctx context.Context, params map[string]interface{}, stream chan<- string) (map[string]interface{}, error) { return map[string]interface{}{"content": "x", "model": "gpt-4", "finish_reason": "stop"}, nil }) engine, err := workflow.NewEngine(wf) if err != nil { t.Fatalf("NewEngine: %v", err) } result, err := engine.Execute(context.Background(), map[string]interface{}{}, adapters) if err != nil { t.Fatalf("Execute: %v", err) } for ev := range result.RunEventStream { if ev.Type == workflow.RunEventStepPrint && ev.StepID != nil && *ev.StepID == "Stop_End" { t.Errorf("unexpected step_print for Stop_End step") } } }