Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion tests/e2e/baseline/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ The topology is guarded two ways so a mis-wired test never false-greens:
| Run the same scenario across every adapter | `@per_adapter()` → `agent` (id via `agent.adapter_id`) |
| Run a scenario across a subset | `@per_adapter(Adapter.X, Adapter.Y)` (positional = include) / `@per_adapter(exclude= by id, supports=/without={Capability.MEMORY} by capability, or runs_tool_loop=True for the custom-tool subgroup)`; add `prompt=`/`features=` (or `**MEMORY_AGENT`) to steer |
| Drive the agent lifecycle myself (build-only / reboot / rehydration) | `@per_adapter()` → `cell` (`cell.build()` / `cell.provision()` / `cell.run_as()` — see **AdapterCell**) |
| Clean up what I created | nothing: `resource_manager` reaps on teardown (`BAND_E2E_AUTOCLEAN=false` keeps it for debugging) |
| Clean up what I created | nothing: `resource_manager` reaps on teardown (`BAND_E2E_AUTOCLEAN=false` keeps it for debugging). Each provision gets its own name, so a missed reap cannot block the next cell. |
| Drive the platform as a user | the `user_ops` fixture (`UserOps`) |
| List who the user could invite to a room | `await user_ops.lookup_peers(not_in_room=room_id)` → `list[Peer]` (the invitable roster; `peer_type="Agent"` narrows) |
| Drive a peer agent (e.g. the `Echo` bounce) | `await resource_manager.peer(peer).send_message(room_id, "ECHO: ...", mention_id=agent.id, mention_name=agent.name)` — posts as that agent, returns the message id to barrier on (needs the peer already in the room) |
Expand Down
12 changes: 6 additions & 6 deletions tests/e2e/baseline/fixtures/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,18 +113,18 @@ async def resource_manager(
) -> AsyncGenerator[ResourceManager, None]:
"""Per-test provision/reap driver.

Teardown force-deletes everything provisioned this run, unless
``BAND_E2E_AUTOCLEAN`` is false (kept for on-purpose debugging; surviving
ids are logged by ``reap_all``/``provision_*``).
Teardown force-deletes everything provisioned this run via
``reap_on_exit``, unless ``BAND_E2E_AUTOCLEAN`` is false (kept for
on-purpose debugging; surviving ids are logged by ``reap_all`` /
``provision_*``).
"""
resources = ResourceManager(
user_client=baseline_user_client,
settings=baseline_settings,
run_id=baseline_run_id,
)
yield resources
if baseline_settings.run.autoclean:
await resources.reap_all()
async with resources.reap_on_exit(enabled=baseline_settings.run.autoclean):
yield resources


@pytest.fixture(scope="session", autouse=True)
Expand Down
90 changes: 73 additions & 17 deletions tests/e2e/baseline/toolkit/provisioning.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,29 +69,68 @@
# silently truncating a handle.
MAX_MENTIONED_LABEL_LEN = len("invitable") # 9

# Platform ``validate_length(:name, max: 100)``. A longer register is rejected
# for length, which is a different 422 from name-taken.
AGENT_NAME_MAX = 100

# ``uuid4().hex`` width. Labels that may exceed the mention cap take the whole
# uuid so a leftover row cannot collide with the next provision of that label.
NAME_TOKEN_HEX = 32


def run_id_len() -> int:
"""Hex length for a run id that keeps the longest @mentioned peer name within
the handle cap: ``CAP - len(prefix) - len("-") - longest mentioned label``."""
return MENTION_HANDLE_CAP - len(NAME_PREFIX) - 1 - MAX_MENTIONED_LABEL_LEN


def normalized_label(label: str) -> str:
return label.replace("_", "-")


def agent_name(*, run_id: str, label: str) -> str:
"""The provisioned name for ``label`` in run ``run_id``.
"""The provisioned name for ``label`` and name token ``run_id``.

Underscores become hyphens: models rewrite ``omp_acp`` as ``omp-acp`` when
repeating a name, so an adapter-id label must already be in the form they echo.
"""
return f"{NAME_PREFIX}{run_id}-{label.replace('_', '-')}"
return f"{NAME_PREFIX}{run_id}-{normalized_label(label)}"


def name_token(label: str) -> str:
"""A fresh hex token for one provision of ``label``.

A peer a scenario @mentions only surfaces its full name inside
``MENTION_HANDLE_CAP``, so that token uses whatever width the cap still
leaves. Adapter-id labels already pass the cap and take a full uuid slice:
every cell of one adapter would otherwise register the same name, and a
row left behind makes the next cell's register fail with name-taken.
"""
normalized = normalized_label(label)
if len(normalized) <= MAX_MENTIONED_LABEL_LEN:
width = MENTION_HANDLE_CAP - len(NAME_PREFIX) - 1 - len(normalized)
else:
width = min(
NAME_TOKEN_HEX,
AGENT_NAME_MAX - len(NAME_PREFIX) - 1 - len(normalized),
)
if width < run_id_len():
raise ValueError(
f"label {normalized!r} leaves {width} characters for a unique name "
f"token; need at least {run_id_len()} within the "
f"{AGENT_NAME_MAX}-character name limit"
)
return uuid.uuid4().hex[:width]


def new_run_id() -> str:
"""Short token identifying a single test session's provisioned resources.
"""Short token the session fixture draws once for the orphan sweep's self-skip.

Its length is *derived* from the mention-handle cap (see :func:`run_id_len`) so
the longest @mentioned peer name (see :func:`agent_name`) still surfaces in
full rather than as a truncated handle. The resulting entropy
(~1M at 5 hex) is ample given the run-id + age guards in ``sweep_orphans``.
Its length is derived from the mention-handle cap (see :func:`run_id_len`) so
a name that still embeds this token fits the longest @mentioned peer. Each
provision draws its own token via :func:`name_token` rather than reusing this
one. The sweep still skips names containing this session's token; per-provision
names rely on the age gate, the same protection another job's agents have.
"""
return uuid.uuid4().hex[: run_id_len()]

Expand Down Expand Up @@ -307,7 +346,7 @@ async def provision_agent(
(default ``E2E baseline test agent ({label})``). Tests that assert on
passive-roster description surfacing pass a self-sourced marker here.
"""
name = agent_name(run_id=self._run_id, label=label)
name = agent_name(run_id=name_token(label), label=label)
agent_description = description or f"E2E baseline test agent ({label})"
response = await self._client.human_api_agents.register_my_agent(
agent=AgentRegisterRequest(
Expand Down Expand Up @@ -413,13 +452,30 @@ async def reap_all(self) -> None:
except Exception:
logger.warning("Failed to reap agent %s", agent_id, exc_info=True)

@asynccontextmanager
async def reap_on_exit(self, *, enabled: bool = True) -> AsyncGenerator[None, None]:
"""Reap everything provisioned in this block when it exits.

``enabled`` is false when ``BAND_E2E_AUTOCLEAN`` is off, so a debug run
keeps the resources. ``reap_all`` logs a delete failure and continues, so
a teardown error does not replace an exception from the block. A row
whose id was never returned is not tracked and is not deleted here; the
next provision draws a new name, so that leftover cannot collide.
"""
try:
yield
finally:
if enabled:
await self.reap_all()

async def sweep_orphans(self) -> int:
"""Force-delete stale test agents left by crashed prior runs.

Only touches agents whose name carries ``NAME_PREFIX``, belongs to a
*different* run, and is older than ``orphan_max_age_minutes`` — so a
concurrent run on the same shared platform is never deleted mid-flight.
Returns the number of agents reaped.
Only touches agents whose name carries ``NAME_PREFIX`` and that are
older than ``orphan_max_age_minutes``. Names that still embed this
session's run id are skipped. Per-provision names do not embed it; a
concurrent run's fresh agents stay because they are younger than the
age gate. Returns the number of agents reaped.
"""
max_age = timedelta(minutes=self._settings.run.orphan_max_age_minutes)
cutoff = datetime.now(UTC) - max_age
Expand All @@ -439,7 +495,7 @@ async def sweep_orphans(self) -> int:
if not agent.name.startswith(NAME_PREFIX):
continue # name filter is a contains-match; re-check the prefix
if f"-{self._run_id}-" in agent.name:
continue # never reap our own run
continue
# inserted_at may be tz-naive depending on serialization; treat
# naive as UTC so the comparison never raises (see the codebase's
# _coerce_inserted_at). A naive>aware compare would TypeError and
Expand Down Expand Up @@ -618,9 +674,9 @@ def build(
async def provision(self, *, label: str | None = None) -> ProvisionedAgent:
"""Register an identity for this cell (tracked + reaped by the manager); no run.

``label`` defaults to the adapter id (a readable provisioned name). Pass a
distinct label to register more than one identity of the same cell in a single
test, else the generated names collide.
``label`` defaults to the adapter id (a readable provisioned name). Each
call draws its own name token, so the same label can be registered again
in one test without colliding with an agent this run already created.
"""
provisioned = await self.resources.provision_agent(label or self.adapter_id)
return replace(provisioned, adapter_id=self.adapter_id)
Expand Down Expand Up @@ -708,7 +764,7 @@ async def run_many(
what a same-adapter co-residency gate must probe.

``labels`` default to ``{adapter_id}-{index}``; an explicit list must be length
``count`` so the provisioned names don't collide. ``prompt`` / ``features`` /
``count`` (one label per member). ``prompt`` / ``features`` /
``tools`` pass through to each :meth:`run_as`, preserving the cell defaults when
omitted.
"""
Expand Down
95 changes: 95 additions & 0 deletions tests/framework_conformance/test_provision_names.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Each provision registers under its own agent name.

A leftover row for a label must not make the next provision of that label
ask the platform for the same name.
"""

from __future__ import annotations

from typing import cast
from unittest.mock import AsyncMock, MagicMock

import pytest
from band_rest import AgentRegisterRequest

from tests.e2e.baseline.settings import BaselineSettings
from tests.e2e.baseline.toolkit.provisioning import (
MENTION_HANDLE_CAP,
NAME_PREFIX,
ResourceManager,
name_token,
)


def recording_manager() -> tuple[ResourceManager, AsyncMock]:
def respond(*, agent: AgentRegisterRequest) -> MagicMock:
response = MagicMock()
response.data.agent.id = agent.name
response.data.credentials.api_key = "test-key"
return response

register = AsyncMock(side_effect=respond)
client = MagicMock()
client.human_api_agents.register_my_agent = register
manager = ResourceManager(
user_client=client,
settings=cast(BaselineSettings, MagicMock()),
run_id="session",
)
return manager, register


def registered_names(register: AsyncMock) -> list[str]:
return [call.kwargs["agent"].name for call in register.await_args_list]


def name_parts(name: str) -> tuple[str, str]:
"""Hex token and label from a provisioned name. The token contains no hyphen."""
token, _separator, label = name.removeprefix(NAME_PREFIX).partition("-")
return token, label


def is_hex(token: str) -> bool:
return bool(token) and all(character in "0123456789abcdef" for character in token)


class TestProvisionNames:
async def test_same_mentioned_label_registers_two_names_inside_the_cap(
self,
) -> None:
manager, register = recording_manager()

first = await manager.provision_agent("invitable")
second = await manager.provision_agent("invitable")

names = registered_names(register)
assert names == [first.name, second.name]
assert len(set(names)) == 2
for name in names:
token, label = name_parts(name)
assert name.startswith(NAME_PREFIX)
assert label == "invitable"
assert is_hex(token)
assert len(name) == MENTION_HANDLE_CAP

async def test_adapter_label_uses_a_wide_token_within_the_name_limit(
self,
) -> None:
manager, register = recording_manager()

first = await manager.provision_agent("copilot_sdk")
second = await manager.provision_agent("copilot_sdk")

names = registered_names(register)
assert names == [first.name, second.name]
assert len(set(names)) == 2
for name in names:
token, label = name_parts(name)
assert label == "copilot-sdk"
assert is_hex(token)
assert len(token) == 32
assert len(name) <= 100

def test_label_too_long_for_a_unique_name_is_rejected(self) -> None:
with pytest.raises(ValueError, match="unique name token"):
name_token("x" * 90)
63 changes: 63 additions & 0 deletions tests/framework_conformance/test_reap_on_exit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Test-end cleanup reaps tracked resources without hiding the test error."""

from __future__ import annotations

from typing import cast
from unittest.mock import AsyncMock, MagicMock

import pytest

from tests.e2e.baseline.settings import BaselineSettings
from tests.e2e.baseline.toolkit.provisioning import ResourceManager

REGISTERED_AGENT_ID = "agent-1"


def manager() -> tuple[ResourceManager, AsyncMock]:
def respond(**_kwargs: object) -> MagicMock:
response = MagicMock()
response.data.agent.id = REGISTERED_AGENT_ID
response.data.credentials.api_key = "test-key"
return response

client = MagicMock()
client.human_api_agents.register_my_agent = AsyncMock(side_effect=respond)
delete = AsyncMock()
client.human_api_agents.delete_my_agent = delete
return (
ResourceManager(
user_client=client,
settings=cast(BaselineSettings, MagicMock()),
run_id="session",
),
delete,
)


class TestReapOnExit:
async def test_reaps_on_normal_exit(self) -> None:
resources, delete = manager()

async with resources.reap_on_exit():
await resources.provision_agent("copilot_sdk")

delete.assert_awaited_once_with(REGISTERED_AGENT_ID, force=True)

async def test_body_error_survives_a_failed_reap(self) -> None:
resources, delete = manager()
delete.side_effect = RuntimeError("delete failed")

with pytest.raises(RuntimeError, match="boom"):
async with resources.reap_on_exit():
await resources.provision_agent("copilot_sdk")
raise RuntimeError("boom")

delete.assert_awaited_once_with(REGISTERED_AGENT_ID, force=True)

async def test_disabled_does_not_reap(self) -> None:
resources, delete = manager()

async with resources.reap_on_exit(enabled=False):
await resources.provision_agent("copilot_sdk")

delete.assert_not_awaited()
Loading