Repository navigation
[l] [agentbricks] Add pytest E2E suites #706
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
elainewang-db
merged 48 commits into
databricks:main
from
elainewang-db:mason-agentbricks-e2e-matrices
Oct 16, 2026
Merged
Changes from all commits
Commits
Show all changes
48 commits
Select commit
Hold shift + click to select a range
fbc714f
[agentbricks] add pytest matrix e2e suite + suite runner
elainewang-db 4bb52a5
[agentbricks] drop unbind from e2e single-feature authoring
elainewang-db 8c8e213
[agentbricks] run e2e matrices in parallel and fix live-run issues
elainewang-db f03bcd2
[agentbricks] replace e2e suite runner with lazy pytest fixtures and …
elainewang-db fa058ba
[agentbricks] rename e2e backend to target workspace, scope genie spa…
elainewang-db ffd4bda
[agentbricks] drop unused scratch-schema bundles from store and traci…
elainewang-db 4ef55a7
[agentbricks] per-test temporary schemas and full Lakebase cleanup in…
elainewang-db 4d3b3b1
[agentbricks] rename e2e matrices to suites
elainewang-db 13c18ca
Merge remote-tracking branch 'origin/main' into mason-agentbricks-e2e…
elainewang-db 607fc2b
[agentbricks] revoke e2e catalog grants and add a standalone leak sweep
elainewang-db ef830d7
[agentbricks] move e2e suite harness under tests/suites and run lefto…
elainewang-db d6b4901
[agentbricks] rename e2e harness process.py to logs.py and inline the…
elainewang-db 4965a90
Merge remote-tracking branch 'origin/main' into mason-agentbricks-e2e…
elainewang-db fe2628c
[agentbricks] disable the coding-agent hint in e2e CLI subprocesses
elainewang-db a2ade45
[agentbricks] shorten the e2e UC marker function name to fit the MCP …
elainewang-db 5169341
[agentbricks] retry e2e endpoint invoke on gateway errors, add --geni…
elainewang-db cb2ad6d
[agentbricks] wait for e2e tool grants to take effect and run the san…
elainewang-db a28ea5a
[agentbricks] E2E suites: run CLI steps as command strings; dev/deplo…
elainewang-db c327470
[agentbricks] E2E suites: tests run init themselves; project fixture …
elainewang-db 8452bb4
[agentbricks] E2E suites: move AgentEndpoint to harness/agent_endpoin…
elainewang-db 4016b01
[agentbricks] E2E suites: rename harness/cli.py to agentbricks_cli.py…
elainewang-db 916b0dc
[agentbricks] E2E suites: write every CLI step in tests as a command;…
elainewang-db fcc2f79
[agentbricks] E2E suites: explicit direct-authoring branch in test_de…
elainewang-db eb41b70
[agentbricks] E2E suites: move harness next to the suites; WorkspaceK…
elainewang-db 84b1519
[agentbricks] E2E suites: reach the agent and App resources through t…
elainewang-db 7153060
[agentbricks] E2E suites: per-test cleanup through the CLI only; the …
elainewang-db d3f7331
[agentbricks] E2E suites: run per-test cleanup commands directly in t…
elainewang-db 8d46b1c
[agentbricks] E2E suites: direct authoring appends tools to init's ag…
elainewang-db 5556730
[agentbricks] E2E suites: drop the unused --bridge-sha mode
elainewang-db 410b6e1
[agentbricks] E2E suites: stop relying on the CLI's tool-resource nam…
elainewang-db ae8f16b
[agentbricks] E2E suites: simplify the sweep to the run prefix plus A…
elainewang-db 35e5955
[agentbricks] E2E suites: tests register their own cleanup commands
elainewang-db 92562c5
[agentbricks] E2E suites: check grants by invoking the tools, not by …
elainewang-db 844cbee
[agentbricks] E2E suites: CLI-authored deploy adds every tool, then d…
elainewang-db 58b991a
[agentbricks] E2E suites: CLI-authored dev adds every tool, then star…
elainewang-db 4cd8e08
[agentbricks] E2E suites: read the App principal through the CLI; nam…
elainewang-db 82d87c6
[agentbricks] E2E suites: check App existence with deployments get, n…
elainewang-db cadc1b9
[agentbricks] E2E suites: run deployments get directly instead of thr…
elainewang-db 0160964
[agentbricks] E2E suites: one happy path per command group, in flat f…
elainewang-db 649c28f
[agentbricks] E2E suites: move invocation_json into the harness CLI m…
elainewang-db 29cceb3
[agentbricks] E2E suites: disable CLI telemetry for test runs
elainewang-db 0221e81
Merge remote-tracking branch 'origin/main' into mason-agentbricks-e2e…
elainewang-db f332180
[agentbricks] E2E suites: check stores and tools through invoke; read…
elainewang-db f776c64
[agentbricks] E2E suites: add --model for workspaces that name models…
elainewang-db 0c49ea2
[agentbricks] E2E suites: no grant checks; test_deploy leaves deletio…
elainewang-db 950e062
[agentbricks] E2E suites: one AgentbricksCli.invoke for endpoint invoke
elainewang-db 3c086fe
[agentbricks] E2E suites: Tool.check_not_called for removed tools
elainewang-db e5b2658
[agentbricks] E2E suites: stop timed-out commands before teardown; re…
elainewang-db File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| """Plain-Python building blocks of the E2E suites; ``suites/conftest.py`` wires them into pytest.""" |
308 changes: 308 additions & 0 deletions
308
integrations/agentbricks/tests/harness/agentbricks_cli.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,308 @@ | ||
| """The ``agentbricks`` CLI: the system under test. | ||
|
|
||
| Every test step is a real ``agentbricks`` subprocess, written as the command a developer would type | ||
| (``cli.run("tools add ...")``), so a regression in the CLI fails the test that makes it. That | ||
| includes reaching a running agent: tests call it with ``agentbricks endpoint invoke``. Nothing in | ||
| this module knows about any particular suite or deletes anything: the ``project`` fixture does. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import contextlib | ||
| import dataclasses | ||
| import json | ||
| import os | ||
| import pathlib | ||
| import re | ||
| import shlex | ||
| import shutil | ||
| import signal | ||
| import socket | ||
| import subprocess | ||
| import sys | ||
| import time | ||
| import urllib.error | ||
| import urllib.request | ||
| import uuid | ||
| from collections.abc import Iterator, Mapping | ||
| from typing import Any | ||
|
|
||
| from harness.logs import last_lines, last_nonempty_line, log, log_command | ||
| from harness.run import E2EError, RunConfig, now | ||
| from harness.target import TargetWorkspace | ||
|
|
||
| # `endpoint invoke`'s error for a 5xx answer: the App restarting after a redeploy, a new App's | ||
| # session store coming up, or a model call failing on the server. | ||
| _SERVER_ERROR = re.compile(r"HTTP 5\d\d\b") | ||
| _NOT_FOUND = re.compile( | ||
| r"NOT_FOUND|RESOURCE_DOES_NOT_EXIST|does not exist|not found", re.IGNORECASE | ||
| ) | ||
|
|
||
|
|
||
| @dataclasses.dataclass | ||
| class BackgroundCommand: | ||
| """A CLI command left running by ``AgentbricksCli.start``, e.g. ``agentbricks dev``.""" | ||
|
|
||
| label: str | ||
| process: subprocess.Popen[str] | ||
| log_path: pathlib.Path | ||
|
|
||
| def wait_until_serving(self, port: int, timeout: float = 1200) -> None: | ||
| """Block until something answers HTTP on ``port``; fails if the command exits first.""" | ||
| started = time.monotonic() | ||
| next_tick = 60.0 | ||
| while True: | ||
| if self.process.poll() is not None: | ||
| raise E2EError( | ||
| f"{self.label} exited {self.process.returncode}: {last_lines(self.log_path, 30)}" | ||
| ) | ||
| try: | ||
| with urllib.request.urlopen(f"http://127.0.0.1:{port}/", timeout=5): | ||
| return | ||
| except urllib.error.HTTPError as exc: | ||
| if exc.code < 500: | ||
| return | ||
| except (urllib.error.URLError, TimeoutError): | ||
| pass | ||
| elapsed = time.monotonic() - started | ||
| if elapsed > timeout: | ||
| raise E2EError( | ||
| f"{self.label} did not become reachable: {last_lines(self.log_path, 30)}" | ||
| ) | ||
| if elapsed >= next_tick: | ||
| log( | ||
| f"tick {now():%H:%M} | {self.label} | starting | {last_nonempty_line(self.log_path)}" | ||
| ) | ||
| next_tick += 60 | ||
| time.sleep(5) | ||
|
|
||
|
|
||
| class AgentbricksCli: | ||
| """Runs ``agentbricks`` commands against a target workspace, logging each to its own files.""" | ||
|
|
||
| def __init__(self, target_workspace: TargetWorkspace, run_config: RunConfig, test_name: str): | ||
| self.target_workspace = target_workspace | ||
| self.run_config = run_config | ||
| # One directory per test: tests run concurrently and each numbers its commands from 1. | ||
| self.logs_dir = ( | ||
| run_config.output / "logs" / re.sub(r"[^A-Za-z0-9_.-]+", "-", test_name).strip("-") | ||
| ) | ||
| # The CLI prints a hint when it detects a coding agent; the suite must behave the same | ||
| # whoever launches it, and the hint would corrupt --output json errors on stderr. Test runs | ||
| # are not product usage, so they send no CLI telemetry. | ||
| self._env = { | ||
| **os.environ, | ||
| **target_workspace.env, | ||
| "AGENTBRICKS_DISABLE_AGENT_HINT": "1", | ||
| "AGENTBRICKS_DISABLE_TELEMETRY": "1", | ||
| } | ||
| self._sequence = 0 | ||
|
|
||
| def run( | ||
| self, | ||
| command: str, | ||
| *, | ||
| timeout: float = 300, | ||
| check: bool = True, | ||
| retry_server_errors: bool = False, | ||
| ) -> subprocess.CompletedProcess[str]: | ||
| """Run ``agentbricks <command>`` to completion. | ||
|
|
||
| ``retry_server_errors`` retries an ``endpoint invoke`` the agent answered with a 5xx; any | ||
| other failure, including a 4xx, is the CLI's or the agent's and fails at once. | ||
| """ | ||
| argv = self._argv(command) | ||
| for attempt in range(1, 4): | ||
| result = self._run_once(argv, self._label(command), timeout) | ||
| output = result.stderr or result.stdout | ||
| if ( | ||
| result.returncode == 0 | ||
| or not retry_server_errors | ||
| or attempt == 3 | ||
| or _SERVER_ERROR.search(output) is None | ||
| ): | ||
| break | ||
| log(f"attempt {attempt}/3 | {command.split()[0]} | server error; retrying") | ||
| time.sleep(15) | ||
| if check and result.returncode != 0: | ||
| raise E2EError(f"Command failed ({result.returncode}): {shlex.join(argv)}\n{output}") | ||
| return result | ||
|
|
||
| def invoke( | ||
| self, target: str, prompt: str, *, session_id: str | None = None, actor: str | None = None | ||
| ) -> str: | ||
| """``agentbricks --output json endpoint invoke <target>`` with ``prompt``; returns its output. | ||
|
|
||
| ``target`` is an App name, or ``--url <url> --no-auth`` for `agentbricks dev`. Each call is | ||
| a new session unless ``session_id`` is given. The agent keys long-term memory by ``actor``, | ||
| which defaults to the session. | ||
| """ | ||
| invocation_id = str(uuid.uuid4()) | ||
| agent_input: dict[str, Any] = { | ||
| "model": self.run_config.model, | ||
| "messages": [{"role": "user", "content": prompt}], | ||
| } | ||
| if actor: | ||
| agent_input["actor"] = actor | ||
| body = { | ||
| "id": invocation_id, | ||
| # session_id is a top-level invocation field; the adapter rejects it nested in input. | ||
| "session_id": session_id or invocation_id, | ||
| "input": agent_input, | ||
| } | ||
| return self.run( | ||
| f"--output json endpoint invoke {target} --path /api/invocations " | ||
| f"--json {shlex.quote(json.dumps(body))}", | ||
| timeout=360, | ||
| retry_server_errors=True, | ||
| ).stdout | ||
|
|
||
| @contextlib.contextmanager | ||
| def start( | ||
| self, command: str, *, env: Mapping[str, str] | None = None | ||
| ) -> Iterator[BackgroundCommand]: | ||
| """Run ``agentbricks <command>`` in the background for the block, then stop it.""" | ||
| argv = self._argv(command) | ||
| label = self._label(command) | ||
| log_path = self.logs_dir / f"{label}.log" | ||
| log_path.parent.mkdir(parents=True, exist_ok=True) | ||
| log_command(argv) | ||
| with log_path.open("w", encoding="utf-8") as log_file: | ||
| process = subprocess.Popen( | ||
| argv, | ||
| text=True, | ||
| stdout=log_file, | ||
| stderr=subprocess.STDOUT, | ||
| start_new_session=True, | ||
| env={**self._env, **(env or {})}, | ||
| ) | ||
| try: | ||
| yield BackgroundCommand(label, process, log_path) | ||
| finally: | ||
| if process.poll() is None: | ||
| terminate_process_group(process) | ||
| log(f"{label} | stopped") | ||
|
|
||
| def _argv(self, command: str) -> list[str]: | ||
| return [_agentbricks_bin(), *self.target_workspace.cli_args, *shlex.split(command)] | ||
|
|
||
| def _label(self, command: str) -> str: | ||
| """A log name from the command's first two words, e.g. ``007-tools-add``.""" | ||
| self._sequence += 1 | ||
| words = shlex.split(command) | ||
| while words and words[0].startswith("-"): | ||
| # A leading global option such as ``--output json``, and its value. | ||
| words = words[1:] if "=" in words[0] else words[2:] | ||
| # The second word only when it is a subcommand or name, never a flag or a path. | ||
| names = words[:1] + [w for w in words[1:2] if re.fullmatch(r"[a-z][a-z0-9_-]*", w)] | ||
| return "-".join([f"{self._sequence:03d}", *names]) | ||
|
|
||
| def _run_once( | ||
| self, argv: list[str], label: str, timeout: float | ||
| ) -> subprocess.CompletedProcess[str]: | ||
| """Run with stdout and stderr in log files, printing progress while a command is slow.""" | ||
| stdout_path = self.logs_dir / f"{label}.out" | ||
| stderr_path = self.logs_dir / f"{label}.err" | ||
| stdout_path.parent.mkdir(parents=True, exist_ok=True) | ||
| log_command(argv) | ||
| started = time.monotonic() | ||
| with ( | ||
| stdout_path.open("w", encoding="utf-8") as out, | ||
| stderr_path.open("w", encoding="utf-8") as err, | ||
| ): | ||
| process = subprocess.Popen( | ||
| argv, | ||
| env=self._env, | ||
| text=True, | ||
| stdout=out, | ||
| stderr=err, | ||
| start_new_session=True, | ||
| ) | ||
| next_tick = 60.0 | ||
| while process.poll() is None: | ||
| elapsed = time.monotonic() - started | ||
| if elapsed >= timeout: | ||
| terminate_process_group(process) | ||
| raise E2EError(f"{label} timed out after {timeout:.0f}s; log: {stderr_path}") | ||
| if elapsed >= next_tick: | ||
| progress = last_nonempty_line(stderr_path) or last_nonempty_line(stdout_path) | ||
| log(f"tick {now():%H:%M} | {label} | running | {progress}") | ||
| next_tick += 60.0 | ||
| time.sleep(0.2 if elapsed < 5 else 2) | ||
| stdout = stdout_path.read_text(encoding="utf-8", errors="replace") | ||
| stderr = stderr_path.read_text(encoding="utf-8", errors="replace") | ||
| for text in (stdout, stderr): | ||
| if text.strip(): | ||
| log(text) | ||
| return subprocess.CompletedProcess(argv, process.returncode, stdout, stderr) | ||
|
|
||
|
|
||
| def _agentbricks_bin() -> str: | ||
| """The CLI of the interpreter running the tests, i.e. the source checkout's own install.""" | ||
| sibling = pathlib.Path(sys.executable).parent / "agentbricks" | ||
| found = str(sibling) if sibling.exists() else shutil.which("agentbricks") | ||
| if found is None: | ||
| raise E2EError("The agentbricks CLI is not installed in the test environment.") | ||
| return found | ||
|
|
||
|
|
||
| def terminate_process_group(process: subprocess.Popen[str], *, grace: float = 20) -> None: | ||
| """Stop the command and what it started, returning only once it has exited. | ||
|
|
||
| A command still running during teardown could create what the cleanup has already deleted. | ||
| """ | ||
| try: | ||
| os.killpg(process.pid, signal.SIGTERM) | ||
| except ProcessLookupError: | ||
| pass | ||
| try: | ||
| process.wait(timeout=grace) | ||
| except subprocess.TimeoutExpired: | ||
| try: | ||
| os.killpg(process.pid, signal.SIGKILL) | ||
| except ProcessLookupError: | ||
| pass | ||
| process.wait() | ||
|
|
||
|
|
||
| def not_found(result: subprocess.CompletedProcess[str]) -> bool: | ||
| """Whether a command failed because what it names doesn't exist, e.g. an App already deleted.""" | ||
| return result.returncode != 0 and bool(_NOT_FOUND.search(result.stderr or result.stdout)) | ||
|
|
||
|
|
||
| # Reading `endpoint invoke --output json`: the LangGraph agent lists every step in | ||
| # ``body.output.output``, the model's messages (``type == "ai"``) and each tool's own result | ||
| # (``type == "tool"``). | ||
|
|
||
|
|
||
| def excerpt(serialized: str, head: int = 1000, tail: int = 3000) -> str: | ||
| """The response for a failure message: tool results come last, so keep the end, not just the start.""" | ||
| if len(serialized) <= head + tail: | ||
| return serialized | ||
| omitted = len(serialized) - head - tail | ||
| return f"{serialized[:head]} ...[{omitted} chars omitted]... {serialized[-tail:]}" | ||
|
|
||
|
|
||
| def _steps(serialized: str) -> list[dict[str, Any]]: | ||
| try: | ||
| steps = json.loads(serialized)["body"]["output"]["output"] | ||
| except (ValueError, KeyError, TypeError) as exc: | ||
| raise AssertionError(f"Unexpected endpoint invoke output: {excerpt(serialized)}") from exc | ||
| return [step for step in steps if isinstance(step, dict)] | ||
|
|
||
|
|
||
| def tool_results(serialized: str) -> list[dict[str, Any]]: | ||
| """Each tool's own result, separate from what the model says about it.""" | ||
| return [step for step in _steps(serialized) if step.get("type") == "tool"] | ||
|
|
||
|
|
||
| def reply(serialized: str) -> str: | ||
| """The model's last message.""" | ||
| messages = [step for step in _steps(serialized) if step.get("type") == "ai"] | ||
| return str(messages[-1].get("content")) if messages else "" | ||
|
|
||
|
|
||
| def free_port() -> int: | ||
| with socket.socket() as probe: | ||
| probe.bind(("127.0.0.1", 0)) | ||
| return probe.getsockname()[1] |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.