Compare commits

...

2 Commits

Author SHA1 Message Date
Pat Sukprasert 7dd2321d77 test(e2e): migrate pi skills-filter test to live session flow; quarantine harness round-trip[pi]
Enabling pi in CI surfaced two `[pi]` rows that previously skipped (pi
CLI absent in CI):

- `test_pi_skills_filter_e2e.py` was a stale straggler: it POSTed to the
  removed stateless `/v1/responses` endpoint (404) instead of the live
  session flow its codex sibling already uses. Rather than delete it
  (losing pi's only end-to-end skill-loading coverage while codex keeps
  its equivalent), migrate it to mirror `test_codex_skills_filter_e2e.py`:
  `create_runner_bound_session` + `send_user_message_to_session` +
  `poll_session_until_terminal`, with a module-level `skipif` on
  `cli_unavailable_reason("pi")` and a `--profile` gate. It now skips
  cleanly in mock CI (no `--profile`) and runs live in `--profile` /
  nightly contexts, pinning that pi's `--skill`/`--no-skills` flags are
  actually honored (the arg construction is separately unit-pinned by
  `test_resolve_pi_skill_args_*`).

- `test_run_harness_without_agent_live_repl_round_trip[pi]`: quarantined
  under #523, same `no-agent-harness-roundtrip-hang` family as the
  already-quarantined [claude-sdk]/[codex]/[openai-agents] siblings.

Co-authored-by: Isaac
2026-06-19 18:57:07 +08:00
Pat Sukprasert 5e8f94187c test(e2e): enable pi harness in CI; fix coding-supervisor[pi] mock routing
CI intentionally omitted the pi CLI, so every `[pi]` e2e row skipped via
`skip_if_harness_cli_missing` — pi had zero e2e coverage and regressions
(like #807) went uncaught. This enables pi and fixes the one test that
mis-routed pi.

- `.github/ci-deps/package.json`: add `@earendil-works/pi-coding-agent`
  (pinned 0.75.5). pi has no install scripts and ships a prebuilt CLI, so
  the existing `npm install --ignore-scripts` + PATH line make it runnable;
  no explicit postinstall step needed. Updated the `e2e.yml` comment.
- `test_example_coding_supervisor_with_forks[pi]`: was feeding pi the real
  `databricks-*` model, so pi inspected the name and switched to gateway
  mode (real auth, ignoring the mock's OPENAI_BASE_URL) and failed. Now
  uses a per-harness `mock-*` key (matching test_per_harness_pi), keeping
  pi in mock mode. All four harness rows pass locally.
- `known_failures.yaml`: bump the `test_yaml_agent_with_tools[pi]` entry
  from `issue: 0` to `issue: 807` and refresh its reason (it now runs in
  CI but stays quarantined for the real tool-dispatch bug).

After the coding-supervisor fix, the only failing pi row is the
quarantined #807 one, so enabling pi in CI is green. Local `npm install`
validation was blocked by sandbox network restrictions; the CI install
step is the definitive check.

Co-authored-by: Isaac
2026-06-19 18:07:45 +08:00
5 changed files with 180 additions and 125 deletions
+2 -1
View File
@@ -2,9 +2,10 @@
"name": "e2e-ci-deps",
"version": "0.0.0",
"private": true,
"description": "Pinned npm CLIs the e2e workflow installs (claude-code, codex).",
"description": "Pinned npm CLIs the e2e workflow installs (claude-code, codex, pi).",
"dependencies": {
"@anthropic-ai/claude-code": "2.1.124",
"@earendil-works/pi-coding-agent": "0.75.5",
"@openai/codex": "0.139.0"
}
}
+3 -2
View File
@@ -159,8 +159,9 @@ jobs:
# --ignore-scripts to block postinstall on every package. The
# claude-code stub binary needs its install.cjs (audited:
# platform detect + same-tree hardlink, no network/exec) so we run
# that one explicitly; codex has no postinstall; pi is intentionally
# absent (its e2e rows skip via skip_if_harness_cli_missing).
# that one explicitly; codex and pi have no install scripts and
# ship prebuilt CLIs, so --ignore-scripts + the PATH line below
# make them runnable directly.
#
# bubblewrap: the linux_bwrap sandbox backend fails loud if `bwrap`
# is missing, and the e2e runner runs real agents with os_env. The
@@ -64,8 +64,14 @@ def test_coding_supervisor_with_forks_one_shot(
response queues.
:param harness: The harness identifier from
:data:`HARNESS_HARNESS_MODELS`.
:param model: The harness-routed model identifier.
:param model: Unused — replaced by a per-harness mock key below.
The real model from :data:`HARNESS_HARNESS_MODELS` would put
the ``pi`` harness into gateway mode (pi inspects the
``databricks-*`` model name and switches to real-gateway auth,
ignoring the mock's ``OPENAI_BASE_URL``); a ``mock-*`` key keeps
every harness routed through the mock LLM server.
"""
del model # replaced by mock_model below
if harness == "claude-sdk":
require_claude_sdk()
if which("claude") is None:
@@ -79,6 +85,10 @@ def test_coding_supervisor_with_forks_one_shot(
if which("pi") is None:
pytest.skip("pi harness prerequisite missing: 'pi' CLI not on PATH.")
# Per-harness mock key so concurrent harness rows get isolated mock
# response queues, and so ``pi`` stays in mock mode rather than
# gateway-routing a ``databricks-*`` model name.
mock_model = f"mock-coding-supervisor-{harness}"
# Pre-seed the mock queue with enough canned replies to cover the
# supervisor turn plus both worker sub-agent turns and any auto-wake.
reset_mock_llm(mock_llm_server_url)
@@ -90,7 +100,7 @@ def test_coding_supervisor_with_forks_one_shot(
{"text": "Worker B finished."},
{"text": "Both workers done. Summary: OK."},
],
key=model,
key=mock_model,
)
result = run_one_shot(
omnigent_python=omnigent_python,
@@ -98,6 +108,6 @@ def test_coding_supervisor_with_forks_one_shot(
omnigent_credentials_env=mock_credentials_env,
example_name="coding_supervisor_with_forks",
harness=harness,
model=model,
model=mock_model,
)
assert_completed_one_shot(result, "coding_supervisor_with_forks")
+154 -117
View File
@@ -1,14 +1,14 @@
"""E2E test: pi executor's ``skills:`` field actually filters
which skills the agent sees.
"""E2E test: pi executor's ``skills:`` field actually filters which
skills the agent sees.
The Pi executor translates ``skills_filter`` into Pi CLI args at
construction time (``_resolve_pi_skill_args``):
- ``"all"`` → ``--skill <bundle_path>`` for each bundle skill,
no ``--no-skills`` (host auto-discovery on).
- ``"all"`` → ``--skill <bundle_path>`` for each bundle skill, no
``--no-skills`` (Pi host auto-discovery stays on).
- ``"none"`` → ``["--no-skills"]`` (suppresses everything).
- list[name] → ``["--no-skills"] + ["--skill", <bundle_path>]`` for
each named bundle skill (silent skip for missing).
- list[name] → ``["--no-skills"]`` plus one ``--skill <bundle_path>``
per named bundle skill (silent skip for missing names).
This test parametrizes the three filter modes against three fixture
agent bundles whose ``skills/`` subdir ships two distinctively-named
@@ -17,17 +17,21 @@ SKILL.md files:
- ``pi-e2e-xyz-greet-c4a8d5``
- ``pi-e2e-xyz-count-d2f6e1``
(Hyphens, not underscores — Pi's skill spec requires names to be
``^[a-z0-9-]+$`` with no other characters.)
(Hyphens, not underscores — Pi's skill spec requires hyphenated
directory names.) The unique suffixes (``c4a8d5`` / ``d2f6e1``) are
unforgable — the model cannot hallucinate them, so a string match in
the agent's enumerated output is unambiguous proof Pi actually loaded
that skill.
The unique suffixes (``c4a8d5`` / ``d2f6e1``) are unforgable — the
LLM cannot hallucinate them, so a string match in the agent's
enumerated output is unambiguous proof Pi actually loaded that skill.
This is the live sibling of ``test_codex_skills_filter_e2e.py`` and
drives the same live-server session flow. The Pi arg-resolution logic
itself is additionally unit-pinned in
``tests/inner/test_pi_executor.py::test_resolve_pi_skill_args_*``.
Usage::
pytest tests/e2e/test_pi_skills_filter_e2e.py \
--llm-api-key $LLM_API_KEY --profile test-profile -v
--profile <databricks-profile> -v
"""
from __future__ import annotations
@@ -41,32 +45,35 @@ import pytest
import yaml
from tests.e2e._harness_probes import cli_unavailable_reason
from tests.e2e.conftest import poll_until_terminal, upload_agent
from tests.e2e.conftest import (
create_runner_bound_session,
poll_session_until_terminal,
send_user_message_to_session,
upload_agent,
)
_FIXTURE_ROOT = Path(__file__).resolve().parent.parent / "resources" / "agents"
# The two bundled skill names. Suffixes are intentionally
# distinctive so the assertions are unambiguous — if these strings
# show up in the model's response, Pi genuinely surfaced them.
_GREET_NAME = "pi-e2e-xyz-greet-c4a8d5"
_COUNT_NAME = "pi-e2e-xyz-count-d2f6e1"
_pytest_pi_unavailable = cli_unavailable_reason("pi")
pytestmark = pytest.mark.skipif(
_pytest_pi_unavailable is not None,
(reason := cli_unavailable_reason("pi")) is not None,
reason=(
"pi skills e2e requires a runnable 'pi' CLI; "
f"{_pytest_pi_unavailable}. Install/fix Pi to run this test."
f"pi skills e2e requires a runnable 'pi' CLI; {reason}. Install/fix Pi to run this module."
),
)
# The two bundled skill names. Suffixes are intentionally distinctive
# so the assertions are unambiguous — if these strings show up in the
# model's response, Pi genuinely surfaced them.
_GREET_NAME = "pi-e2e-xyz-greet-c4a8d5"
_COUNT_NAME = "pi-e2e-xyz-count-d2f6e1"
def _extract_all_text(body: dict[str, Any]) -> str:
"""
Concatenate all ``output_text`` blocks from a response body.
Concatenate all message text blocks from a response body.
:param body: The terminal response body returned by
:func:`tests.e2e.conftest.poll_until_terminal`.
:func:`tests.e2e.conftest.poll_session_until_terminal`.
:returns: All assistant text joined by newlines.
"""
parts: list[str] = []
@@ -79,6 +86,59 @@ def _extract_all_text(body: dict[str, Any]) -> str:
return "\n".join(parts)
def _enumerate_skills_with_retry(
http_client: httpx.Client,
session_id: str,
*,
max_attempts: int = 3,
) -> str:
"""
Enumerate the agent's skills, retrying on a transient empty turn.
A harness occasionally completes a turn with ``output: []`` — an
empty model completion that carries no skill info, so resend up to
*max_attempts* times until the agent produces text. This can't mask
a broken filter: wrong-skill output breaks the loop on the first
non-empty turn (assertions fire as normal), and a filter that stays
empty drains the retries and fails ``expected_visible`` on empty
text.
:returns: The first non-empty assistant text, else the last
(empty) attempt.
"""
content = (
"List every skill name available to you in this session. "
"Output ONLY the names, one per line, exactly as they appear "
"in your environment — do not paraphrase, do not abbreviate, "
"do not invent skills you do not see. If you have no skills, "
"output the literal string `NO_SKILLS_LOADED`."
)
body: dict[str, Any] = {}
text = ""
for _ in range(max_attempts):
response_id = send_user_message_to_session(
http_client,
session_id=session_id,
content=content,
)
body = poll_session_until_terminal(
http_client,
session_id=session_id,
response_id=response_id,
timeout=120,
)
# A non-completed / errored turn is a genuine failure (harness
# crash, auth error), not the empty-completion flake — surface
# it immediately rather than burning retries on it.
assert body["status"] == "completed", (
f"agent run failed: status={body.get('status')!r} error={body.get('error')!r}"
)
text = _extract_all_text(body)
if text.strip():
break
return text
def _materialize_with_profile(
src_dir: Path,
dst_dir: Path,
@@ -87,18 +147,18 @@ def _materialize_with_profile(
"""
Copy a fixture agent bundle and inject the Databricks profile.
Mirror of the codex e2e variant. The fixture YAMLs intentionally
omit ``executor.profile`` so the same fixtures work across
developers with different ``~/.databrickscfg`` profile names. At
test time we materialize a per-test copy with the actual
``--profile`` baked in. Without a profile the Pi harness wrap
can't authenticate with the Databricks gateway and the agent run
fails before skills are even consulted.
The fixture YAMLs intentionally omit ``executor.profile`` so the
same fixtures work across developers with different
``~/.databrickscfg`` profile names. At test time we materialize a
per-test copy with the actual ``--profile`` baked in. Without a
profile the Pi harness wrap can't authenticate with the Databricks
gateway and the agent run fails before skills are even consulted.
:param src_dir: Path to the fixture under
``tests/resources/agents/pi_skills_*/``.
:param dst_dir: Tmp directory to copy into.
:param profile: Databricks profile name from ``--profile``.
:param profile: Databricks profile name from ``--profile``, e.g.
``"test-profile"``.
:returns: The materialized bundle directory ready for
:func:`upload_agent`.
"""
@@ -116,10 +176,6 @@ def pi_profile(request: pytest.FixtureRequest) -> str:
"""
Return the ``--profile`` CLI arg, or skip if not provided.
This test is not parametrized over a ``harness`` argument, so the autouse
harness gate in ``tests.e2e.conftest`` cannot infer that the Pi executor is
required. The module-level ``pytestmark`` handles the Pi CLI prerequisite.
:param request: Pytest request object.
:returns: The Databricks profile name.
:raises pytest.skip.Exception: If ``--profile`` was not passed.
@@ -138,21 +194,21 @@ def pi_profile(request: pytest.FixtureRequest) -> str:
"fixture, expected_visible, expected_hidden",
[
# ``skills: all`` → both bundled skills exposed via
# ``--skill <path>`` flags. Failure mode: resolver drops
# bundle source, env-var bridge drops BUNDLE_DIR, or the
# ``"all"`` branch is broken. Any of these would leave the
# agent with zero bundle skills and the ``in text``
# ``--skill <path>`` flags. Failure mode: the resolver drops
# the bundle source, the env-var bridge drops the bundle dir,
# or the ``"all"`` branch is broken. Any of these would leave
# the agent with zero bundle skills and the ``in text``
# assertion would fail.
(
"pi_skills_all",
[_GREET_NAME, _COUNT_NAME],
[],
),
# ``skills: none`` → ``--no-skills`` flag suppresses both
# auto-discovery and explicit skills. Failure mode: env-var
# bridge drops SKILLS_FILTER, harness wrap defaults to
# ``"all"``, resolver's ``"none"`` branch emits stray
# ``--skill`` flags. Any of these would leak bundle skills
# ``skills: none`` → ``--no-skills`` suppresses both
# auto-discovery and explicit skills. Failure mode: the
# env-var bridge drops the filter, the harness wrap defaults
# to ``"all"``, or the ``"none"`` branch emits stray
# ``--skill`` flags. Any of these would leak the bundle skills
# and the ``not in text`` assertion would fail.
(
"pi_skills_none",
@@ -160,10 +216,10 @@ def pi_profile(request: pytest.FixtureRequest) -> str:
[_GREET_NAME, _COUNT_NAME],
),
# ``skills: [greet]`` → ``--no-skills`` plus exactly one
# ``--skill`` for the named bundle skill. Failure mode:
# per-name filter doesn't apply (counter leaks), or filter
# applies but emits the wrong path (greet missing). Either
# is caught by one of the two assertions.
# ``--skill`` for the named bundle skill. Failure mode: the
# per-name filter doesn't apply (counter leaks), or it applies
# but emits the wrong path (greet missing). Either is caught by
# one of the two assertions.
(
"pi_skills_list",
[_GREET_NAME],
@@ -172,8 +228,9 @@ def pi_profile(request: pytest.FixtureRequest) -> str:
],
)
def test_pi_skills_filter_e2e(
pi_profile: str,
http_client: httpx.Client,
pi_profile: str,
live_runner_id: str,
fixture: str,
expected_visible: list[str],
expected_hidden: list[str],
@@ -182,97 +239,77 @@ def test_pi_skills_filter_e2e(
"""
Pi's ``skills:`` filter actually controls what the model sees.
Live e2e regression-pin for the pi skills bridge. Loaded with
deterministic-name fixtures (suffixes unforgable by the LLM) so
the assertions can string-match without an LLM judge: the
presence of ``pi_e2e_xyz_greet_c4a8d5`` in the model's output is
unambiguous proof Pi loaded that SKILL.md.
Live e2e regression-pin for the Pi skills bridge. Loaded with
deterministic-name fixtures (suffixes unforgable by the LLM) so the
assertions can string-match without an LLM judge: the presence of
``pi-e2e-xyz-greet-c4a8d5`` in the model's output is unambiguous
proof Pi actually loaded that skill via the ``--skill`` flag its
``_resolve_pi_skill_args`` emitted.
**What breaks if the feature is wrong:**
- If ``_resolve_pi_skill_args`` doesn't emit ``--skill`` flags
for bundle skills (e.g. the bundle source is dropped), the
``"all"`` and ``"list"`` cases find no bundle-skill names
``expected_visible`` assertion fires with a clear "not
visible" message.
- If ``_resolve_skills_filter`` defaults to ``"all"`` when the
AP-side env-var bridge breaks (the original pre-fix
regression), the ``"none"`` case leaks bundle skills →
- If the ``"all"`` branch drops the bundle source (or the
AP→harness env-var bridge drops the bundle dir / filter), the
``"all"`` and ``"list"`` cases find no bundle-skill names in the
output → ``expected_visible`` assertion fires.
- If the filter defaults to ``"all"`` when the env-var bridge
breaks, the ``"none"`` case leaks bundle skills →
``expected_hidden`` assertion fires with the leaked name.
- If the per-name list filter is broken (matches everything or
matches nothing), the ``"list"`` case fires either branch.
nothing), the ``"list"`` case fires either branch.
Each of those breakages produces a specific failure message
naming the offending skill so a regression triage can jump
straight to the right layer.
Each breakage produces a specific failure message naming the
offending skill, so triage can jump straight to the right layer.
:param http_client: The session-scoped ``httpx.Client`` from
``tests.e2e.conftest``, pointed at a live Omnigent server.
:param pi_profile: Databricks profile from ``--profile``.
:param fixture: Fixture agent dir name; selects the spec's
``skills:`` value.
:param expected_visible: Bundled skill names that MUST appear
in the agent's output.
:param expected_hidden: Bundled skill names that MUST NOT
appear in the agent's output (scoped to our fixture's
distinctive names so the user's host-installed Pi
extensions don't pollute the assertion).
:param tmp_path: Pytest-provided per-test tmpdir for the
materialized bundle.
:param pi_profile: Databricks profile name from ``--profile``.
:param live_runner_id: Id of the live runner the session binds to.
:param fixture: Name of the fixture agent dir under
``tests/resources/agents/`` whose ``skills:`` value determines
what the agent is allowed to see.
:param expected_visible: Bundled skill names that MUST appear in
the agent's output (string contains).
:param expected_hidden: Bundled skill names that MUST NOT appear in
the agent's output (string-not-contains, scoped to our
fixture's distinctive names so the user's host skills don't
pollute the assertion).
"""
bundle = _materialize_with_profile(_FIXTURE_ROOT / fixture, tmp_path, pi_profile)
agent = upload_agent(http_client, bundle)
resp = http_client.post(
"/v1/responses",
json={
"model": agent,
"input": (
"List every skill name available to you in this session. "
"Output ONLY the names, one per line, exactly as they appear "
"in your environment — do not paraphrase, do not abbreviate, "
"do not invent skills you do not see. If you have no skills, "
"output the literal string `NO_SKILLS_LOADED`."
),
"background": True,
},
session_id = create_runner_bound_session(
http_client,
agent_name=agent,
runner_id=live_runner_id,
)
resp.raise_for_status()
response_id = resp.json()["id"]
text = _enumerate_skills_with_retry(http_client, session_id)
body = poll_until_terminal(http_client, response_id, timeout=120)
assert body["status"] == "completed", (
f"agent run failed: status={body.get('status')!r} error={body.get('error')!r}"
)
text = _extract_all_text(body)
# Visibility assertions — the listed skill names MUST appear.
# If a name is absent, Pi didn't see that skill in its session,
# which means either the resolver didn't emit a ``--skill``
# flag for it or the env-var bridge dropped
# ``HARNESS_PI_BUNDLE_DIR`` / ``HARNESS_PI_SKILLS_FILTER``.
# Visibility assertions — the listed skill names MUST appear. If a
# name is absent, Pi didn't load that skill, which means either the
# resolver didn't emit ``--skill`` for the bundle source or the
# AP→harness env-var bridge dropped the bundle dir / filter.
for name in expected_visible:
assert name in text, (
f"fixture={fixture!r}: bundle skill {name!r} should be visible "
f"to the agent but didn't appear in the enumerated output. "
f"Likely the pi resolver didn't emit --skill flags for the "
f"bundle, or the APharness env-var bridge dropped "
f"HARNESS_PI_BUNDLE_DIR / HARNESS_PI_SKILLS_FILTER. "
f"Agent output:\n{text[:1500]}"
f"Likely the Pi resolver didn't emit ``--skill`` for the bundle "
f"source, or the AP->harness env-var bridge dropped the bundle "
f"dir / skills filter. Agent output:\n{text[:1500]}"
)
# Suppression assertions — the listed names MUST NOT appear.
# Scoped to the distinctive ``c4a8d5`` / ``d2f6e1`` suffixes;
# any host-installed Pi extension with a different name won't
# match so the assertion stays clean.
# Suppression assertions — the listed names MUST NOT appear. We
# only assert on OUR distinctive skill names (with the ``c4a8d5`` /
# ``d2f6e1`` suffix); the user's host skills may legitimately
# surface in the output but won't match our suffixed names, so the
# assertion stays clean.
for name in expected_hidden:
assert name not in text, (
f"fixture={fixture!r}: bundle skill {name!r} should be HIDDEN "
f"from the agent but appeared in the output. The "
f"``skills: {fixture.removeprefix('pi_skills_')!r}`` filter "
f"didn't suppress this skill — likely the env-var bridge "
f"dropped HARNESS_PI_SKILLS_FILTER, the harness wrap fell "
f'back to ``"all"``, or the resolver emitted stray '
f"``--skill`` flags. Agent output:\n{text[:1500]}"
f'dropped the filter, the harness wrap fell back to ``"all"``, '
f"or the per-name filter isn't checking each name. "
f"Agent output:\n{text[:1500]}"
)
+8 -2
View File
@@ -53,6 +53,12 @@ skips:
cluster: no-agent-harness-roundtrip-hang
mode: skip
- id: tests/e2e/omnigent/test_run_harness_without_agent_e2e.py::test_run_harness_without_agent_live_repl_round_trip[pi]
reason: "No-AGENT `omnigent run --harness` live round-trip crashes the xdist worker — same family as the [claude-sdk]/[codex]/[openai-agents] siblings. Surfaced once pi was enabled in CI (PR #809); previously the [pi] row skipped because the pi CLI was absent. See [claude-sdk] entry for the full diagnosis."
issue: 523
cluster: no-agent-harness-roundtrip-hang
mode: skip
- id: tests/e2e/omnigent/test_repl_session_lifecycle.py::test_repl_effort_command_persists_session_metadata
reason: "REPL session-lifecycle / pexpect cluster."
issue: 523
@@ -108,7 +114,7 @@ skips:
mode: skip
- id: tests/e2e/omnigent/test_yaml_hello_world.py::test_yaml_agent_with_tools[pi]
reason: "pi harness does not complete the headless `-p` tool round-trip: it makes only ONE LLM request (receives the forced calculate tool_call) then exits 0 with empty stdout — the tool is never dispatched and no follow-up call is made. Distinct from #677 (stale stdout-marker expectation, now fixed for the claude-sdk/codex/openai-agents rows); reproduces only locally since pi's CLI is absent in CI (row skipped there). Needs a tracking issue + pi headless-dispatch investigation."
issue: 0
reason: "pi harness does not complete the headless `-p` tool round-trip: it makes only ONE LLM request (receives the forced calculate tool_call) then exits 0 with empty stdout — the tool is never dispatched and no follow-up call is made. Distinct from #677 (stale stdout-marker expectation, fixed for the claude-sdk/codex/openai-agents rows). See #807 for the full diagnosis."
issue: 807
cluster: pi-headless-tool-roundtrip
mode: skip