923 lines
35 KiB
Python
923 lines
35 KiB
Python
"""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"
|