"""End-to-end tests for the pipeline engine. These tests exercise realistic multi-step scenarios — no ACE imports. Dummy steps simulate the Agent → Evaluate → Reflect → Update pattern using a test-local StepContext subclass, so the full plumbing is exercised. """ from __future__ import annotations import asyncio import threading import time from dataclasses import dataclass from types import MappingProxyType from typing import Any import pytest from pipeline import ( Branch, MergeStrategy, Pipeline, SampleResult, StepContext, ) # --------------------------------------------------------------------------- # Test-local context subclass (simulates ACE-style domain fields) # --------------------------------------------------------------------------- @dataclass(frozen=True) class E2EContext(StepContext): """Domain context with named fields for the Agent → Evaluate → Reflect → Update pattern.""" agent_output: Any = None environment_result: Any = None reflection: Any = None skill_manager_output: Any = None # --------------------------------------------------------------------------- # Domain-agnostic dummy steps (ACE-shaped but no ACE imports) # --------------------------------------------------------------------------- class AgentStep: """Reads ctx.sample (a named field, not metadata), writes agent_output.""" requires = frozenset() provides = frozenset({"agent_output"}) def __call__(self, ctx: E2EContext) -> E2EContext: return ctx.replace(agent_output=f"answer_for_{ctx.sample}") class EvaluateStep: """Reads agent_output + environment from metadata, writes environment_result.""" requires = frozenset({"agent_output"}) provides = frozenset({"environment_result"}) def __call__(self, ctx: E2EContext) -> E2EContext: correct = ctx.agent_output == ctx.metadata.get("expected") return ctx.replace( environment_result={ "correct": correct, "feedback": "ok" if correct else "wrong", } ) class ReflectStep: """Background step: reads agent_output + environment_result, writes reflection.""" requires = frozenset({"agent_output", "environment_result"}) provides = frozenset({"reflection"}) async_boundary = True max_workers = 3 def __call__(self, ctx: E2EContext) -> E2EContext: time.sleep(0.01) # simulate LLM latency return ctx.replace( reflection={ "insight": "reflected", "correct": ctx.environment_result["correct"], } ) class UpdateStep: """Background step: reads reflection, writes skill_manager_output. Serialized.""" requires = frozenset({"reflection"}) provides = frozenset({"skill_manager_output"}) max_workers = 1 _updates: list = [] _lock = threading.Lock() def __call__(self, ctx: E2EContext) -> E2EContext: time.sleep(0.005) with self._lock: UpdateStep._updates.append(ctx.reflection) return ctx.replace(skill_manager_output={"updated": True}) class LogStep: """Side-effect step that records sample name (for Branch tests).""" requires = frozenset() provides = frozenset() def __init__(self): self.log: list[str] = [] self._lock = threading.Lock() def __call__(self, ctx: StepContext) -> StepContext: with self._lock: self.log.append(ctx.sample) return ctx.replace(metadata=MappingProxyType({**ctx.metadata, "logged": True})) class MetricStep: """Writes a metric to metadata (for Branch tests alongside ReflectStep).""" requires = frozenset() provides = frozenset() def __call__(self, ctx: StepContext) -> StepContext: return ctx.replace( metadata=MappingProxyType({**ctx.metadata, "metric": len(str(ctx.sample))}) ) # --------------------------------------------------------------------------- # E2E: full 4-step pipeline (no async_boundary first) # --------------------------------------------------------------------------- @pytest.mark.integration class TestFullPipelineChain: def _pipe(self) -> Pipeline: return Pipeline().then(AgentStep()).then(EvaluateStep()) def test_single_sample_correct_answer(self): sample_ctx_kwargs = {"metadata": {"expected": "answer_for_q1"}} ctx = E2EContext(sample="q1", **sample_ctx_kwargs) # Run via __call__ (nested mode) out = self._pipe()(ctx) assert out.agent_output == "answer_for_q1" assert out.environment_result["correct"] is True def test_multiple_samples_run(self): # Samples without expected = all "wrong" results = self._pipe().run([E2EContext(sample=s) for s in ("q1", "q2", "q3")]) assert len(results) == 3 assert all(r.error is None for r in results) assert all(r.output.agent_output.startswith("answer_for_") for r in results) def test_data_flows_correctly_through_chain(self): results = self._pipe().run([E2EContext(sample="hello")]) out = results[0].output assert out.sample == "hello" assert out.agent_output == "answer_for_hello" assert out.environment_result is not None assert "correct" in out.environment_result # --------------------------------------------------------------------------- # E2E: async_boundary — fire-and-forget pipeline # --------------------------------------------------------------------------- @pytest.mark.integration @pytest.mark.slow class TestAsyncBoundaryPipeline: def _pipe(self) -> Pipeline: UpdateStep._updates.clear() return ( Pipeline() .then(AgentStep()) .then(EvaluateStep()) .then(ReflectStep()) # async_boundary = True .then(UpdateStep()) ) def test_run_returns_before_background_finishes(self): pipe = self._pipe() t0 = time.monotonic() results = pipe.run([E2EContext(sample="q1")], workers=1) foreground_time = time.monotonic() - t0 # Should return quickly (foreground only: agent + evaluate) # Background runs Reflect (0.01s) + Update (0.005s) asynchronously assert len(results) == 1 pipe.wait_for_background(timeout=5.0) def test_all_steps_complete_after_wait(self): pipe = self._pipe() results = pipe.run([E2EContext(sample="q1"), E2EContext(sample="q2")]) pipe.wait_for_background(timeout=5.0) assert all(r.output is not None for r in results) assert all(r.output.skill_manager_output == {"updated": True} for r in results) def test_update_serialized_across_samples(self): """UpdateStep.max_workers=1 — updates must not interleave.""" UpdateStep._updates.clear() pipe = self._pipe() contexts = [E2EContext(sample=f"s{i}") for i in range(5)] pipe.run(contexts, workers=3) pipe.wait_for_background(timeout=10.0) # All 5 samples must have triggered an update assert len(UpdateStep._updates) == 5 def test_failed_foreground_not_sent_to_background(self): class FailEval: requires = frozenset({"agent_output"}) provides = frozenset({"environment_result"}) def __call__(self, ctx): raise RuntimeError("eval failed") UpdateStep._updates.clear() pipe = ( Pipeline() .then(AgentStep()) .then(FailEval()) .then(ReflectStep()) .then(UpdateStep()) ) results = pipe.run([E2EContext(sample="q1")]) pipe.wait_for_background(timeout=2.0) # Error in foreground → no background submission assert results[0].error is not None assert results[0].failed_at == "FailEval" assert len(UpdateStep._updates) == 0 # --------------------------------------------------------------------------- # E2E: Branch inside a pipeline # --------------------------------------------------------------------------- @pytest.mark.integration class TestBranchInPipeline: def test_branch_parallel_reflect_and_log(self): log_step = LogStep() pipe = ( Pipeline() .then(AgentStep()) .then(EvaluateStep()) .branch( Pipeline().then(MetricStep()), Pipeline().then(log_step), merge=MergeStrategy.RAISE_ON_CONFLICT, ) ) results = pipe.run([E2EContext(sample="hello"), E2EContext(sample="world")]) assert len(results) == 2 assert all(r.error is None for r in results) # Both branches ran assert all(r.output.metadata.get("metric") is not None for r in results) assert sorted(log_step.log) == ["hello", "world"] def test_step_after_branch_receives_merged_context(self): class Summarize: requires = frozenset() provides = frozenset({"summary"}) def __call__(self, ctx): return ctx.replace( metadata=MappingProxyType({**ctx.metadata, "summary": "done"}) ) pipe = ( Pipeline() .then(AgentStep()) .branch( Pipeline().then(MetricStep()), Pipeline().then(LogStep()), ) .then(Summarize()) ) results = pipe.run([E2EContext(sample="test")]) assert results[0].output.metadata.get("summary") == "done" def test_branch_failure_captured_in_sample_result(self): class BranchBoom: requires = frozenset() provides = frozenset() def __call__(self, ctx): raise RuntimeError("branch_fail") pipe = ( Pipeline() .then(AgentStep()) .branch( Pipeline().then(MetricStep()), Pipeline().then(BranchBoom()), ) ) results = pipe.run([E2EContext(sample="s")]) assert results[0].error is not None assert results[0].failed_at == "Branch" # --------------------------------------------------------------------------- # E2E: nested pipeline reuse # --------------------------------------------------------------------------- @pytest.mark.integration class TestNestedPipelineReuse: def test_inner_pipeline_reused_in_two_outer_pipelines(self): inner = Pipeline().then(AgentStep()).then(EvaluateStep()) outer_a = Pipeline().then(inner) outer_b = Pipeline().then(inner).then(MetricStep()) r_a = outer_a.run([E2EContext(sample="q1")]) r_b = outer_b.run([E2EContext(sample="q1")]) assert r_a[0].output.agent_output == "answer_for_q1" assert r_b[0].output.metadata.get("metric") is not None def test_deeply_nested_pipelines(self): level1 = Pipeline().then(AgentStep()) level2 = Pipeline().then(level1).then(EvaluateStep()) level3 = Pipeline().then(level2).then(MetricStep()) results = level3.run([E2EContext(sample="deep")]) out = results[0].output assert out.agent_output == "answer_for_deep" assert out.environment_result is not None assert out.metadata.get("metric") is not None # --------------------------------------------------------------------------- # E2E: multiple run() calls on same instance # --------------------------------------------------------------------------- @pytest.mark.integration class TestMultipleRunCalls: def test_two_run_calls_accumulate_background_work(self): UpdateStep._updates.clear() pipe = ( Pipeline() .then(AgentStep()) .then(EvaluateStep()) .then(ReflectStep()) .then(UpdateStep()) ) pipe.run([E2EContext(sample="a"), E2EContext(sample="b")]) pipe.run([E2EContext(sample="c"), E2EContext(sample="d")]) pipe.wait_for_background(timeout=10.0) assert len(UpdateStep._updates) == 4 def test_pipeline_state_not_contaminated_between_runs(self): pipe = Pipeline().then(AgentStep()).then(EvaluateStep()) r1 = pipe.run([E2EContext(sample="sample_x")]) r2 = pipe.run([E2EContext(sample="sample_y")]) assert r1[0].output.sample == "sample_x" assert r2[0].output.sample == "sample_y" # --------------------------------------------------------------------------- # E2E: async steps inside pipeline # --------------------------------------------------------------------------- @pytest.mark.integration class TestAsyncStepsInPipeline: def test_async_step_runs_via_run(self): class AsyncAgent: requires = frozenset() provides = frozenset({"agent_output"}) async def __call__(self, ctx: E2EContext) -> E2EContext: await asyncio.sleep(0) return ctx.replace(agent_output="async_answer") results = Pipeline().then(AsyncAgent()).run([E2EContext(sample="s")]) assert results[0].output.agent_output == "async_answer" def test_mixed_sync_async_steps(self): class AsyncAgent: requires = frozenset() provides = frozenset({"agent_output"}) async def __call__(self, ctx: E2EContext) -> E2EContext: await asyncio.sleep(0) return ctx.replace(agent_output="async") class SyncEval: requires = frozenset({"agent_output"}) provides = frozenset({"environment_result"}) def __call__(self, ctx: E2EContext) -> E2EContext: return ctx.replace(environment_result={"score": 1.0}) results = ( Pipeline().then(AsyncAgent()).then(SyncEval()).run([E2EContext(sample="s")]) ) assert results[0].output.agent_output == "async" assert results[0].output.environment_result["score"] == 1.0 def test_run_async_entry_point(self): contexts = [E2EContext(sample="q1"), E2EContext(sample="q2")] results = asyncio.run( Pipeline().then(AgentStep()).then(EvaluateStep()).run_async(contexts) ) assert len(results) == 2 assert all(r.error is None for r in results) # --------------------------------------------------------------------------- # E2E: background executor shared across pipeline instances # --------------------------------------------------------------------------- @pytest.mark.integration @pytest.mark.slow class TestSharedBackgroundExecutor: def test_same_step_class_uses_same_executor(self): """Two Pipeline instances sharing the same step class share the executor.""" class SharedBg: requires = frozenset() provides = frozenset({"done"}) async_boundary = True max_workers = 1 # single-threaded shared pool call_count = 0 _lock = threading.Lock() def __call__(self, ctx): with SharedBg._lock: SharedBg.call_count += 1 time.sleep(0.01) return ctx.replace( metadata=MappingProxyType({**ctx.metadata, "done": True}) ) SharedBg.call_count = 0 # Reset class executor so the test is independent if hasattr(SharedBg, "_executor") and SharedBg._executor is not None: SharedBg._executor.shutdown(wait=False) SharedBg._executor = None pipe_a = Pipeline().then(SharedBg()) pipe_b = Pipeline().then(SharedBg()) results_a = pipe_a.run([E2EContext(sample="a1"), E2EContext(sample="a2")]) results_b = pipe_b.run([E2EContext(sample="b1"), E2EContext(sample="b2")]) pipe_a.wait_for_background(timeout=5.0) pipe_b.wait_for_background(timeout=5.0) assert SharedBg.call_count == 4 assert all(r.output.metadata.get("done") for r in results_a + results_b) # --------------------------------------------------------------------------- # E2E: iterable (non-list) inputs # --------------------------------------------------------------------------- @pytest.mark.integration class TestIterableInputs: def test_generator_input(self): """Pipeline.run() accepts a generator, not only a list.""" def gen(): for s in ("a", "b", "c"): yield E2EContext(sample=s) results = Pipeline().then(AgentStep()).run(gen()) assert len(results) == 3 assert all(r.error is None for r in results) assert {r.output.agent_output for r in results} == { "answer_for_a", "answer_for_b", "answer_for_c", } def test_tuple_input(self): """Pipeline.run() accepts a tuple of contexts.""" contexts = tuple(E2EContext(sample=s) for s in ("x", "y")) results = Pipeline().then(AgentStep()).then(EvaluateStep()).run(contexts) assert len(results) == 2 assert all(r.error is None for r in results) def test_run_async_with_generator(self): """Pipeline.run_async() also accepts a generator.""" def gen(): for s in ("q1", "q2"): yield E2EContext(sample=s) results = asyncio.run( Pipeline().then(AgentStep()).then(EvaluateStep()).run_async(gen()) ) assert len(results) == 2 assert all(r.error is None for r in results)