chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
"""Pytest config for langroid adapter tests.
|
||||
|
||||
Adds the package's ``src/`` to ``sys.path`` so imports like
|
||||
``from agents.agui_adapter import ...`` resolve identically to the
|
||||
runtime path used by the FastAPI entry point in ``src/agent_server.py``
|
||||
(which likewise imports ``agents.agui_adapter``). Keeping these import
|
||||
paths aligned means a regression in the adapter surfaces in tests the
|
||||
same way it would at runtime.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
_PKG_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
_SRC_DIR = os.path.join(_PKG_DIR, "src")
|
||||
# Include the integration root so the ``tools`` symlink
|
||||
# (``langroid/tools`` → ``../../shared/python/tools``) is importable.
|
||||
if _PKG_DIR not in sys.path:
|
||||
sys.path.insert(0, _PKG_DIR)
|
||||
if _SRC_DIR not in sys.path:
|
||||
sys.path.insert(0, _SRC_DIR)
|
||||
@@ -0,0 +1,922 @@
|
||||
"""Unit tests for the Langroid AG-UI SSE adapter.
|
||||
|
||||
These tests exercise the event-emission logic of ``handle_run`` by driving
|
||||
it with fabricated ``Request`` doubles. They mock ``_call_openai`` to return
|
||||
controlled OpenAI ChatCompletionMessage-shaped responses, avoiding the need
|
||||
for a real LLM or aimock.
|
||||
|
||||
Run: ``pytest tests/python/ -v`` from the langroid package root.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from agents import agui_adapter
|
||||
from agents.agui_adapter import (
|
||||
ParsedArgs,
|
||||
_execute_backend_tool,
|
||||
_parse_tool_args,
|
||||
_TOOL_BY_NAME,
|
||||
handle_run,
|
||||
)
|
||||
from agents.agent import ALL_TOOLS, FRONTEND_TOOL_NAMES
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fakes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeRequest:
|
||||
"""Stand-in for ``fastapi.Request`` — only ``.json()`` is used."""
|
||||
|
||||
def __init__(self, body: dict):
|
||||
self._body = body
|
||||
|
||||
async def json(self) -> dict:
|
||||
return self._body
|
||||
|
||||
|
||||
def _install_fake_openai(monkeypatch: pytest.MonkeyPatch, response: Any) -> None:
|
||||
"""Replace ``_call_openai`` with a coroutine that returns *response*."""
|
||||
|
||||
async def _fake_call_openai(messages, tools, model):
|
||||
return response
|
||||
|
||||
monkeypatch.setattr(agui_adapter, "_call_openai", _fake_call_openai)
|
||||
|
||||
|
||||
def _minimal_run_input(thread_id: str = "") -> dict:
|
||||
"""Build a minimal ``RunAgentInput`` body.
|
||||
|
||||
``RunAgentInput`` requires ``thread_id`` to be present, but the adapter's
|
||||
``run_input.thread_id or ...`` fallback is what we're testing — so
|
||||
passing an empty string triggers the fallback branch identically to
|
||||
the real "missing" case the fix addresses.
|
||||
"""
|
||||
return {
|
||||
"thread_id": thread_id,
|
||||
"run_id": "run-123",
|
||||
"messages": [{"id": "m1", "role": "user", "content": "hi"}],
|
||||
"tools": [],
|
||||
"context": [],
|
||||
"forwarded_props": {},
|
||||
"state": {},
|
||||
}
|
||||
|
||||
|
||||
async def _collect(streaming_response) -> list[str]:
|
||||
"""Drain a FastAPI StreamingResponse body_iterator into a list of strings."""
|
||||
out: list[str] = []
|
||||
async for chunk in streaming_response.body_iterator:
|
||||
if isinstance(chunk, (bytes, bytearray)):
|
||||
out.append(chunk.decode("utf-8"))
|
||||
else:
|
||||
out.append(chunk)
|
||||
return out
|
||||
|
||||
|
||||
def _parse_events(lines: list[str]) -> list[dict]:
|
||||
"""Extract the JSON payloads from SSE ``data: ...`` frames."""
|
||||
events: list[dict] = []
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
events.append(json.loads(line[len("data:") :].strip()))
|
||||
return events
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Happy path: tool_calls with valid JSON args
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oai_tool_calls_emit_start_args_end_in_order(monkeypatch):
|
||||
"""A single ``tool_calls`` entry with valid JSON args should emit
|
||||
TEXT_MESSAGE_START -> TOOL_CALL_START -> TOOL_CALL_ARGS ->
|
||||
TOOL_CALL_END -> TEXT_MESSAGE_END in that exact order (tool calls
|
||||
are wrapped in a parent TextMessage)."""
|
||||
tool_call = SimpleNamespace(
|
||||
id="call-1",
|
||||
function=SimpleNamespace(
|
||||
name="change_background", # frontend tool, no backend execution
|
||||
arguments='{"background": "linear-gradient(red, blue)"}',
|
||||
),
|
||||
)
|
||||
response = SimpleNamespace(content="", tool_calls=[tool_call])
|
||||
_install_fake_openai(monkeypatch, response)
|
||||
|
||||
req = _FakeRequest(_minimal_run_input(thread_id="t-1"))
|
||||
resp = await handle_run(req)
|
||||
events = _parse_events(await _collect(resp))
|
||||
|
||||
types = [e["type"] for e in events]
|
||||
assert types == [
|
||||
"RUN_STARTED",
|
||||
"TEXT_MESSAGE_START",
|
||||
"TOOL_CALL_START",
|
||||
"TOOL_CALL_ARGS",
|
||||
"TOOL_CALL_END",
|
||||
"TEXT_MESSAGE_END",
|
||||
"RUN_FINISHED",
|
||||
]
|
||||
|
||||
start = next(e for e in events if e["type"] == "TOOL_CALL_START")
|
||||
args = next(e for e in events if e["type"] == "TOOL_CALL_ARGS")
|
||||
end = next(e for e in events if e["type"] == "TOOL_CALL_END")
|
||||
assert start["toolCallId"] == "call-1"
|
||||
assert start["toolCallName"] == "change_background"
|
||||
assert args["toolCallId"] == "call-1"
|
||||
assert json.loads(args["delta"]) == {"background": "linear-gradient(red, blue)"}
|
||||
assert end["toolCallId"] == "call-1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parentMessageId is set on TOOL_CALL_START
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_has_parent_message_id(monkeypatch):
|
||||
"""TOOL_CALL_START must carry parentMessageId matching the wrapping
|
||||
TEXT_MESSAGE_START's messageId — the Runtime middleware-sse-parser
|
||||
uses this to attach tool calls to their parent message."""
|
||||
tool_call = SimpleNamespace(
|
||||
id="call-pm",
|
||||
function=SimpleNamespace(
|
||||
name="change_background",
|
||||
arguments='{"background": "teal"}',
|
||||
),
|
||||
)
|
||||
response = SimpleNamespace(content="", tool_calls=[tool_call])
|
||||
_install_fake_openai(monkeypatch, response)
|
||||
|
||||
req = _FakeRequest(_minimal_run_input(thread_id="t-pm"))
|
||||
resp = await handle_run(req)
|
||||
events = _parse_events(await _collect(resp))
|
||||
|
||||
txt_start = next(e for e in events if e["type"] == "TEXT_MESSAGE_START")
|
||||
tc_start = next(e for e in events if e["type"] == "TOOL_CALL_START")
|
||||
assert "parentMessageId" in tc_start
|
||||
assert tc_start["parentMessageId"] == txt_start["messageId"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Malformed args → warning + skip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_malformed_args_skip_tool_call_and_logs_warning(monkeypatch, caplog):
|
||||
"""Unparseable JSON in ``arguments`` must skip the tool call entirely
|
||||
(no TOOL_CALL_* events) — firing a tool with empty args renders a
|
||||
meaningless UI card. A warning must be logged explaining the skip.
|
||||
"""
|
||||
tool_call = SimpleNamespace(
|
||||
id="call-2",
|
||||
function=SimpleNamespace(name="change_background", arguments="not json {"),
|
||||
)
|
||||
response = SimpleNamespace(content="", tool_calls=[tool_call])
|
||||
_install_fake_openai(monkeypatch, response)
|
||||
|
||||
req = _FakeRequest(_minimal_run_input(thread_id="t-2"))
|
||||
with caplog.at_level(logging.WARNING, logger=agui_adapter.logger.name):
|
||||
resp = await handle_run(req)
|
||||
events = _parse_events(await _collect(resp))
|
||||
|
||||
types = [e["type"] for e in events]
|
||||
assert types == ["RUN_STARTED", "RUN_FINISHED"], (
|
||||
f"expected no TOOL_CALL_* events on malformed args, got: {types}"
|
||||
)
|
||||
|
||||
assert any(
|
||||
"Failed to JSON-decode tool-call arguments" in rec.getMessage()
|
||||
for rec in caplog.records
|
||||
), f"expected a warning log, got: {[r.getMessage() for r in caplog.records]}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Empty-string content → no TEXT_MESSAGE_* events
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_content_skips_text_message_events(monkeypatch):
|
||||
"""A response with empty content and no tool calls must not emit
|
||||
any TEXT_MESSAGE_* events (AG-UI rejects empty deltas)."""
|
||||
response = SimpleNamespace(content="", tool_calls=None)
|
||||
_install_fake_openai(monkeypatch, response)
|
||||
|
||||
req = _FakeRequest(_minimal_run_input(thread_id="t-4"))
|
||||
resp = await handle_run(req)
|
||||
events = _parse_events(await _collect(resp))
|
||||
|
||||
types = [e["type"] for e in events]
|
||||
assert types == ["RUN_STARTED", "RUN_FINISHED"]
|
||||
assert not any(t.startswith("TEXT_MESSAGE") for t in types)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thread-id stability across RUN_STARTED and RUN_FINISHED
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thread_id_stable_when_caller_omits(monkeypatch):
|
||||
"""Invariant: RUN_STARTED and RUN_FINISHED MUST share the same
|
||||
``thread_id``. When the caller omits it, the adapter synthesizes one
|
||||
UUID and reuses it across every event emitted for the run."""
|
||||
response = SimpleNamespace(content="hello", tool_calls=None)
|
||||
_install_fake_openai(monkeypatch, response)
|
||||
|
||||
# Empty string triggers the ``run_input.thread_id or str(uuid.uuid4())``
|
||||
# fallback — exactly the same code path as a missing thread_id.
|
||||
req = _FakeRequest(_minimal_run_input(thread_id=""))
|
||||
resp = await handle_run(req)
|
||||
events = _parse_events(await _collect(resp))
|
||||
|
||||
started = next(e for e in events if e["type"] == "RUN_STARTED")
|
||||
finished = next(e for e in events if e["type"] == "RUN_FINISHED")
|
||||
assert started["threadId"], "thread_id should be a non-empty synthesized UUID"
|
||||
assert started["threadId"] == finished["threadId"], (
|
||||
f"RUN_STARTED thread_id {started['threadId']!r} must match "
|
||||
f"RUN_FINISHED {finished['threadId']!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thread_id_from_caller_preserved(monkeypatch):
|
||||
"""Invariant: when the caller supplies a thread_id, the adapter MUST
|
||||
preserve it verbatim — no new UUID is synthesized, and RUN_STARTED
|
||||
and RUN_FINISHED both carry the caller's value."""
|
||||
response = SimpleNamespace(content="hello", tool_calls=None)
|
||||
_install_fake_openai(monkeypatch, response)
|
||||
|
||||
req = _FakeRequest(_minimal_run_input(thread_id="abc"))
|
||||
resp = await handle_run(req)
|
||||
events = _parse_events(await _collect(resp))
|
||||
|
||||
started = next(e for e in events if e["type"] == "RUN_STARTED")
|
||||
finished = next(e for e in events if e["type"] == "RUN_FINISHED")
|
||||
assert started["threadId"] == "abc"
|
||||
assert finished["threadId"] == "abc"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool-class uniqueness assertion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tool_class_uniqueness():
|
||||
"""Every ToolMessage class in ``ALL_TOOLS`` must expose a unique
|
||||
``request`` default — silent overwrites in ``_TOOL_BY_NAME`` would
|
||||
mean one tool shadows another at runtime."""
|
||||
requests = [cls.default_value("request") for cls in ALL_TOOLS]
|
||||
assert len(set(requests)) == len(requests), (
|
||||
f"Duplicate tool request names: "
|
||||
f"{[r for r in requests if requests.count(r) > 1]}"
|
||||
)
|
||||
# And the module-level map built at import time agrees.
|
||||
assert len(_TOOL_BY_NAME) == len(ALL_TOOLS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper coverage: _parse_tool_args
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_tool_args_dict_passthrough():
|
||||
parsed = _parse_tool_args({"a": 1})
|
||||
assert isinstance(parsed, ParsedArgs)
|
||||
assert parsed.status == "ok"
|
||||
assert parsed.usable is True
|
||||
assert parsed.args == {"a": 1}
|
||||
|
||||
|
||||
def test_parse_tool_args_empty_string_is_malformed():
|
||||
"""Empty string is treated as malformed, not "ok with {}".
|
||||
Consistent with the oai-path rationale: firing a tool with no
|
||||
arguments produces a meaningless UI card, so we skip it the same
|
||||
way we skip unparseable JSON."""
|
||||
parsed = _parse_tool_args("")
|
||||
assert parsed.status == "malformed"
|
||||
assert parsed.usable is False
|
||||
assert parsed.args == {}
|
||||
|
||||
|
||||
def test_parse_tool_args_valid_json_string():
|
||||
parsed = _parse_tool_args('{"x": 2}')
|
||||
assert parsed.status == "ok"
|
||||
assert parsed.usable is True
|
||||
assert parsed.args == {"x": 2}
|
||||
|
||||
|
||||
def test_parse_tool_args_malformed_returns_malformed_status(caplog):
|
||||
"""Malformed JSON returns ``status="malformed"`` — callers must
|
||||
skip the tool call rather than fire it with empty args."""
|
||||
with caplog.at_level(logging.WARNING, logger=agui_adapter.logger.name):
|
||||
parsed = _parse_tool_args("not json {")
|
||||
assert parsed.status == "malformed"
|
||||
assert parsed.usable is False
|
||||
assert any(
|
||||
"Failed to JSON-decode tool-call arguments" in rec.getMessage()
|
||||
for rec in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_parse_tool_args_non_dict_json_is_malformed(caplog):
|
||||
"""Valid JSON but not a dict (e.g. an array) is likewise malformed."""
|
||||
with caplog.at_level(logging.WARNING, logger=agui_adapter.logger.name):
|
||||
parsed = _parse_tool_args("[1, 2, 3]")
|
||||
assert parsed.status == "malformed"
|
||||
assert parsed.usable is False
|
||||
assert any("parsed to non-dict" in rec.getMessage() for rec in caplog.records)
|
||||
|
||||
|
||||
def test_parse_tool_args_unknown_type_is_empty():
|
||||
"""Unknown non-dict / non-str / non-bytes types (e.g. ``None``,
|
||||
``int``) produce ``status="empty"`` — not a parse failure, just
|
||||
nothing to try. Callers still skip (``usable == False``)."""
|
||||
for val in (None, 42, 3.14, object()):
|
||||
parsed = _parse_tool_args(val)
|
||||
assert parsed.status == "empty", f"for {val!r}"
|
||||
assert parsed.usable is False
|
||||
|
||||
|
||||
def test_parse_tool_args_bytes_input():
|
||||
"""Bytes input is valid (``json.loads`` accepts bytes) and should
|
||||
round-trip identically to str input."""
|
||||
parsed = _parse_tool_args(b'{"y": 3}')
|
||||
assert parsed.status == "ok"
|
||||
assert parsed.args == {"y": 3}
|
||||
|
||||
|
||||
def test_parse_tool_args_returns_copy():
|
||||
"""The dict on a successful parse must be a fresh copy — callers
|
||||
must be free to mutate without affecting the original payload
|
||||
(which may be shared across tool calls)."""
|
||||
original = {"a": 1, "b": 2}
|
||||
parsed = _parse_tool_args(original)
|
||||
assert parsed.status == "ok"
|
||||
assert parsed.args == original
|
||||
parsed.args["c"] = 3
|
||||
parsed.args["a"] = 999
|
||||
assert "c" not in original, "caller's mutation leaked into original dict"
|
||||
assert original["a"] == 1, "caller's mutation leaked into original dict"
|
||||
|
||||
|
||||
def test_tool_by_name_is_frozen():
|
||||
"""``_TOOL_BY_NAME`` must be immutable post-import — assignment
|
||||
should raise ``TypeError``. Mutating this map at runtime would
|
||||
silently shadow tool dispatch for the rest of the process."""
|
||||
with pytest.raises(TypeError):
|
||||
_TOOL_BY_NAME["brand_new_tool"] = object # type: ignore[index]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backend tool execution: happy path + sanitized error on exception
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backend_tool_execution_happy_path(monkeypatch):
|
||||
"""A backend tool (not in FRONTEND_TOOL_NAMES) executes server-side
|
||||
and its result is emitted as a TOOL_CALL_RESULT event after
|
||||
TOOL_CALL_END so the CopilotKit runtime transitions useRenderTool
|
||||
status from "executing" to "complete"."""
|
||||
|
||||
# Stub the backend tool's handler so we don't hit real impls (weather
|
||||
# API, DB, etc.) and we can pin the result string exactly.
|
||||
from agents import agent as agent_module
|
||||
|
||||
def _fake_handle(self):
|
||||
return '{"location": "SF", "temp_f": 68}'
|
||||
|
||||
monkeypatch.setattr(agent_module.GetWeatherTool, "handle", _fake_handle)
|
||||
|
||||
tool_call = SimpleNamespace(
|
||||
id="call-wx",
|
||||
function=SimpleNamespace(
|
||||
name="get_weather",
|
||||
arguments='{"location": "SF"}',
|
||||
),
|
||||
)
|
||||
response = SimpleNamespace(content="", tool_calls=[tool_call])
|
||||
_install_fake_openai(monkeypatch, response)
|
||||
|
||||
req = _FakeRequest(_minimal_run_input(thread_id="t-wx"))
|
||||
resp = await handle_run(req)
|
||||
events = _parse_events(await _collect(resp))
|
||||
|
||||
types = [e["type"] for e in events]
|
||||
# Backend tools emit a TOOL_CALL_RESULT after TOOL_CALL_END, all
|
||||
# wrapped in a parent TEXT_MESSAGE_START/END pair.
|
||||
assert types == [
|
||||
"RUN_STARTED",
|
||||
"TEXT_MESSAGE_START", # parent message wrapper
|
||||
"TOOL_CALL_START",
|
||||
"TOOL_CALL_ARGS",
|
||||
"TOOL_CALL_END",
|
||||
"TOOL_CALL_RESULT", # backend tool result
|
||||
"TEXT_MESSAGE_END", # parent message close
|
||||
"RUN_FINISHED",
|
||||
], f"unexpected event sequence: {types}"
|
||||
|
||||
result = next(e for e in events if e["type"] == "TOOL_CALL_RESULT")
|
||||
assert result["content"] == '{"location": "SF", "temp_f": 68}'
|
||||
assert result["toolCallId"] == "call-wx"
|
||||
|
||||
|
||||
# The sensitive substring shared by several tests — asserting that
|
||||
# none of it survives sanitization is the sanitization contract.
|
||||
_SENSITIVE_EXC_MESSAGE = (
|
||||
"DB connection failed at /opt/app/secret/internal.py line 42 "
|
||||
"postgres://user:password@host:5432/db traceback frame ..."
|
||||
)
|
||||
_FORBIDDEN_SUBSTRINGS = (
|
||||
"/opt/app",
|
||||
"internal.py",
|
||||
"line 42",
|
||||
"postgres://",
|
||||
"password",
|
||||
"traceback",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backend_tool_exception_returns_sanitized_error(monkeypatch, caplog):
|
||||
"""When a backend tool raises a narrowed data-error (``ValueError``
|
||||
or ``pydantic.ValidationError``), the error payload streamed to the
|
||||
user must be SANITIZED — no stack frames, no file paths, no
|
||||
``str(exc)`` (which commonly embeds internal details). Only the
|
||||
tool name and the exception class leak. The full traceback must
|
||||
still be logged server-side via ``logger.exception``."""
|
||||
from agents import agent as agent_module
|
||||
|
||||
def _raise_handle(self):
|
||||
raise ValueError(_SENSITIVE_EXC_MESSAGE)
|
||||
|
||||
monkeypatch.setattr(agent_module.GetWeatherTool, "handle", _raise_handle)
|
||||
|
||||
tool_call = SimpleNamespace(
|
||||
id="call-bad",
|
||||
function=SimpleNamespace(
|
||||
name="get_weather",
|
||||
arguments='{"location": "SF"}',
|
||||
),
|
||||
)
|
||||
response = SimpleNamespace(content="", tool_calls=[tool_call])
|
||||
_install_fake_openai(monkeypatch, response)
|
||||
|
||||
req = _FakeRequest(_minimal_run_input(thread_id="t-err"))
|
||||
with caplog.at_level(logging.ERROR, logger=agui_adapter.logger.name):
|
||||
resp = await handle_run(req)
|
||||
events = _parse_events(await _collect(resp))
|
||||
|
||||
# The sanitized error rides on a TOOL_CALL_RESULT event (so the
|
||||
# frontend tool-call card transitions out of the executing state
|
||||
# with the error payload in hand).
|
||||
result = next(e for e in events if e["type"] == "TOOL_CALL_RESULT")
|
||||
payload = json.loads(result["content"])
|
||||
assert "error" in payload
|
||||
err = payload["error"]
|
||||
|
||||
# The sanitized error contains the tool name and the exception class.
|
||||
assert "get_weather" in err
|
||||
assert "ValueError" in err
|
||||
|
||||
# And MUST NOT contain any of the internal details from the exception
|
||||
# message: file paths, connection strings, stack-frame markers.
|
||||
for needle in _FORBIDDEN_SUBSTRINGS:
|
||||
assert needle not in err, (
|
||||
f"sanitized error leaked internal detail {needle!r}: {err!r}"
|
||||
)
|
||||
|
||||
# Server-side: the full exception must be logged (logger.exception
|
||||
# attaches exc_info with the traceback).
|
||||
exc_records = [r for r in caplog.records if r.exc_info]
|
||||
assert exc_records, "expected logger.exception to capture traceback server-side"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_str_exc_never_appears_in_sse_stream(monkeypatch, caplog):
|
||||
"""Sanitization contract: the FULL ``str(exc)`` must never appear
|
||||
anywhere in the SSE stream bytes — not just in the ``error`` field.
|
||||
A regression that accidentally echoed ``str(exc)`` into a TEXT
|
||||
delta or a TOOL_CALL_ARGS payload would still leak internals.
|
||||
"""
|
||||
from agents import agent as agent_module
|
||||
|
||||
def _raise_handle(self):
|
||||
raise ValueError(_SENSITIVE_EXC_MESSAGE)
|
||||
|
||||
monkeypatch.setattr(agent_module.GetWeatherTool, "handle", _raise_handle)
|
||||
|
||||
tool_call = SimpleNamespace(
|
||||
id="call-bad-sse",
|
||||
function=SimpleNamespace(
|
||||
name="get_weather",
|
||||
arguments='{"location": "SF"}',
|
||||
),
|
||||
)
|
||||
response = SimpleNamespace(content="", tool_calls=[tool_call])
|
||||
_install_fake_openai(monkeypatch, response)
|
||||
|
||||
req = _FakeRequest(_minimal_run_input(thread_id="t-sse-sanit"))
|
||||
with caplog.at_level(logging.ERROR, logger=agui_adapter.logger.name):
|
||||
resp = await handle_run(req)
|
||||
raw_chunks = await _collect(resp)
|
||||
|
||||
raw_stream = "".join(raw_chunks)
|
||||
|
||||
# Absolute: the full sensitive message, byte-for-byte, must not appear.
|
||||
assert _SENSITIVE_EXC_MESSAGE not in raw_stream, (
|
||||
"full str(exc) leaked into SSE stream"
|
||||
)
|
||||
# And no substring of the private bits either.
|
||||
for needle in _FORBIDDEN_SUBSTRINGS:
|
||||
assert needle not in raw_stream, (
|
||||
f"forbidden internal detail {needle!r} leaked into SSE stream"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backend_tool_executes_via_unified_helper(monkeypatch):
|
||||
"""Both the oai-path and the content-JSON path go through the same
|
||||
``_execute_backend_tool`` helper. This test exercises the happy
|
||||
path through that helper to lock in the contract."""
|
||||
from agents import agent as agent_module
|
||||
|
||||
def _fake_handle(self):
|
||||
return '{"ok": true}'
|
||||
|
||||
monkeypatch.setattr(agent_module.GetWeatherTool, "handle", _fake_handle)
|
||||
|
||||
# Instantiate directly and invoke the helper synchronously — this
|
||||
# is the non-awaited unit under test (``to_thread`` is orthogonal).
|
||||
tool = agent_module.GetWeatherTool(location="SF")
|
||||
assert _execute_backend_tool(tool, "get_weather") == '{"ok": true}'
|
||||
|
||||
|
||||
def test_execute_backend_tool_sanitizes_narrowed_exception(caplog):
|
||||
"""``_execute_backend_tool`` sanitizes ``ValueError`` /
|
||||
``pydantic.ValidationError`` into a JSON payload — and logs the
|
||||
full traceback server-side."""
|
||||
from agents import agent as agent_module
|
||||
|
||||
class _BoomTool(agent_module.GetWeatherTool):
|
||||
def handle(self) -> str:
|
||||
raise ValueError(_SENSITIVE_EXC_MESSAGE)
|
||||
|
||||
tool = _BoomTool(location="SF")
|
||||
with caplog.at_level(logging.ERROR, logger=agui_adapter.logger.name):
|
||||
result = _execute_backend_tool(tool, "get_weather")
|
||||
|
||||
payload = json.loads(result)
|
||||
assert payload == {"error": "Tool get_weather failed: ValueError"}
|
||||
|
||||
# Traceback captured server-side.
|
||||
assert any(r.exc_info for r in caplog.records), (
|
||||
"expected logger.exception to capture traceback server-side"
|
||||
)
|
||||
|
||||
|
||||
def test_execute_backend_tool_propagates_unhandled_exception():
|
||||
"""Non-narrowed exceptions (``RuntimeError``, ``KeyError``, ...)
|
||||
must NOT be caught by the helper — they indicate real bugs or
|
||||
config drift and must propagate up so the outer framework can
|
||||
log/flag them. Silently sanitizing them hides real signal.
|
||||
"""
|
||||
from agents import agent as agent_module
|
||||
|
||||
class _BugTool(agent_module.GetWeatherTool):
|
||||
def handle(self) -> str:
|
||||
raise RuntimeError("internal library bug, not data-shape error")
|
||||
|
||||
tool = _BugTool(location="SF")
|
||||
with pytest.raises(RuntimeError, match="internal library bug"):
|
||||
_execute_backend_tool(tool, "get_weather")
|
||||
|
||||
|
||||
def test_try_parse_tool_non_str_content_warns_and_returns_none(caplog):
|
||||
"""The ``isinstance(content, (str, bytes, bytearray))`` guard in
|
||||
``_try_parse_tool`` must surface a WARNING (programmer bug signal)
|
||||
rather than silently swallowing the case under a plain-text fallback.
|
||||
"""
|
||||
from agents.agui_adapter import _try_parse_tool
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger=agui_adapter.logger.name):
|
||||
result = _try_parse_tool(12345) # type: ignore[arg-type]
|
||||
|
||||
assert result is None
|
||||
assert any("non-str/bytes content" in rec.getMessage() for rec in caplog.records), (
|
||||
f"expected type-guard warning, got: {[r.getMessage() for r in caplog.records]}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# bytes-args handling in _try_parse_tool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_try_parse_tool_function_call_bytes_arguments():
|
||||
"""Real OpenAI/httpx stacks can deliver ``function_call.arguments`` as
|
||||
``bytes`` (not ``str``). The function_call path must parse them the
|
||||
same way ``_parse_tool_args`` does — previously a narrow
|
||||
``isinstance(args, str)`` guard fell through to ``tool_cls(**args)``
|
||||
with raw bytes and silently TypeError'd."""
|
||||
from agents.agui_adapter import _try_parse_tool
|
||||
from agents import agent as agent_module
|
||||
|
||||
# Use a real backend tool from ALL_TOOLS so this exercises the actual
|
||||
# dispatch loop rather than a synthetic stub.
|
||||
request_name = agent_module.GetWeatherTool.default_value("request")
|
||||
data = {"name": request_name, "arguments": b'{"location": "SF"}'}
|
||||
import agents.agui_adapter as adapter_mod
|
||||
|
||||
real_loads = adapter_mod.json.loads
|
||||
calls: list[Any] = []
|
||||
|
||||
def _fake_loads(payload, *args, **kwargs):
|
||||
calls.append(payload)
|
||||
# First call: adapter loads the outer content. Return the
|
||||
# dict with bytes inner arguments.
|
||||
if len(calls) == 1:
|
||||
return data
|
||||
return real_loads(payload, *args, **kwargs)
|
||||
|
||||
import unittest.mock as mock
|
||||
|
||||
with mock.patch.object(adapter_mod.json, "loads", side_effect=_fake_loads):
|
||||
result = _try_parse_tool("ignored-outer")
|
||||
|
||||
assert result is not None, (
|
||||
"bytes arguments should round-trip through the (str, bytes, bytearray) guard"
|
||||
)
|
||||
assert isinstance(result, agent_module.GetWeatherTool)
|
||||
assert result.location == "SF"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# mid-stream _call_openai failure must not hang the UI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_openai_failure_emits_run_finished(monkeypatch, caplog):
|
||||
"""When ``_call_openai`` raises a narrowed runtime error
|
||||
(``openai.APIError`` / ``httpx.HTTPError`` / ``asyncio.TimeoutError``)
|
||||
*after* RUN_STARTED has been emitted, the generator must emit a
|
||||
sanitized TEXT_MESSAGE triple + RUN_FINISHED (never leave the
|
||||
frontend hanging).
|
||||
|
||||
Uses ``asyncio.TimeoutError`` as the representative covered
|
||||
exception — it's the simplest of the four to construct and
|
||||
exercises the same code path as the others.
|
||||
"""
|
||||
import asyncio as _asyncio
|
||||
|
||||
async def _exploding_call_openai(messages, tools, model):
|
||||
raise _asyncio.TimeoutError(
|
||||
"secret: postgres://user:pass@host/db /opt/app/internal.py line 99"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(agui_adapter, "_call_openai", _exploding_call_openai)
|
||||
|
||||
req = _FakeRequest(_minimal_run_input(thread_id="t-boom"))
|
||||
with caplog.at_level(logging.ERROR, logger=agui_adapter.logger.name):
|
||||
resp = await handle_run(req)
|
||||
raw_chunks = await _collect(resp)
|
||||
|
||||
events = _parse_events(raw_chunks)
|
||||
types = [e["type"] for e in events]
|
||||
assert types[0] == "RUN_STARTED"
|
||||
assert types[-1] == "RUN_FINISHED", (
|
||||
f"RUN_FINISHED must terminate the stream even on mid-stream failure, got: {types}"
|
||||
)
|
||||
assert "TEXT_MESSAGE_CONTENT" in types, (
|
||||
"sanitized error must be surfaced to the user"
|
||||
)
|
||||
|
||||
content = next(e for e in events if e["type"] == "TEXT_MESSAGE_CONTENT")
|
||||
payload = json.loads(content["delta"])
|
||||
assert "TimeoutError" in payload["error"]
|
||||
# And sanitization still holds — no internal details leak.
|
||||
raw_stream = "".join(raw_chunks)
|
||||
for needle in ("postgres://", "password", "/opt/app", "internal.py", "line 99"):
|
||||
assert needle not in raw_stream, (
|
||||
f"internal detail {needle!r} leaked into SSE stream"
|
||||
)
|
||||
|
||||
# Full traceback preserved server-side.
|
||||
assert any(r.exc_info for r in caplog.records), (
|
||||
"expected logger.exception to capture the _call_openai failure"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_openai_programmer_bug_propagates(monkeypatch):
|
||||
"""Programmer bugs (``AttributeError``, ``NameError``, ``TypeError``)
|
||||
from ``_call_openai`` must NOT be sanitized — they indicate real
|
||||
bugs and must propagate so the outer framework can log/flag them
|
||||
with a real traceback.
|
||||
"""
|
||||
|
||||
async def _typo_call_openai(messages, tools, model):
|
||||
raise AttributeError("typo")
|
||||
|
||||
monkeypatch.setattr(agui_adapter, "_call_openai", _typo_call_openai)
|
||||
|
||||
req = _FakeRequest(_minimal_run_input(thread_id="t-bug"))
|
||||
resp = await handle_run(req)
|
||||
with pytest.raises(AttributeError, match="typo"):
|
||||
await _collect(resp)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# request.json() / RunAgentInput failures get correlation ids
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_malformed_request_body_returns_structured_error():
|
||||
"""An unparseable request body must NOT raise an unstructured 500 —
|
||||
it must return a structured JSON response carrying an ``errorId``
|
||||
for correlation."""
|
||||
|
||||
class _BadJsonRequest:
|
||||
async def json(self) -> dict:
|
||||
raise json.JSONDecodeError("bad", "doc", 0)
|
||||
|
||||
resp = await handle_run(_BadJsonRequest()) # type: ignore[arg-type]
|
||||
assert resp.status_code == 400
|
||||
payload = json.loads(resp.body)
|
||||
assert payload["error"] == "Invalid JSON body"
|
||||
assert "errorId" in payload and payload["errorId"], (
|
||||
"every error response needs a correlation id"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_run_agent_input_returns_422():
|
||||
"""Body that's valid JSON but doesn't match ``RunAgentInput`` must
|
||||
return a 422 with a structured payload + correlation id."""
|
||||
# Missing every required field.
|
||||
req = _FakeRequest({})
|
||||
resp = await handle_run(req)
|
||||
assert resp.status_code == 422
|
||||
payload = json.loads(resp.body)
|
||||
assert payload["error"] == "Invalid RunAgentInput payload"
|
||||
assert payload.get("errorId"), "correlation id is required"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tool-collision RuntimeError names the colliding classes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_duplicate_tool_error_includes_class_identity():
|
||||
"""When two tool classes collide on ``request`` name, the startup
|
||||
RuntimeError must include fully-qualified class identities so the
|
||||
developer can find the duplicate definitions without grepping."""
|
||||
from langroid.agent.tool_message import ToolMessage
|
||||
|
||||
class ToolA(ToolMessage):
|
||||
request: str = "clashing"
|
||||
purpose: str = "a"
|
||||
|
||||
class ToolB(ToolMessage):
|
||||
request: str = "clashing"
|
||||
purpose: str = "b"
|
||||
|
||||
tools = [ToolA, ToolB]
|
||||
by_name: dict[str, list[str]] = {}
|
||||
for cls in tools:
|
||||
name = cls.default_value("request")
|
||||
ident = f"{cls.__module__}.{cls.__qualname__}"
|
||||
by_name.setdefault(name, []).append(ident)
|
||||
dupes = {n: ids for n, ids in by_name.items() if len(ids) > 1}
|
||||
|
||||
assert "clashing" in dupes
|
||||
idents = dupes["clashing"]
|
||||
# Both identities appear, and they carry the qualname — not just
|
||||
# the bare tool name.
|
||||
assert any("ToolA" in i for i in idents)
|
||||
assert any("ToolB" in i for i in idents)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plain_text_turn_does_not_warn(monkeypatch, caplog):
|
||||
"""A normal chat reply like "hello" is NOT JSON. The adapter's
|
||||
tool-parse fallback must fail silently — warning on every chat turn
|
||||
floods logs and drowns real signal."""
|
||||
response = SimpleNamespace(content="hello", tool_calls=None)
|
||||
_install_fake_openai(monkeypatch, response)
|
||||
|
||||
req = _FakeRequest(_minimal_run_input(thread_id="t-plain"))
|
||||
with caplog.at_level(logging.WARNING, logger=agui_adapter.logger.name):
|
||||
resp = await handle_run(req)
|
||||
events = _parse_events(await _collect(resp))
|
||||
|
||||
# Sanity: content was streamed back as text.
|
||||
content = next(e for e in events if e["type"] == "TEXT_MESSAGE_CONTENT")
|
||||
assert content["delta"] == "hello"
|
||||
|
||||
# The key assertion: NO warning-level log records from the adapter.
|
||||
adapter_warnings = [
|
||||
r
|
||||
for r in caplog.records
|
||||
if r.name == agui_adapter.logger.name and r.levelno >= logging.WARNING
|
||||
]
|
||||
assert adapter_warnings == [], (
|
||||
"plain-text turn unexpectedly logged warnings: "
|
||||
f"{[r.getMessage() for r in adapter_warnings]}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _agui_messages_to_openai: message conversion tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_agui_messages_to_openai_user_message():
|
||||
"""Simple user message is preserved."""
|
||||
from agents.agui_adapter import _agui_messages_to_openai
|
||||
|
||||
msgs = [SimpleNamespace(role="user", content="hello")]
|
||||
result = _agui_messages_to_openai(msgs, "sys prompt")
|
||||
assert result[0] == {"role": "system", "content": "sys prompt"}
|
||||
assert result[1] == {"role": "user", "content": "hello"}
|
||||
|
||||
|
||||
def test_agui_messages_to_openai_tool_message():
|
||||
"""Tool result messages preserve tool_call_id."""
|
||||
from agents.agui_adapter import _agui_messages_to_openai
|
||||
|
||||
msgs = [
|
||||
SimpleNamespace(role="tool", content="result text", tool_call_id="tc-123"),
|
||||
]
|
||||
result = _agui_messages_to_openai(msgs, "sys")
|
||||
assert result[1] == {
|
||||
"role": "tool",
|
||||
"tool_call_id": "tc-123",
|
||||
"content": "result text",
|
||||
}
|
||||
|
||||
|
||||
def test_agui_messages_to_openai_assistant_with_tool_calls():
|
||||
"""Assistant messages with tool_calls preserve the full structure."""
|
||||
from agents.agui_adapter import _agui_messages_to_openai
|
||||
|
||||
tc = SimpleNamespace(
|
||||
id="call-1",
|
||||
function=SimpleNamespace(name="show_card", arguments='{"title":"Ada"}'),
|
||||
)
|
||||
msgs = [
|
||||
SimpleNamespace(role="assistant", content=None, tool_calls=[tc]),
|
||||
]
|
||||
result = _agui_messages_to_openai(msgs, "sys")
|
||||
assistant_msg = result[1]
|
||||
assert assistant_msg["role"] == "assistant"
|
||||
assert assistant_msg["content"] is None # null, not missing
|
||||
assert len(assistant_msg["tool_calls"]) == 1
|
||||
assert assistant_msg["tool_calls"][0]["id"] == "call-1"
|
||||
assert assistant_msg["tool_calls"][0]["function"]["name"] == "show_card"
|
||||
|
||||
|
||||
def test_agui_messages_to_openai_full_tool_roundtrip():
|
||||
"""Full multi-turn: user -> assistant+tool_call -> tool_result.
|
||||
This is the exact sequence that gen-ui-headless needs for the
|
||||
follow-up aimock match to work."""
|
||||
from agents.agui_adapter import _agui_messages_to_openai
|
||||
|
||||
tc = SimpleNamespace(
|
||||
id="call_d5_show_card_001",
|
||||
function=SimpleNamespace(
|
||||
name="show_card",
|
||||
arguments='{"title":"Ada Lovelace","body":"mathematician"}',
|
||||
),
|
||||
)
|
||||
msgs = [
|
||||
SimpleNamespace(role="user", content="Show me a profile card for Ada Lovelace"),
|
||||
SimpleNamespace(role="assistant", content=None, tool_calls=[tc]),
|
||||
SimpleNamespace(role="tool", content="", tool_call_id="call_d5_show_card_001"),
|
||||
]
|
||||
result = _agui_messages_to_openai(msgs, "sys")
|
||||
|
||||
# System + user + assistant + tool = 4 messages
|
||||
assert len(result) == 4
|
||||
assert result[0]["role"] == "system"
|
||||
assert result[1]["role"] == "user"
|
||||
assert result[2]["role"] == "assistant"
|
||||
assert result[2]["tool_calls"][0]["id"] == "call_d5_show_card_001"
|
||||
assert result[3]["role"] == "tool"
|
||||
assert result[3]["tool_call_id"] == "call_d5_show_card_001"
|
||||
@@ -0,0 +1,312 @@
|
||||
"""Red→green tests for the langroid backend CVDIAG boundary instrumentation.
|
||||
|
||||
Exercises the REAL emit surface — every assertion reads the actual
|
||||
``CVDIAG {<json>}`` lines that ``_shared.cvdiag_bootstrap.emit_cvdiag`` writes
|
||||
to stdout (captured via ``capsys``), driven through the real
|
||||
``CvdiagBackendMiddleware`` and the real ``LlmCallScope`` / agent helpers. No
|
||||
mocks of the emit path.
|
||||
|
||||
What's covered (spec §3 / §5 / §6):
|
||||
* All 11 backend boundaries emit to stdout across the three request shapes
|
||||
that collectively exercise them (happy streaming, aborted stream, raised
|
||||
exception) for synthetic requests with ``CVDIAG_BACKEND_EMITTER=1`` (run at
|
||||
DEBUG tier so the verbose+debug boundaries are permitted).
|
||||
* PII scrub: a synthetic ``sk-test-12345`` in an exception message never
|
||||
appears in the emitted ``backend.error.caught`` JSON.
|
||||
* Heartbeat fires within ~12s of a slow-LLM simulation.
|
||||
* Default-OFF: with the flag unset, NO CVDIAG backend line is emitted.
|
||||
|
||||
RED before instrumentation: ``agents._cvdiag_backend`` does not exist →
|
||||
ImportError; the 11-boundary / heartbeat / scrub assertions cannot pass.
|
||||
GREEN after: every boundary, the scrub, and the heartbeat assert true.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Dict, List
|
||||
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.responses import StreamingResponse
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from agents._cvdiag_backend import (
|
||||
CvdiagBackendMiddleware,
|
||||
LlmCallScope,
|
||||
_RequestCtx,
|
||||
emit_agent_enter,
|
||||
emit_agent_exit,
|
||||
scrub,
|
||||
)
|
||||
|
||||
# The 11 backend boundaries (spec §5).
|
||||
ALL_BACKEND_BOUNDARIES = {
|
||||
"backend.request.ingress",
|
||||
"backend.agent.enter",
|
||||
"backend.llm.call.start",
|
||||
"backend.llm.call.heartbeat",
|
||||
"backend.llm.call.response",
|
||||
"backend.sse.first_byte",
|
||||
"backend.sse.event",
|
||||
"backend.sse.aborted",
|
||||
"backend.agent.exit",
|
||||
"backend.response.complete",
|
||||
"backend.error.caught",
|
||||
}
|
||||
|
||||
VALID_TEST_ID = "0190a9c0-1a2b-7c3d-8e4f-5a6b7c8d9e0f"
|
||||
|
||||
|
||||
def _parse_cvdiag_lines(captured: str) -> List[Dict]:
|
||||
"""Extract every ``CVDIAG {<json>}`` envelope line from captured stdout."""
|
||||
out: List[Dict] = []
|
||||
for line in captured.splitlines():
|
||||
if line.startswith("CVDIAG {"):
|
||||
out.append(json.loads(line[len("CVDIAG ") :]))
|
||||
return out
|
||||
|
||||
|
||||
def _boundaries(envelopes: List[Dict]) -> set:
|
||||
return {e["boundary"] for e in envelopes}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _debug_tier(monkeypatch):
|
||||
"""Run each test at DEBUG tier so verbose+debug boundaries are permitted.
|
||||
|
||||
``current_tier()`` is resolved once at bootstrap import; re-resolve it under
|
||||
a non-production env with ``CVDIAG_DEBUG=1`` so the §6 matrix lets
|
||||
``backend.sse.event`` (debug) and the verbose LLM boundaries through.
|
||||
"""
|
||||
import _shared.cvdiag_bootstrap as bootstrap
|
||||
|
||||
monkeypatch.setenv("SHOWCASE_ENV", "test")
|
||||
monkeypatch.setenv("CVDIAG_DEBUG", "1")
|
||||
bootstrap.setup({"SHOWCASE_ENV": "test", "CVDIAG_DEBUG": "1"})
|
||||
yield
|
||||
bootstrap.setup({"SHOWCASE_ENV": "test"})
|
||||
|
||||
|
||||
def _make_client(*, raise_server_exceptions: bool = True) -> TestClient:
|
||||
"""An app exposing three routes — happy stream, aborted stream, raise — each
|
||||
wrapped by the CVDIAG middleware. The endpoints emit the agent/LLM
|
||||
boundaries the middleware cannot observe, all keyed on the per-request ctx.
|
||||
"""
|
||||
|
||||
async def happy_stream(request):
|
||||
ctx = getattr(request.state, "cvdiag", None)
|
||||
if ctx is not None:
|
||||
emit_agent_enter(ctx, agent_name="showcase", model_id="gpt-4o-mini")
|
||||
|
||||
async def gen():
|
||||
if ctx is not None:
|
||||
async with LlmCallScope(
|
||||
ctx, provider="openai", model="gpt-4o-mini", interval_s=0.02
|
||||
):
|
||||
await asyncio.sleep(0.05) # let the heartbeat tick once
|
||||
yield b"data: hello\n\n"
|
||||
yield b"data: world\n\n"
|
||||
emit_agent_exit(ctx, terminal_outcome="ok", total_duration_ms=1)
|
||||
else:
|
||||
yield b"data: hello\n\n"
|
||||
|
||||
return StreamingResponse(gen(), media_type="text/event-stream")
|
||||
|
||||
async def raises(request):
|
||||
raise RuntimeError("upstream rejected key sk-test-12345 Bearer abc.def.ghi")
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route("/", happy_stream, methods=["POST"]),
|
||||
Route("/boom", raises, methods=["POST"]),
|
||||
]
|
||||
)
|
||||
app.add_middleware(CvdiagBackendMiddleware)
|
||||
return TestClient(app, raise_server_exceptions=raise_server_exceptions)
|
||||
|
||||
|
||||
async def _drive_abort() -> None:
|
||||
"""Drive the CVDIAG middleware over an unbounded stream and disconnect.
|
||||
|
||||
Builds the middleware around an unbounded inner stream, calls ``dispatch``
|
||||
to get the wrapped ``body_iterator``, reads one chunk, then ``aclose()``s it
|
||||
— the deterministic equivalent of a client disconnecting mid-stream. This
|
||||
raises ``GeneratorExit`` into the wrapper → ``backend.sse.aborted``.
|
||||
"""
|
||||
from starlette.requests import Request
|
||||
|
||||
async def unbounded():
|
||||
i = 0
|
||||
while True:
|
||||
yield f"data: chunk-{i}\n\n".encode()
|
||||
i += 1
|
||||
|
||||
inner_response = StreamingResponse(unbounded(), media_type="text/event-stream")
|
||||
|
||||
async def call_next(_request):
|
||||
return inner_response
|
||||
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/",
|
||||
"headers": [(b"x-aimock-context", b"langroid")],
|
||||
"query_string": b"",
|
||||
}
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b""}
|
||||
|
||||
mw = CvdiagBackendMiddleware(app=lambda *a: None)
|
||||
request = Request(scope, receive)
|
||||
wrapped = await mw.dispatch(request, call_next)
|
||||
|
||||
body = wrapped.body_iterator
|
||||
await body.__anext__() # first chunk
|
||||
await body.aclose() # client disconnect mid-stream
|
||||
|
||||
|
||||
def test_all_eleven_backend_boundaries_emit(monkeypatch, capsys):
|
||||
"""All 11 backend boundaries emit across the three request shapes.
|
||||
|
||||
The happy stream yields ingress / agent.enter / llm.* / sse.first_byte /
|
||||
sse.event / agent.exit / response.complete; a disconnected stream yields
|
||||
sse.aborted; the raising route yields error.caught. Their union is the full
|
||||
eleven.
|
||||
"""
|
||||
monkeypatch.setenv("CVDIAG_BACKEND_EMITTER", "1")
|
||||
client = _make_client(raise_server_exceptions=False)
|
||||
|
||||
headers = {"x-test-id": VALID_TEST_ID, "x-aimock-context": "langroid"}
|
||||
resp = client.post("/", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Client-disconnect abort surface (→ backend.sse.aborted), driven directly
|
||||
# because Starlette's sync TestClient cannot reliably tear a stream down
|
||||
# mid-flight.
|
||||
asyncio.run(_drive_abort())
|
||||
|
||||
client.post("/boom", headers=headers)
|
||||
|
||||
envelopes = _parse_cvdiag_lines(capsys.readouterr().out)
|
||||
seen = _boundaries(envelopes)
|
||||
|
||||
missing = ALL_BACKEND_BOUNDARIES - seen
|
||||
assert not missing, (
|
||||
f"missing backend boundaries: {sorted(missing)}; saw {sorted(seen)}"
|
||||
)
|
||||
|
||||
# Correlation: every backend envelope carries the slug. The header-bearing
|
||||
# HTTP requests forward x-test-id verbatim; the directly driven abort
|
||||
# request mints its own UUIDv7 (no inbound header). Assert the forwarded
|
||||
# test_id appears on the header-bearing envelopes, and every minted id is a
|
||||
# well-formed UUIDv7.
|
||||
backend = [e for e in envelopes if e["layer"] == "backend"]
|
||||
assert backend, "no backend-layer envelopes emitted"
|
||||
assert all(e["slug"] == "langroid" for e in backend)
|
||||
forwarded = [e for e in backend if e["test_id"] == VALID_TEST_ID]
|
||||
assert forwarded, "forwarded x-test-id never appeared on any backend envelope"
|
||||
uuid7_re = __import__("re").compile(
|
||||
r"^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
||||
)
|
||||
assert all(uuid7_re.match(e["test_id"]) for e in backend)
|
||||
# Closed 9-key edge-header bag always present on a header-bearing ingress.
|
||||
ingress = next(
|
||||
e
|
||||
for e in backend
|
||||
if e["boundary"] == "backend.request.ingress" and e["test_id"] == VALID_TEST_ID
|
||||
)
|
||||
assert set(ingress["edge_headers"].keys()) == {
|
||||
"cf-ray",
|
||||
"cf-mitigated",
|
||||
"cf-cache-status",
|
||||
"x-railway-edge",
|
||||
"x-railway-request-id",
|
||||
"x-hikari-trace",
|
||||
"retry-after",
|
||||
"via",
|
||||
"server",
|
||||
}
|
||||
|
||||
|
||||
def test_error_caught_scrubs_secret(monkeypatch, capsys):
|
||||
"""A synthetic ``sk-test-12345`` in an exception never reaches the emitted
|
||||
``backend.error.caught`` envelope."""
|
||||
monkeypatch.setenv("CVDIAG_BACKEND_EMITTER", "1")
|
||||
client = _make_client(raise_server_exceptions=False)
|
||||
|
||||
client.post("/boom", headers={"x-aimock-context": "langroid"})
|
||||
|
||||
out = capsys.readouterr().out
|
||||
envelopes = _parse_cvdiag_lines(out)
|
||||
errs = [e for e in envelopes if e["boundary"] == "backend.error.caught"]
|
||||
assert errs, "backend.error.caught not emitted"
|
||||
err = errs[0]
|
||||
assert err["metadata"]["exception_type"] == "RuntimeError"
|
||||
blob = json.dumps(err)
|
||||
assert "sk-test-12345" not in blob, "raw secret leaked into error envelope"
|
||||
assert "Bearer abc" not in blob, "raw bearer token leaked into error envelope"
|
||||
assert "[REDACTED]" in err["metadata"]["message_scrubbed"]
|
||||
|
||||
|
||||
def test_scrub_helper_redacts_known_secret_shapes():
|
||||
"""Unit-level: the scrub helper redacts bearer/sk-/pk-/userinfo shapes."""
|
||||
assert "sk-test-12345" not in scrub("key sk-test-12345 here")
|
||||
assert "sk-abcdefghijklmnopqrstuvwx" not in scrub("sk-abcdefghijklmnopqrstuvwx")
|
||||
assert "Bearer secrettoken" not in scrub("auth Bearer secrettoken")
|
||||
assert "pw" not in scrub("https://user:pw@host/path")
|
||||
assert scrub(None) == ""
|
||||
|
||||
|
||||
def test_heartbeat_fires_within_window(monkeypatch, capsys):
|
||||
"""``backend.llm.call.heartbeat`` fires while a slow LLM call is outstanding.
|
||||
|
||||
Uses a short interval so the test is fast; the production interval is ~10s
|
||||
and the spec requires a heartbeat within ~12s of a slow-LLM simulation —
|
||||
proven here by the same code path firing within its interval.
|
||||
"""
|
||||
monkeypatch.setenv("CVDIAG_BACKEND_EMITTER", "1")
|
||||
|
||||
async def run():
|
||||
ctx = _RequestCtx(test_id=VALID_TEST_ID, slug="langroid", demo="default")
|
||||
async with LlmCallScope(ctx, provider="openai", model="m", interval_s=0.05):
|
||||
await asyncio.sleep(0.18) # ~3 heartbeat intervals
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
envelopes = _parse_cvdiag_lines(capsys.readouterr().out)
|
||||
hb = [e for e in envelopes if e["boundary"] == "backend.llm.call.heartbeat"]
|
||||
assert hb, "no heartbeat emitted during a slow LLM call"
|
||||
assert all("elapsed_ms_since_start" in e["metadata"] for e in hb)
|
||||
|
||||
|
||||
def test_sse_aborted_on_client_disconnect(monkeypatch, capsys):
|
||||
"""Tearing the response stream down mid-flight emits ``backend.sse.aborted``
|
||||
with a ``termination_kind`` and the bytes streamed before the abort."""
|
||||
monkeypatch.setenv("CVDIAG_BACKEND_EMITTER", "1")
|
||||
|
||||
asyncio.run(_drive_abort())
|
||||
|
||||
envelopes = _parse_cvdiag_lines(capsys.readouterr().out)
|
||||
aborts = [e for e in envelopes if e["boundary"] == "backend.sse.aborted"]
|
||||
assert aborts, "backend.sse.aborted not emitted on client disconnect"
|
||||
meta = aborts[0]["metadata"]
|
||||
assert meta["termination_kind"] in {"rst", "timeout", "chunk_error"}
|
||||
assert meta["bytes_before_abort"] > 0
|
||||
# A disconnected stream must NOT also report a clean response.complete.
|
||||
completes = [e for e in envelopes if e["boundary"] == "backend.response.complete"]
|
||||
assert not completes, "clean response.complete emitted for an aborted stream"
|
||||
|
||||
|
||||
def test_disabled_by_default_emits_nothing(monkeypatch, capsys):
|
||||
"""With ``CVDIAG_BACKEND_EMITTER`` unset, NO backend CVDIAG line is emitted."""
|
||||
monkeypatch.delenv("CVDIAG_BACKEND_EMITTER", raising=False)
|
||||
client = _make_client()
|
||||
client.post("/", headers={"x-aimock-context": "langroid"})
|
||||
|
||||
envelopes = _parse_cvdiag_lines(capsys.readouterr().out)
|
||||
backend = [e for e in envelopes if e["layer"] == "backend"]
|
||||
assert backend == [], f"emitter fired while disabled: {backend}"
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user