From 32f5102b3f086753cbbbe32848a0cb4a9f1097c0 Mon Sep 17 00:00:00 2001 From: Muthukumaran PK Date: Mon, 4 May 2026 16:20:57 -0400 Subject: [PATCH 1/3] feat: add integration deployment test for LangGraph HITL agent Add test_deployment.py for the human_in_the_loop agent following the same pattern as react_agent: build-openshift, deploy via Helm, validate GET /health returns 200, and teardown via fixture finalizer. Also introduces load_agent_name() in shared utils to read the agent name from agent.yaml instead of hardcoding it (addresses PR #83 feedback), and updates react_agent's test to use it. Jira: RHAIENG-4644 Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/agent-deployment-test.yaml | 1 + agents/langgraph/human_in_the_loop/Makefile | 9 +- .../tests/integration/__init__.py | 0 .../tests/integration/conftest.py | 2 + .../tests/integration/test_deployment.py | 90 +++++++++++++++++++ .../tests/integration/test_deployment.py | 13 ++- tests/integration/utils.py | 9 ++ 7 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 agents/langgraph/human_in_the_loop/tests/integration/__init__.py create mode 100644 agents/langgraph/human_in_the_loop/tests/integration/conftest.py create mode 100644 agents/langgraph/human_in_the_loop/tests/integration/test_deployment.py diff --git a/.github/workflows/agent-deployment-test.yaml b/.github/workflows/agent-deployment-test.yaml index 9a7a1e97..8ce3b486 100644 --- a/.github/workflows/agent-deployment-test.yaml +++ b/.github/workflows/agent-deployment-test.yaml @@ -58,6 +58,7 @@ jobs: matrix: agent: - { name: langgraph-react-agent, dir: agents/langgraph/react_agent } + - { name: langgraph-hitl-agent, dir: agents/langgraph/human_in_the_loop } env: API_KEY: ${{ vars.API_KEY }} BASE_URL: ${{ vars.BASE_URL }} diff --git a/agents/langgraph/human_in_the_loop/Makefile b/agents/langgraph/human_in_the_loop/Makefile index b4aa66f9..d4aa72c4 100644 --- a/agents/langgraph/human_in_the_loop/Makefile +++ b/agents/langgraph/human_in_the_loop/Makefile @@ -5,7 +5,7 @@ VALUES_FILE := values.yaml CONTAINER_CLI := $(shell command -v podman 2>/dev/null || command -v docker 2>/dev/null) MODEL ?= llama3.1:8b -.PHONY: init re-init env ollama llama-server run-app run-app-fresh run-cli build push build-openshift deploy undeploy test dry-run help +.PHONY: init re-init env ollama llama-server run-app run-app-fresh run-cli build push build-openshift deploy undeploy test test-integration dry-run help help: ## Show this help @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-12s %s\n", $$1, $$2}' @@ -158,4 +158,9 @@ undeploy: ## Remove deployment from cluster helm uninstall $(AGENT_NAME) test: ## Run tests - uv run --extra dev python -m pytest tests/ + uv run --extra dev python -m pytest tests/ --ignore=tests/integration + +test-integration: ## Run integration deployment test + PYTHONPATH=$$(git rev-parse --show-toplevel)/tests \ + uv run --extra dev python -m pytest tests/integration/test_deployment.py \ + -v --tb=long --junitxml=results.xml diff --git a/agents/langgraph/human_in_the_loop/tests/integration/__init__.py b/agents/langgraph/human_in_the_loop/tests/integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/agents/langgraph/human_in_the_loop/tests/integration/conftest.py b/agents/langgraph/human_in_the_loop/tests/integration/conftest.py new file mode 100644 index 00000000..e1144084 --- /dev/null +++ b/agents/langgraph/human_in_the_loop/tests/integration/conftest.py @@ -0,0 +1,2 @@ +# Re-export shared integration fixtures so pytest discovers them. +from integration.conftest import cluster_auth, repo_root # noqa: F401 diff --git a/agents/langgraph/human_in_the_loop/tests/integration/test_deployment.py b/agents/langgraph/human_in_the_loop/tests/integration/test_deployment.py new file mode 100644 index 00000000..0fbe674d --- /dev/null +++ b/agents/langgraph/human_in_the_loop/tests/integration/test_deployment.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import logging +import os + +import pytest +from integration.utils import ( + MakeTargetError, + RouteNotFoundError, + get_route, + health_check, + load_agent_name, + run_make, +) + +logger = logging.getLogger(__name__) + +INTERNAL_REGISTRY = "image-registry.openshift-image-registry.svc:5000" + + +@pytest.fixture(scope="module") +def agent_dir(repo_root): + return repo_root / "agents" / "langgraph" / "human_in_the_loop" + + +@pytest.fixture(scope="module") +def agent_name(agent_dir): + return load_agent_name(agent_dir) + + +def _write_env_file(agent_dir, container_image): + """Write a .env file so Makefile targets can source it.""" + missing = [v for v in ("BASE_URL", "MODEL_ID") if v not in os.environ] + if missing: + pytest.fail( + f"Missing required env vars: {', '.join(missing)}. " + "Set them in the CI workflow or export locally." + ) + env_path = agent_dir / ".env" + env_path.write_text( + f"API_KEY={os.environ.get('API_KEY', 'not-needed')}\n" + f"BASE_URL={os.environ['BASE_URL']}\n" + f"MODEL_ID={os.environ['MODEL_ID']}\n" + f"CONTAINER_IMAGE={container_image}\n" + ) + return env_path + + +@pytest.fixture(scope="module") +def deployed_agent(cluster_auth, agent_dir, agent_name): + namespace = cluster_auth["namespace"] + container_image = f"{INTERNAL_REGISTRY}/{namespace}/{agent_name}:latest" + env_path = _write_env_file(agent_dir, container_image) + + deployed = False + try: + logger.info("Building image on cluster via build-openshift...") + run_make("build-openshift", cwd=agent_dir, timeout=600) + + logger.info("Deploying to cluster...") + run_make("deploy", cwd=agent_dir, timeout=300) + deployed = True + + route_url = get_route(agent_name, namespace=namespace) + logger.info("Agent deployed at %s", route_url) + + yield route_url + + except (MakeTargetError, RouteNotFoundError) as exc: + pytest.fail(f"Deployment failed: {exc}") + + finally: + if deployed: + logger.info("Tearing down deployment...") + try: + run_make("undeploy", cwd=agent_dir, timeout=120) + except MakeTargetError: + logger.warning( + "Cleanup failed — manual undeploy may be needed", exc_info=True + ) + env_path.unlink(missing_ok=True) + + +@pytest.mark.integration +def test_health_endpoint(deployed_agent): + route_url = deployed_agent + result = health_check(f"{route_url}/health", retries=12, backoff=5.0) + + assert result["status"] == "healthy" + assert result["agent_initialized"] is True diff --git a/agents/langgraph/react_agent/tests/integration/test_deployment.py b/agents/langgraph/react_agent/tests/integration/test_deployment.py index 4aa44598..bc652a62 100644 --- a/agents/langgraph/react_agent/tests/integration/test_deployment.py +++ b/agents/langgraph/react_agent/tests/integration/test_deployment.py @@ -9,12 +9,12 @@ RouteNotFoundError, get_route, health_check, + load_agent_name, run_make, ) logger = logging.getLogger(__name__) -AGENT_NAME = "langgraph-react-agent" INTERNAL_REGISTRY = "image-registry.openshift-image-registry.svc:5000" @@ -23,6 +23,11 @@ def agent_dir(repo_root): return repo_root / "agents" / "langgraph" / "react_agent" +@pytest.fixture(scope="module") +def agent_name(agent_dir): + return load_agent_name(agent_dir) + + def _write_env_file(agent_dir, container_image): """Write a .env file so Makefile targets can source it.""" missing = [v for v in ("BASE_URL", "MODEL_ID") if v not in os.environ] @@ -42,9 +47,9 @@ def _write_env_file(agent_dir, container_image): @pytest.fixture(scope="module") -def deployed_agent(cluster_auth, agent_dir): +def deployed_agent(cluster_auth, agent_dir, agent_name): namespace = cluster_auth["namespace"] - container_image = f"{INTERNAL_REGISTRY}/{namespace}/{AGENT_NAME}:latest" + container_image = f"{INTERNAL_REGISTRY}/{namespace}/{agent_name}:latest" env_path = _write_env_file(agent_dir, container_image) deployed = False @@ -56,7 +61,7 @@ def deployed_agent(cluster_auth, agent_dir): run_make("deploy", cwd=agent_dir, timeout=300) deployed = True - route_url = get_route(AGENT_NAME, namespace=namespace) + route_url = get_route(agent_name, namespace=namespace) logger.info("Agent deployed at %s", route_url) yield route_url diff --git a/tests/integration/utils.py b/tests/integration/utils.py index 256289dd..8966e5e5 100644 --- a/tests/integration/utils.py +++ b/tests/integration/utils.py @@ -11,6 +11,15 @@ logger = logging.getLogger(__name__) + +def load_agent_name(agent_dir: str | Path) -> str: + text = (Path(agent_dir) / "agent.yaml").read_text() + match = re.search(r"^name:\s*(.+)", text, re.MULTILINE) + if not match: + raise ValueError(f"No 'name' field in {agent_dir}/agent.yaml") + return match.group(1).strip() + + _REDACT_PATTERNS = [ re.compile(r"(API_KEY=)\S+"), re.compile(r'(apiKey:\s*")[^"]*"'), From b747417dc9aba0df16cca9fdda941416f2fbbc70 Mon Sep 17 00:00:00 2001 From: Muthukumaran PK Date: Wed, 6 May 2026 10:30:56 -0400 Subject: [PATCH 2/3] refactor: use yaml.safe_load() in load_agent_name() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback — regex could capture inline YAML comments or quoted values, silently corrupting image tags and route names. PyYAML is already a transitive dependency via LangGraph/LangChain. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/integration/utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/integration/utils.py b/tests/integration/utils.py index 8966e5e5..6b95fdba 100644 --- a/tests/integration/utils.py +++ b/tests/integration/utils.py @@ -8,16 +8,16 @@ from pathlib import Path import httpx +import yaml logger = logging.getLogger(__name__) def load_agent_name(agent_dir: str | Path) -> str: - text = (Path(agent_dir) / "agent.yaml").read_text() - match = re.search(r"^name:\s*(.+)", text, re.MULTILINE) - if not match: + data = yaml.safe_load((Path(agent_dir) / "agent.yaml").read_text()) + if not isinstance(data, dict) or "name" not in data: raise ValueError(f"No 'name' field in {agent_dir}/agent.yaml") - return match.group(1).strip() + return str(data["name"]).strip() _REDACT_PATTERNS = [ From 776f6409e9e043f93444febf466f209f780e4c7b Mon Sep 17 00:00:00 2001 From: Muthukumaran PK Date: Wed, 6 May 2026 14:05:08 -0400 Subject: [PATCH 3/3] fix: prevent SIGPIPE exit in cluster connection verification The `set -o pipefail` combined with `oc get all | head -10` causes exit code 141 when head closes the pipe before oc finishes writing. Add `|| true` since this step is diagnostic only. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/agent-deployment-test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/agent-deployment-test.yaml b/.github/workflows/agent-deployment-test.yaml index 8ce3b486..cc043741 100644 --- a/.github/workflows/agent-deployment-test.yaml +++ b/.github/workflows/agent-deployment-test.yaml @@ -39,7 +39,7 @@ jobs: oc project echo "" echo "--- Namespace resources ---" - oc get all -n ci-testing --no-headers | head -10 + oc get all -n ci-testing --no-headers 2>&1 | head -10 || true echo "" echo "Cluster connection verified successfully."