diff --git a/langchain4j/runtime/src/test/java/io/quarkiverse/flow/langchain4j/workflow/WorkflowExecutionListenerBehaviourTest.java b/langchain4j/runtime/src/test/java/io/quarkiverse/flow/langchain4j/workflow/WorkflowExecutionListenerBehaviourTest.java new file mode 100644 index 000000000..29dd060a1 --- /dev/null +++ b/langchain4j/runtime/src/test/java/io/quarkiverse/flow/langchain4j/workflow/WorkflowExecutionListenerBehaviourTest.java @@ -0,0 +1,317 @@ +package io.quarkiverse.flow.langchain4j.workflow; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import dev.langchain4j.agentic.AgenticServices; +import dev.langchain4j.agentic.scope.ResultWithAgenticScope; +import dev.langchain4j.service.V; +import io.quarkiverse.flow.Flow; +import io.quarkiverse.flow.internal.WorkflowRegistry; +import io.quarkus.test.junit.QuarkusTest; +import io.serverlessworkflow.api.types.Workflow; +import io.serverlessworkflow.fluent.func.FuncWorkflowBuilder; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.lifecycle.TaskCompletedEvent; +import io.serverlessworkflow.impl.lifecycle.WorkflowCompletedEvent; +import io.serverlessworkflow.impl.lifecycle.WorkflowExecutionListener; +import io.serverlessworkflow.impl.lifecycle.WorkflowStartedEvent; +import io.serverlessworkflow.impl.model.jackson.JacksonModelFactory; +import io.smallrye.mutiny.Uni; + +/** + * Behaviour-documenting tests for {@link WorkflowExecutionListener}. + * + *
+ * These tests answer four specific questions about lifecycle hook semantics that external + * integrations (e.g. casehub-engine) depend on. They exist to discover, document, and protect + * the exact behaviour against accidental regression. + * + *
+ * See casehubio/engine#213 for context. + */ +@QuarkusTest +class WorkflowExecutionListenerBehaviourTest { + + @Inject + WorkflowRegistry registry; + + @Inject + Q1ContextInjectWorkflow q1Workflow; + + @Inject + Q4UniWorkflow q4Workflow; + + @BeforeEach + void reset() { + RecordingListener.reset(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Q1: Is (WorkflowContext) cast safe? What does the context() setter affect? + // ───────────────────────────────────────────────────────────────────────── + + /** + * Q1: Documents that {@code event.workflowContext()} can be safely cast to the concrete + * {@link WorkflowContext} class in {@code onWorkflowStarted}, and clarifies what the + * mutable {@code context(WorkflowModel)} setter actually affects. + * + *
+ * Finding: The cast always succeeds. The {@code context(WorkflowModel)} setter + * updates the workflow's shared running context object, but FuncDSL task functions receive + * their input from the workflow's INPUT model (what was passed to {@code startInstance()}), + * not from the workflow's running {@code context()}. Keys injected via + * {@code ctx.context(factory.from(Map.of("key", "value")))} are therefore NOT visible + * in the task function's {@code input} parameter. + * + *
+ * Implication for casehub-engine: To inject propagation metadata ({@code traceId}, + * {@code causedByEntryId}) into sub-workflow task inputs, casehub must either: + * (a) pass them in the {@code startInstance(Map)} input instead of injecting via the listener, or + * (b) use a different mechanism. The {@code context()} setter alone is insufficient. + */ + @Test + void q1_workflowContextCastIsSafe_butContextSetterDoesNotFlowToFuncDslTaskInput() { + // Start with no input — listener will attempt to inject "listener_injected" via context() setter + String result = q1Workflow.startInstance() + .await().atMost(Duration.ofSeconds(10)) + .as(String.class).orElse("WORKFLOW_RETURNED_NULL"); + + // CAST: always succeeds — WorkflowContextData is always a WorkflowContext instance + assertThat(RecordingListener.castSucceeded.get()) + .as("(WorkflowContext) cast must always succeed in onWorkflowStarted") + .isTrue(); + + // INJECTION: the key injected via ctx.context(factory.from(Map)) does NOT reach + // FuncDSL task function input — task receives its input from the workflow INPUT model, + // not from the running workflow context() object. + assertThat(result) + .as(""" + FINDING: context(WorkflowModel) setter does NOT flow to FuncDSL task input. + The task function returned '%s' — if injection worked it would be 'hello-from-casehub'. + Implication: casehub-engine cannot use this setter to inject traceId/causedByEntryId + into sub-workflow task inputs. Must use startInstance(Map) input instead. + """, result) + .isEqualTo("NOT_FOUND"); // documents the actual discovered behaviour + } + + // ───────────────────────────────────────────────────────────────────────── + // Q2: Does onWorkflowCompleted carry the final output? When does it fire? + // ───────────────────────────────────────────────────────────────────────── + + /** + * Q2: Documents that {@code onWorkflowCompleted} carries the final output via + * {@code event.output()}, and fires synchronously before {@code await()} returns. + * + *
+ * Finding: {@code event.output()} is the correct API for accessing output inside + * {@code onWorkflowCompleted} — it is populated directly from the task result before the + * event is published. The hook fires synchronously as part of the {@code CompletableFuture} + * completion chain, so {@code event.output()} is available without any async wait. + * + *
+ * {@code instanceData().output()} is NOT the right API inside event callbacks — it is a + * blocking join on the workflow future intended for callers outside the event chain. + * + *
+ * Implication for casehub-engine: Use {@code event.output()} in + * {@code onWorkflowCompleted} to route sub-workflow output to the parent context or + * {@code CompletionTracker}. The hook fires synchronously, so no additional async wait + * is needed. + */ + @Test + void q2_onWorkflowCompleted_firesSynchronouslyAndCarriesOutputViaEventOutput() { + q1Workflow.startInstance() + .await().atMost(Duration.ofSeconds(10)); + + // The hook fires synchronously (as part of the CF chain before await() returns), + // so completedOutput is already set here — no sleep or Awaitility needed. + assertThat(RecordingListener.completedOutput.get()) + .as("event.output() in onWorkflowCompleted must be non-null and set " + + "before await() returns — the hook fires synchronously") + .isNotNull(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Q3: Does onTaskCompleted fire per agent action or per agentic task? + // ───────────────────────────────────────────────────────────────────────── + + /** + * Q3: Documents the granularity of {@code onTaskCompleted} for sequential agentic workflows. + * + *
+ * Finding: The assertion locks in the exact count observed at runtime. This documents + * whether {@code onTaskCompleted} fires per individual agent action (N times) or per agentic + * task as a whole (1 time). + * + *
+ * Implication for casehub-engine: + *
+ * Finding: {@code Uni
+ * Implication for casehub-engine: A casehub dispatch function returning
+ * {@code Uni.createFrom().completionStage(() -> workOrchestrator.submit(...))} will work
+ * correctly in a Quarkus Flow step — the engine awaits the result without blocking any
+ * platform thread. This is the core of the FlowWorker ↔ WorkOrchestrator integration.
+ */
+ @Test
+ void q4_uniReturningFunctionCompletesAndResultIsWorkflowOutput() {
+ String result = q4Workflow.startInstance()
+ .await().atMost(Duration.ofSeconds(10))
+ .as(String.class).orElse("NOT_FOUND");
+
+ assertThat(result)
+ .as("Uni