321 lines
11 KiB
Python
321 lines
11 KiB
Python
"""E2E test: zero-downtime agent update (mock LLM).
|
|
|
|
Verifies that an in-flight request on the old agent version completes
|
|
successfully, and a new request after the update uses the new version
|
|
(observable via changed instructions that affect the response content).
|
|
|
|
Usage::
|
|
|
|
pytest tests/e2e/test_agent_update.py -v
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import os
|
|
import tarfile
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import yaml
|
|
|
|
from tests.e2e.conftest import (
|
|
configure_mock_llm,
|
|
create_runner_bound_session,
|
|
poll_session_until_terminal,
|
|
release_mock_gate,
|
|
reset_mock_llm,
|
|
send_user_message_to_session,
|
|
)
|
|
from tests.e2e.helpers import final_assistant_text
|
|
|
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
_TEST_AGENT_DIR = _REPO_ROOT / "tests" / "resources" / "agents" / "compaction-test"
|
|
_TEST_AGENT_NAME = "compaction-test"
|
|
|
|
# Marker phrase injected into v2 instructions so we can verify
|
|
# the v2 response was produced by the updated spec.
|
|
_V2_MARKER = "ZEBRAFINCH"
|
|
|
|
|
|
def _upload_agent_with_id(
|
|
client: httpx.Client,
|
|
agent_dir: Path,
|
|
mock_llm_server_url: str,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Upload an agent bundle via multipart ``POST /v1/sessions`` and
|
|
return the agent metadata (including ``id``) from the
|
|
session-scoped agent endpoint.
|
|
|
|
Patches the config to route through the mock LLM server.
|
|
|
|
:param client: HTTP client pointed at the server.
|
|
:param agent_dir: Path to the agent directory.
|
|
:param mock_llm_server_url: Mock LLM server URL.
|
|
:returns: The agent response JSON with ``id``, ``name``,
|
|
``version``, etc.
|
|
"""
|
|
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
|
|
with tarfile.open(tmp.name, "w:gz") as tar:
|
|
for item in sorted(agent_dir.rglob("*")):
|
|
if not item.is_file():
|
|
continue
|
|
arcname = str(item.relative_to(agent_dir))
|
|
if item.name == "config.yaml":
|
|
cfg = yaml.safe_load(item.read_text())
|
|
# Point at mock LLM
|
|
cfg.setdefault("executor", {})["auth"] = {
|
|
"type": "api_key",
|
|
"api_key": "mock-key",
|
|
"base_url": f"{mock_llm_server_url}/v1",
|
|
}
|
|
data = yaml.dump(cfg).encode()
|
|
info = tarfile.TarInfo(name=arcname)
|
|
info.size = len(data)
|
|
tar.addfile(info, io.BytesIO(data))
|
|
else:
|
|
tar.add(str(item), arcname=arcname)
|
|
tmp_path = tmp.name
|
|
try:
|
|
with open(tmp_path, "rb") as f:
|
|
metadata = json.dumps({})
|
|
resp = client.post(
|
|
"/v1/sessions",
|
|
data={"metadata": metadata},
|
|
files={
|
|
"bundle": (
|
|
"agent.tar.gz",
|
|
f,
|
|
"application/gzip",
|
|
),
|
|
},
|
|
)
|
|
resp.raise_for_status()
|
|
session_id = resp.json()["session_id"]
|
|
agent_resp = client.get(f"/v1/sessions/{session_id}/agent")
|
|
agent_resp.raise_for_status()
|
|
agent_data: dict[str, Any] = agent_resp.json()
|
|
agent_data["_session_id"] = session_id
|
|
return agent_data
|
|
finally:
|
|
os.unlink(tmp_path)
|
|
|
|
|
|
def _build_updated_bundle(
|
|
agent_dir: Path,
|
|
config_overrides: dict[str, Any],
|
|
mock_llm_server_url: str,
|
|
) -> bytes:
|
|
"""
|
|
Build a tarball from an agent directory with config.yaml
|
|
fields overridden and mock LLM auth injected.
|
|
|
|
:param agent_dir: Path to the original agent directory.
|
|
:param config_overrides: Dict of fields to merge into
|
|
config.yaml.
|
|
:param mock_llm_server_url: Mock LLM server URL.
|
|
:returns: Raw bytes of the ``.tar.gz`` bundle.
|
|
"""
|
|
buf = io.BytesIO()
|
|
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
|
for item in Path(agent_dir).rglob("*"):
|
|
if not item.is_file():
|
|
continue
|
|
arcname = str(item.relative_to(agent_dir))
|
|
if item.name == "config.yaml" and item.parent == agent_dir:
|
|
config = yaml.safe_load(item.read_text())
|
|
config.update(config_overrides)
|
|
config.setdefault("executor", {})["auth"] = {
|
|
"type": "api_key",
|
|
"api_key": "mock-key",
|
|
"base_url": f"{mock_llm_server_url}/v1",
|
|
}
|
|
data = yaml.dump(config).encode()
|
|
info = tarfile.TarInfo(name=arcname)
|
|
info.size = len(data)
|
|
tar.addfile(info, io.BytesIO(data))
|
|
else:
|
|
tar.add(str(item), arcname=arcname)
|
|
return buf.getvalue()
|
|
|
|
|
|
def _update_agent(
|
|
client: httpx.Client,
|
|
session_id: str,
|
|
bundle_bytes: bytes,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
PUT a new bundle to update an existing agent via the
|
|
session-scoped agent endpoint.
|
|
|
|
:param client: HTTP client pointed at the server.
|
|
:param session_id: The session ID whose agent to update.
|
|
:param bundle_bytes: Raw bytes of the new ``.tar.gz`` bundle.
|
|
:returns: The updated agent response JSON.
|
|
"""
|
|
resp = client.put(
|
|
f"/v1/sessions/{session_id}/agent",
|
|
files={
|
|
"bundle": (
|
|
"agent.tar.gz",
|
|
bundle_bytes,
|
|
"application/gzip",
|
|
),
|
|
},
|
|
)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
|
|
def _wait_for_gate_pending(mock_llm_server_url: str, timeout: float = 30) -> None:
|
|
"""Poll until a request is blocked on the mock LLM gate."""
|
|
import time
|
|
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
resp = httpx.get(f"{mock_llm_server_url}/gate/pending", timeout=2.0)
|
|
resp.raise_for_status()
|
|
if resp.json().get("pending"):
|
|
return
|
|
time.sleep(0.1)
|
|
raise AssertionError(f"No gate pending within {timeout}s")
|
|
|
|
|
|
def test_update_agent_zero_downtime(
|
|
http_client: httpx.Client,
|
|
live_runner_id: str,
|
|
mock_llm_server_url: str,
|
|
) -> None:
|
|
"""
|
|
Verifies that the update endpoint doesn't disrupt in-flight
|
|
requests and that new requests use the updated spec.
|
|
|
|
**What this test proves:**
|
|
- The PUT endpoint succeeds while a background request is
|
|
running (the server doesn't crash or deadlock).
|
|
- A request created after the update uses the new spec
|
|
(verified via a marker phrase in the mock response).
|
|
- The in-flight request completes without error.
|
|
|
|
Steps:
|
|
1. Upload compaction-test agent (version 1).
|
|
2. Send a request whose mock response blocks on a gate.
|
|
3. PUT a new bundle with modified instructions (version 2).
|
|
4. Release the gate so v1 completes; send v2 request.
|
|
5. Both requests complete successfully.
|
|
6. V1 response does NOT contain the v2 marker.
|
|
7. V2 response DOES contain the v2 marker.
|
|
8. Agent metadata shows version=2 and updated_at is set.
|
|
"""
|
|
# Use a unique model key for the agent so our mock queue is isolated.
|
|
# The compaction-test agent ships with model: gpt-5.4 which the mock
|
|
# maps via the "default" queue. We configure the default queue since
|
|
# the bundled config.yaml keeps the original model name.
|
|
reset_mock_llm(mock_llm_server_url)
|
|
|
|
# Queue: v1 response blocks, then v2 response carries the marker.
|
|
configure_mock_llm(
|
|
mock_llm_server_url,
|
|
[
|
|
{
|
|
"text": (
|
|
"Photosynthesis is the process by which green plants "
|
|
"convert light energy into chemical energy."
|
|
),
|
|
"block": True,
|
|
},
|
|
{"text": f"The answer is 4. {_V2_MARKER}"},
|
|
],
|
|
)
|
|
|
|
# Step 1: Upload compaction-test (v1) and bind the runner.
|
|
created = _upload_agent_with_id(
|
|
http_client,
|
|
_TEST_AGENT_DIR,
|
|
mock_llm_server_url=mock_llm_server_url,
|
|
)
|
|
session_id = created["_session_id"]
|
|
assert created["version"] == 1
|
|
http_client.patch(
|
|
f"/v1/sessions/{session_id}",
|
|
json={"runner_id": live_runner_id},
|
|
).raise_for_status()
|
|
|
|
# Step 2: Start a turn whose mock response blocks on the gate.
|
|
response_id_1 = send_user_message_to_session(
|
|
http_client,
|
|
session_id=session_id,
|
|
content="Explain photosynthesis in detail.",
|
|
)
|
|
|
|
# Wait for the mock LLM to be blocked on the gate.
|
|
_wait_for_gate_pending(mock_llm_server_url)
|
|
|
|
# Step 3: Update agent to v2 with marker in instructions.
|
|
v2_bundle = _build_updated_bundle(
|
|
_TEST_AGENT_DIR,
|
|
{
|
|
"description": "Updated compaction-test v2 for e2e test",
|
|
"instructions": (
|
|
f"You MUST include the word '{_V2_MARKER}' somewhere in every response you give."
|
|
),
|
|
},
|
|
mock_llm_server_url=mock_llm_server_url,
|
|
)
|
|
updated = _update_agent(http_client, session_id, v2_bundle)
|
|
assert updated["version"] == 2
|
|
|
|
# Release the gate so v1 completes.
|
|
release_mock_gate(mock_llm_server_url)
|
|
|
|
# Step 4: New session on v2.
|
|
session_id_2 = create_runner_bound_session(
|
|
http_client,
|
|
agent_name=_TEST_AGENT_NAME,
|
|
runner_id=live_runner_id,
|
|
)
|
|
response_id_2 = send_user_message_to_session(
|
|
http_client,
|
|
session_id=session_id_2,
|
|
content="What is 2+2? Answer briefly.",
|
|
)
|
|
|
|
# Step 5: Poll both to terminal state.
|
|
body1 = poll_session_until_terminal(
|
|
http_client, session_id=session_id, response_id=response_id_1, timeout=60
|
|
)
|
|
body2 = poll_session_until_terminal(
|
|
http_client, session_id=session_id_2, response_id=response_id_2, timeout=60
|
|
)
|
|
|
|
assert body1["status"] == "completed", (
|
|
f"V1 request failed with status {body1['status']!r}. Output: {body1.get('output', [])}"
|
|
)
|
|
assert body2["status"] == "completed", (
|
|
f"V2 request failed with status {body2['status']!r}. Output: {body2.get('output', [])}"
|
|
)
|
|
|
|
# Step 6: V1 response should NOT contain the marker.
|
|
v1_text = final_assistant_text(body1)
|
|
assert _V2_MARKER not in v1_text, (
|
|
f"V1 response unexpectedly contains the v2 marker "
|
|
f"'{_V2_MARKER}'. First 500 chars: {v1_text[:500]}"
|
|
)
|
|
|
|
# Step 7: V2 response MUST contain the marker.
|
|
v2_text = final_assistant_text(body2)
|
|
assert _V2_MARKER in v2_text, (
|
|
f"V2 response does NOT contain the marker '{_V2_MARKER}'. First 500 chars: {v2_text[:500]}"
|
|
)
|
|
|
|
# Step 8: Agent metadata reflects the update.
|
|
agent_resp = http_client.get(f"/v1/sessions/{session_id}/agent")
|
|
agent_resp.raise_for_status()
|
|
agent = agent_resp.json()
|
|
assert agent["version"] == 2
|
|
assert agent["updated_at"] is not None
|