import asyncio from dataclasses import dataclass import pytest from typing_extensions import override from agenton.compositor import Compositor, LayerNode, LayerProvider from agenton.layers import EmptyLayerConfig, LayerDeps, PlainLayer from agenton_collections.layers.plain import ObjectLayer class RenamedObjectDeps(LayerDeps): renamed: ObjectLayer[str] # pyright: ignore[reportUninitializedInstanceVariable] @dataclass(slots=True) class RenamedConsumerLayer(PlainLayer[RenamedObjectDeps]): @property @override def prefix_prompts(self) -> list[str]: return [self.deps.renamed.value] class SameNameObjectDeps(LayerDeps): same: ObjectLayer[str] # pyright: ignore[reportUninitializedInstanceVariable] @dataclass(slots=True) class SameNameConsumerLayer(PlainLayer[SameNameObjectDeps]): @property @override def prefix_prompts(self) -> list[str]: return [self.deps.same.value] class OptionalObjectDeps(LayerDeps): maybe: ObjectLayer[str] | None # pyright: ignore[reportUninitializedInstanceVariable] @dataclass(slots=True) class OptionalConsumerLayer(PlainLayer[OptionalObjectDeps]): pass def _object_provider(value: str) -> LayerProvider[ObjectLayer[str]]: return LayerProvider.from_factory( layer_type=ObjectLayer, create=lambda config: ObjectLayer(value), ) def test_direct_deps_access_uses_explicit_dependency_rename() -> None: compositor = Compositor( [ LayerNode("actual", _object_provider("target")), LayerNode("consumer", RenamedConsumerLayer, deps={"renamed": "actual"}), ] ) async def run() -> None: async with compositor.enter() as active_run: target = active_run.get_layer("actual", ObjectLayer) consumer = active_run.get_layer("consumer", RenamedConsumerLayer) assert consumer.deps.renamed is target assert [prompt.value for prompt in active_run.prompts] == ["target"] asyncio.run(run()) def test_direct_deps_access_uses_explicit_same_name_dependency() -> None: compositor = Compositor( [ LayerNode("same", _object_provider("target")), LayerNode("consumer", SameNameConsumerLayer, deps={"same": "same"}), ] ) async def run() -> None: async with compositor.enter() as active_run: target = active_run.get_layer("same", ObjectLayer) consumer = active_run.get_layer("consumer", SameNameConsumerLayer) assert consumer.deps.same is target assert [prompt.value for prompt in active_run.prompts] == ["target"] asyncio.run(run()) def test_optional_missing_dependency_is_bound_to_none() -> None: compositor = Compositor([LayerNode("consumer", OptionalConsumerLayer)]) async def run() -> None: async with compositor.enter() as active_run: consumer = active_run.get_layer("consumer", OptionalConsumerLayer) assert consumer.deps.maybe is None asyncio.run(run()) def test_missing_required_dependency_is_rejected_before_hooks() -> None: compositor = Compositor([LayerNode("consumer", SameNameConsumerLayer)]) with pytest.raises(ValueError, match="Dependency 'same' is required"): asyncio.run(_enter_once(compositor)) def test_unknown_dependency_mapping_is_rejected_for_compositor_construction() -> None: with pytest.raises(ValueError, match="unknown dependency keys: missing"): Compositor([LayerNode("consumer", RenamedConsumerLayer, deps={"missing": "target"})]) def test_undefined_dependency_target_is_rejected_for_compositor_construction() -> None: with pytest.raises(ValueError, match="depends on undefined layer names: missing_target"): Compositor([LayerNode("consumer", RenamedConsumerLayer, deps={"renamed": "missing_target"})]) def test_dependency_target_must_precede_dependent_layer_in_graph_order() -> None: with pytest.raises(ValueError, match="must target preceding layer nodes"): Compositor( [ LayerNode("consumer", RenamedConsumerLayer, deps={"renamed": "target"}), LayerNode("target", _object_provider("value")), ] ) def test_duplicate_layer_node_name_is_rejected() -> None: with pytest.raises(ValueError, match="Duplicate layer name 'same'"): Compositor( [ LayerNode("same", _object_provider("first")), LayerNode("same", _object_provider("second")), ] ) async def _enter_once(compositor: Compositor) -> None: async with compositor.enter(configs={"consumer": EmptyLayerConfig()}): pass