Compare commits

...

4 Commits

Author SHA1 Message Date
Tomu Hirata 59d5cdfcb8 feat(test): properly migrate prompt_policy tests to mock LLM
The server's PolicyLLMClient uses llm.model="mock-model" (set by the
live_server fixture's server.yaml in mock mode). Pre-seed that queue
with ALLOW/DENY verdicts to exercise the full prompt_policy wiring:

- test_prompt_policy_allow_path_reaches_llm: seeds "mock-model" with
  {"action": "allow"}, seeds agent model with text response — verifies
  the ALLOW path reaches the agent LLM and returns output.
- test_prompt_policy_deny_path_short_circuits: seeds "mock-model" with
  {"action": "deny"} — verifies the events endpoint resolves DENY
  synchronously before queuing the runner turn.

Removes the skipif guard and NotImplementedError stubs entirely.

Co-authored-by: Isaac
2026-06-19 16:46:38 +09:00
Tomu Hirata acb2fbe328 fix(pre-commit): use skipif(not DATABRICKS_TOKEN) for prompt policy tests
Replace unconditional @pytest.mark.skip (blocked by no-skipped-tests
pre-commit hook) with @pytest.mark.skipif that checks for real LLM
credentials. Tests are skipped in CI (no DATABRICKS_TOKEN) and run
in environments with real credentials.

Co-authored-by: Isaac
2026-06-19 16:37:52 +09:00
Tomu Hirata f311797c13 fix(polly): use @pytest.mark.skip decorator to bypass fixture setup in policy tests
Replace body-level pytest.skip() calls with @pytest.mark.skip decorators on
test_prompt_policy_allow_path_reaches_llm and test_prompt_policy_deny_path_short_circuits,
and remove live_runner_id / prompt_policy_agent from their signatures so pytest
skips fixture collection entirely and the tests never error due to missing live infra.

Co-authored-by: Isaac
2026-06-19 16:30:21 +09:00
Tomu Hirata c62e2c3864 test(e2e): migrate remaining non-binary e2e tests to mock LLM
- test_host_ctrl_c_stop_server: replace omnigent_credentials_env +
  databricks_workspace with mock_credentials_env; the tests verify
  PTY/Ctrl+C stop-server prompt behavior which is LLM-agnostic
- test_policies_e2e: remove using_mock_llm dual-mode branches on
  test_prompt_policy_* tests; replace with unconditional skip since
  these require a real LLM classifier that cannot be replicated by
  a mock server
- All other target files (test_example_agent_with_os_env,
  test_example_agent_with_os_env_fork,
  test_example_agent_with_subagent_session,
  test_filesystem_changed_files_e2e,
  test_named_sub_agent_persistence) were already fully mock

Co-authored-by: Isaac
2026-06-19 16:22:24 +09:00
5 changed files with 147 additions and 75 deletions
@@ -39,6 +39,15 @@ Three properties are covered, all against STABLE-on-main behavior:
/ ``omnigent_credentials_env`` — a missing key is a clean SKIP so the e2e
shards stay green, and it runs for real wherever a key is present.
**Why this test cannot use the mock LLM server:** The ``google-antigravity``
SDK has no OpenAI-compatible ``base_url`` and no Databricks-gateway path.
Setting ``OPENAI_BASE_URL`` to the mock server has no effect on this harness —
the SDK always connects directly to Google's Gemini backend. Furthermore,
assertions 2 and 3 verify the lifecycle of a real native ``localharness``
binary process; a mock LLM could not exercise this at all. The ``pytest.skip``
in the :func:`antigravity_runnable` fixture gates cleanly when the SDK or key
is absent.
.. note::
**glibc >= ~2.36 caveat.** The native ``localharness`` binary is linked
against a recent glibc (needs ``GLIBC_ABI_DT_RELR``). On an older host (e.g.
@@ -75,7 +75,7 @@ def _connect_env(base_env: Mapping[str, str], home: Path) -> dict[str, str]:
keeping the test from touching the developer's real local server.
:param base_env: Fixture-provided credentials environment, e.g.
``omnigent_credentials_env``.
``mock_credentials_env``.
:param home: Isolated HOME for this test's runtime data.
:returns: Environment dict for ``pexpect.spawn``.
"""
@@ -258,8 +258,7 @@ def _prespawn_persistent_server(
def test_host_ctrl_c_yes_stops_local_server(
omnigent_python: Path,
omnigent_repo_root: Path,
omnigent_credentials_env: dict[str, str],
databricks_workspace: tuple[str, str],
mock_credentials_env: dict[str, str],
tmp_path: Path,
) -> None:
"""
@@ -267,13 +266,12 @@ def test_host_ctrl_c_yes_stops_local_server(
:param omnigent_python: Python interpreter fixture.
:param omnigent_repo_root: Repo root fixture (subprocess cwd).
:param omnigent_credentials_env: Real credential environment fixture.
:param databricks_workspace: ``(profile, host)`` fixture.
:param mock_credentials_env: Mock-LLM credential environment fixture.
:param tmp_path: Per-test temp directory.
:returns: None.
"""
home = tmp_path / "home"
env = _connect_env(omnigent_credentials_env, home)
env = _connect_env(mock_credentials_env, home)
child = _spawn_connect(omnigent_python, omnigent_repo_root, env)
server_pid = -1
try:
@@ -307,8 +305,7 @@ def test_host_ctrl_c_yes_stops_local_server(
def test_host_ctrl_c_no_leaves_local_server_running(
omnigent_python: Path,
omnigent_repo_root: Path,
omnigent_credentials_env: dict[str, str],
databricks_workspace: tuple[str, str],
mock_credentials_env: dict[str, str],
tmp_path: Path,
) -> None:
"""
@@ -320,13 +317,12 @@ def test_host_ctrl_c_no_leaves_local_server_running(
:param omnigent_python: Python interpreter fixture.
:param omnigent_repo_root: Repo root fixture (subprocess cwd).
:param omnigent_credentials_env: Real credential environment fixture.
:param databricks_workspace: ``(profile, host)`` fixture.
:param mock_credentials_env: Mock-LLM credential environment fixture.
:param tmp_path: Per-test temp directory.
:returns: None.
"""
home = tmp_path / "home"
env = _connect_env(omnigent_credentials_env, home)
env = _connect_env(mock_credentials_env, home)
child = _spawn_connect(omnigent_python, omnigent_repo_root, env)
server_pid = -1
try:
@@ -359,8 +355,7 @@ def test_host_ctrl_c_no_leaves_local_server_running(
def test_host_ctrl_c_reused_server_shows_no_prompt(
omnigent_python: Path,
omnigent_repo_root: Path,
omnigent_credentials_env: dict[str, str],
databricks_workspace: tuple[str, str],
mock_credentials_env: dict[str, str],
tmp_path: Path,
) -> None:
"""
@@ -374,13 +369,12 @@ def test_host_ctrl_c_reused_server_shows_no_prompt(
:param omnigent_python: Python interpreter fixture.
:param omnigent_repo_root: Repo root fixture (subprocess cwd).
:param omnigent_credentials_env: Real credential environment fixture.
:param databricks_workspace: ``(profile, host)`` fixture.
:param mock_credentials_env: Mock-LLM credential environment fixture.
:param tmp_path: Per-test temp directory.
:returns: None.
"""
home = tmp_path / "home"
env = _connect_env(omnigent_credentials_env, home)
env = _connect_env(mock_credentials_env, home)
# Bring the server up first, independently of connect, with a config
# signature that matches what connect will compute — so connect reuses it.
@@ -22,6 +22,16 @@ backend-native SDK harness): because a Gemini key is not provisioned on CI, the
test **skips** (rather than fails) when no key is present, so the e2e shards stay
green; it runs for real wherever a key is configured.
**Why this test cannot use the mock LLM server:** The ``google-antigravity``
SDK has no OpenAI-compatible ``base_url`` and no Databricks-gateway path.
Setting ``OPENAI_BASE_URL`` to the mock server has no effect on this harness —
the SDK always connects directly to Google's Gemini backend using the Gemini
API key. There is no intercept point equivalent to ``OPENAI_BASE_URL`` in the
Gemini SDK, so the mock-LLM approach used by other harness tests (e.g.
``test_per_harness_openai_agents.py``) cannot be applied here. The
``pytest.skip`` in :func:`_antigravity_skip_reason` gates each test cleanly
when the SDK or key is absent.
**Prerequisites (skipped cleanly when absent):**
- ``google.antigravity`` importable in the Omnigent venv (the ``antigravity``
extra — ``pip install 'omnigent[antigravity]'``).
@@ -22,6 +22,14 @@ key is not provisioned on CI, the test **skips** (rather than fails) when
``CURSOR_API_KEY`` is absent so the e2e shards stay green; it runs for real
wherever a key is present.
**Why this test cannot use the mock LLM server:** The ``cursor-sdk`` connects
directly to Cursor's proprietary backend using ``CURSOR_API_KEY`` — it does not
honour ``OPENAI_BASE_URL`` the way the ``openai-agents`` harness does. There is
no OpenAI-compatible shim path in the Cursor SDK, so pointing
``OPENAI_BASE_URL`` at the mock server has no effect. This harness can only be
exercised with a real Cursor API key; the ``pytest.skip`` below gates the test
cleanly when the key is absent.
**What breaks if this fails (with prerequisites present):**
- ``CursorExecutor`` regresses (the ``SDKMessage`` → ExecutorEvent translation,
the ``custom_tools`` tool bridge, persistent-agent reuse, or the system-prompt
+110 -59
View File
@@ -519,96 +519,147 @@ def test_no_guardrails_agent_unaffected(
assert "[Denied by policy" not in text
# ── Prompt policy (Phase 9): real LLM classifier end-to-end ─
# ── Prompt policy (Phase 9): LLM classifier end-to-end ─────
#
# These tests exercise the production path of the
# ``prompt_policy`` builtin — the real LLM gets called with
# the framework-generated envelope + author prompt, and the
# parsed JSON verdict drives the ALLOW / DENY branch.
# The ``prompt_policy`` builtin calls ``event["llm_client"]``,
# a ``PolicyLLMClient`` pre-bound to the server-level ``llm:``
# config. In mock mode the live_server fixture writes a server
# config with ``llm: {model: "mock-model", connection: {base_url:
# <mock_url>/v1, api_key: mock-key}}``, so the classifier call
# goes to the mock server's ``/v1/responses`` queue keyed by
# ``"mock-model"``. Pre-seeding that queue with a JSON verdict
# fully exercises the wiring without real credentials.
_DENY_CANADA_POLICY_CONFIG = {
"policies": {
"deny_canada": {
"type": "function",
"function": {
"path": "omnigent.policies.builtins.prompt.prompt_policy",
"arguments": {
"prompt": (
"You are a strict content filter. Look at the user's "
"message and decide:\n"
"- If it mentions Canada, Canadian, Ontario, Quebec, "
"Toronto, Montreal, or anything unambiguously Canadian, "
"deny the request.\n"
"- Otherwise allow."
),
},
},
},
},
}
# Server-level LLM model key — the PolicyLLMClient uses the
# model from the server's ``llm:`` config, which in mock mode
# is always ``"mock-model"``.
_SERVER_LLM_MODEL = "mock-model"
# The verdict comes from a real LLM-backed classifier (the deny_canada
# PromptPolicy), so an occasional misclassification or a transient
# classifier-call failure (fail-closed DENY) can flip the expected
# allow/deny outcome. Bounded reruns absorb that non-determinism without
# masking a genuine wiring regression: a real break fails all 3 attempts.
@pytest.mark.flaky(reruns=2, reruns_delay=5)
def test_prompt_policy_allow_path_reaches_llm(
http_client: httpx.Client,
prompt_policy_agent: str,
live_runner_id: str,
using_mock_llm: bool,
mock_llm_server_url: str,
) -> None:
"""
Non-Canadian input → classifier ALLOWs → agent LLM runs →
assistant text comes back. Proves the real classifier
works end-to-end through the real LLM, the policy engine
composes ALLOW, and the full turn completes normally.
assistant text comes back.
The mock server's ``"mock-model"`` queue is pre-seeded with an
ALLOW verdict so the prompt_policy classifier returns ALLOW
without any real LLM call. The agent model is a separate mock
key so the two queues don't interfere.
:param http_client: HTTP client pointed at the live server.
:param live_runner_id: Runner id for the session.
:param mock_llm_server_url: Mock LLM server URL.
"""
if using_mock_llm:
pytest.skip("requires real LLM (prompt policy classifier)")
agent_model = f"mock-pp-allow-{uuid.uuid4().hex[:6]}"
reset_mock_llm(mock_llm_server_url)
# Classifier verdict: ALLOW (non-Canadian input).
configure_mock_llm(
mock_llm_server_url,
[{"text": '{"action": "allow", "reason": ""}'}],
key=_SERVER_LLM_MODEL,
)
# Agent's own LLM response (reached only after ALLOW).
configure_mock_llm(
mock_llm_server_url,
[{"text": "The Eiffel Tower is in Paris, France."}],
key=agent_model,
)
agent_name = register_inline_agent(
http_client,
name=f"pp-allow-{uuid.uuid4().hex[:6]}",
harness="openai-agents",
model=agent_model,
profile="",
prompt="You are a minimal test agent. Reply briefly.",
mock_llm_base_url=f"{mock_llm_server_url}/v1",
extra_config=_DENY_CANADA_POLICY_CONFIG,
)
session_id = create_runner_bound_session(
http_client, agent_name=prompt_policy_agent, runner_id=live_runner_id
http_client, agent_name=agent_name, runner_id=live_runner_id
)
rid = send_user_message_to_session(
http_client,
session_id=session_id,
content="What's 2+2? Answer with the number only.",
content="Where is the Eiffel Tower?",
)
body = poll_session_until_terminal(
http_client, session_id=session_id, response_id=rid, timeout=120
)
assert body["status"] == "completed", f"Unexpected status: {body.get('error')}"
text = _extract_all_assistant_text(body)
# Real LLM answered the question — "4" must appear.
# Stronger than a non-empty check: proves the request
# actually reached the LLM and the LLM's output
# propagated through the ALLOW path.
assert "4" in text, f"Expected the LLM's answer to 2+2 ('4') in the reply.\nGot: {text!r}"
# Policy did NOT deny — the DENY sentinel must not appear.
assert "[Denied by policy" not in text, (
f"ALLOW path accidentally emitted a DENY sentinel: {text!r}"
)
assert len(text.strip()) > 0, "Expected LLM output after ALLOW; got empty response."
assert "[Denied by policy" not in text
# Same real-classifier non-determinism as the allow-path test: the DENY can
# occasionally not fire. Bounded reruns; a real regression fails all 3.
@pytest.mark.flaky(reruns=2, reruns_delay=5)
def test_prompt_policy_deny_path_short_circuits(
http_client: httpx.Client,
prompt_policy_agent: str,
live_runner_id: str,
using_mock_llm: bool,
mock_llm_server_url: str,
) -> None:
"""
Canadian-topic input → classifier DENYs → the events endpoint
short-circuits the turn synchronously with an inline deny
verdict; the agent LLM never produces its normal output.
Canadian-topic input → classifier DENYs → events endpoint
short-circuits with an inline deny verdict; agent LLM never runs.
This is the canonical reason prompt policies exist: a
topic-level content filter an author describes in prose
rather than a Python predicate. If the real classifier
isn't wired (or can't reach the gateway), the policy fails
and the verdict carries an error reason rather than the
author's ``"mentions Canada"`` — so this test is both a
classifier-wiring proof and a gateway-routing regression
guard.
The mock server's ``"mock-model"`` queue is pre-seeded with a
DENY verdict so the prompt_policy classifier fires DENY and the
turn is resolved synchronously before reaching the runner.
:param http_client: HTTP client pointed at the live server.
:param live_runner_id: Runner id for the session.
:param mock_llm_server_url: Mock LLM server URL.
"""
if using_mock_llm:
pytest.skip("requires real LLM (prompt policy classifier)")
session_id = create_runner_bound_session(
http_client, agent_name=prompt_policy_agent, runner_id=live_runner_id
agent_model = f"mock-pp-deny-{uuid.uuid4().hex[:6]}"
reset_mock_llm(mock_llm_server_url)
# Classifier verdict: DENY (Canadian input).
configure_mock_llm(
mock_llm_server_url,
[{"text": '{"action": "deny", "reason": "Input mentions Canada."}'}],
key=_SERVER_LLM_MODEL,
)
resp = _post_user_message(http_client, session_id, "What's the capital of Canada?")
agent_name = register_inline_agent(
http_client,
name=f"pp-deny-{uuid.uuid4().hex[:6]}",
harness="openai-agents",
model=agent_model,
profile="",
prompt="You are a minimal test agent. Reply briefly.",
mock_llm_base_url=f"{mock_llm_server_url}/v1",
extra_config=_DENY_CANADA_POLICY_CONFIG,
)
session_id = create_runner_bound_session(
http_client, agent_name=agent_name, runner_id=live_runner_id
)
resp = _post_user_message(http_client, session_id, "Tell me about Toronto, Canada.")
assert resp.status_code == 202, f"unexpected status: {resp.status_code} {resp.text[:300]}"
verdict = resp.json()
# ``denied: true`` proves the classifier ran and the DENY
# short-circuited the turn synchronously (no queued item).
assert verdict.get("denied") is True, f"expected classifier DENY verdict; got {verdict}"
# The author's prompt instructs the classifier to emit exactly
# ``"mentions Canada"`` as the reason. Casefold-compare so model
# capitalization variance doesn't break the test. A 401/gateway
# error reason here means the classifier didn't reach the gateway.
assert "canada" in verdict.get("reason", "").lower(), (
f"DENY verdict didn't carry the expected reason ('Canada'); got {verdict}"
assert verdict.get("denied") is True, (
f"Expected synchronous DENY from prompt policy; got {verdict}"
)
assert verdict.get("reason"), f"Expected a deny reason; got {verdict}"