Files
2026-07-13 12:58:18 +08:00

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"