Files
wehub-resource-sync 25576b0be6
Checks (magui2.0) / python-lint (push) Failing after 1s
Checks (magui2.0) / python-test (push) Failing after 0s
Checks (magui2.0) / frontend-lint (push) Failing after 0s
CodeQL Advanced / Analyze (actions) (push) Failing after 1s
Checks (magui2.0) / python-format (push) Failing after 1s
CodeQL Advanced / Analyze (python) (push) Failing after 0s
Checks (magui2.0) / python-pyright (push) Failing after 1s
Checks (magui2.0) / frontend-format (push) Failing after 1s
Checks (magui2.0) / frontend-typecheck (push) Failing after 0s
Checks (magui2.0) / frontend-test (push) Failing after 2s
CodeQL Advanced / Analyze (javascript-typescript) (push) Failing after 1s
chore: import upstream snapshot with attribution
2026-07-13 12:24:32 +08:00

485 lines
18 KiB
Python

"""Tests for the delegate_cua harness layer.
Covers:
- DELEGATE_CUA_DEF schema shape (task / context / files params)
- build_delegate_cua_task helper: structure, error handling, truncation
- run_stream dispatch wires delegate_cua args through build_delegate_cua_task
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from magentic_ui.agents.base import Capability
from magentic_ui.agents.registry import AgentEntry, AgentRegistry
from magentic_ui.magentic_ui_config import ApprovalPolicy
from magentic_ui.sandbox._null import NullSandbox
from magentic_ui.teams.omniagent._omni_agent import OmniAgent
from magentic_ui.teams.omniagent._registry import (
DELEGATE_CUA_DEF,
_DELEGATE_CUA_FILE_PREVIEW_CHARS,
_DELEGATE_CUA_FILES_TOTAL_CHARS,
build_delegate_cua_task,
)
# ---------------------------------------------------------------------------
# Schema shape
# ---------------------------------------------------------------------------
class TestSchema:
def test_required_param_is_task_only(self) -> None:
params = DELEGATE_CUA_DEF["function"]["parameters"]
assert params["required"] == ["task"]
assert set(params["properties"].keys()) == {"task", "context", "files"}
def test_files_is_array_of_strings(self) -> None:
files_def = DELEGATE_CUA_DEF["function"]["parameters"]["properties"]["files"]
assert files_def["type"] == "array"
assert files_def["items"] == {"type": "string"}
def test_description_includes_when_to_use(self) -> None:
desc = DELEGATE_CUA_DEF["function"]["description"]
assert "WHEN TO USE" in desc
assert "WHEN NOT TO USE" in desc
# ---------------------------------------------------------------------------
# build_delegate_cua_task — unit tests with mocked _read_file
# ---------------------------------------------------------------------------
def _patch_read(side_effect: list[dict[str, Any]] | dict[str, Any]):
"""Patch _read_file in _registry to return canned responses."""
return patch(
"magentic_ui.teams.omniagent._registry._read_file",
new=AsyncMock(
side_effect=side_effect if isinstance(side_effect, list) else None,
return_value=None if isinstance(side_effect, list) else side_effect,
),
)
class TestBuildDelegateCuaTask:
@pytest.mark.asyncio
async def test_task_only(self) -> None:
result = await build_delegate_cua_task(
MagicMock(), task="Look up the weather", context="", file_paths=[]
)
assert result == "Look up the weather"
@pytest.mark.asyncio
async def test_task_plus_context(self) -> None:
result = await build_delegate_cua_task(
MagicMock(),
task="Apply for the role",
context="URL is https://example.com/jobs/eng",
file_paths=[],
)
assert "Apply for the role" in result
assert "Background:\nURL is https://example.com/jobs/eng" in result
assert result.index("Apply for the role") < result.index("Background:")
@pytest.mark.asyncio
async def test_files_inlined(self) -> None:
with _patch_read(
{"content": "Cheng Tan, software engineer.", "total_lines": 1}
):
result = await build_delegate_cua_task(
MagicMock(),
task="Apply with my resume",
context="",
file_paths=["./resume.docx"],
)
assert "Apply with my resume" in result
assert "File ./resume.docx (extracted to text):" in result
assert "Cheng Tan, software engineer." in result
@pytest.mark.asyncio
async def test_missing_file_emits_inline_error(self) -> None:
with _patch_read({"error": "file not found"}):
result = await build_delegate_cua_task(
MagicMock(),
task="Open it",
context="",
file_paths=["./missing.txt"],
)
assert "[File ./missing.txt: file not found]" in result
@pytest.mark.asyncio
async def test_per_file_truncation(self) -> None:
"""File over per-file cap is head-truncated with a char-count marker."""
big = "x" * (_DELEGATE_CUA_FILE_PREVIEW_CHARS + 1000)
with _patch_read({"content": big, "total_lines": 1}):
result = await build_delegate_cua_task(
MagicMock(), task="t", context="", file_paths=["./big.txt"]
)
# Truncated body present
assert "x" * _DELEGATE_CUA_FILE_PREVIEW_CHARS in result
# Marker mentions actual size
assert f"{_DELEGATE_CUA_FILE_PREVIEW_CHARS + 1000} total chars" in result
# Full untruncated content NOT present
assert "x" * (_DELEGATE_CUA_FILE_PREVIEW_CHARS + 500) not in result
@pytest.mark.asyncio
async def test_total_budget_exhausted_stops_iteration(self) -> None:
"""Many files exceed the total cap; later files are skipped or partially included."""
# Three files each at the per-file cap → 3 * 8K = 24K, exceeds 16K total.
per_file = "a" * _DELEGATE_CUA_FILE_PREVIEW_CHARS
with _patch_read(
[
{"content": per_file, "total_lines": 1},
{"content": per_file, "total_lines": 1},
{"content": per_file, "total_lines": 1},
]
):
result = await build_delegate_cua_task(
MagicMock(),
task="t",
context="",
file_paths=["a.txt", "b.txt", "c.txt"],
)
# First two files appear; total rendered output capped near budget.
assert "File a.txt" in result
assert "File b.txt" in result
# Measure the actual rendered output size rather than counting only
# "a" chars, which would ignore headers, separators, and markers.
# Allow a small fixed overhead for task text and per-file headers.
assert len(result) <= _DELEGATE_CUA_FILES_TOTAL_CHARS + 200
# ---------------------------------------------------------------------------
# Dispatch integration — run_stream → build_delegate_cua_task → fara stub
# ---------------------------------------------------------------------------
def _make_completion(text: str, total_tokens: int = 50) -> MagicMock:
response = MagicMock()
response.choices = [MagicMock()]
response.choices[0].message.content = text
response.usage = MagicMock(
prompt_tokens=total_tokens - 5,
completion_tokens=5,
total_tokens=total_tokens,
)
return response
def _mock_llm_client(responses: list[str]) -> MagicMock:
from ._stream_mock import install_stream_mock
client = MagicMock()
install_stream_mock(client, [_make_completion(t) for t in responses])
return client
class _FaraStub:
"""Minimal sub-agent stub that records what it was called with."""
def __init__(self) -> None:
self.received_task: str | None = None
async def run_stream(self, task: str = "", **_: Any):
self.received_task = task
from magentic_ui.agents.web_surfer.fara._types import StreamUpdate
yield StreamUpdate(text="weather is sunny", additional_properties={})
async def close(self) -> None:
pass
class TestDispatchWiring:
@pytest.mark.asyncio
async def test_delegate_cua_call_routes_through_builder(
self, tmp_path: Path
) -> None:
"""LLM emits delegate_cua with task/context/files; fara stub receives
the assembled string with all three pieces inlined."""
client = _mock_llm_client(
[
# Round 1: model delegates with all three params
'<tool_call>{"name": "delegate_cua", "arguments": '
'{"task": "Look up news", "context": "User in NYC", '
'"files": ["./note.txt"]}}</tool_call>',
# Round 2: model wraps up
"<answer>done</answer>",
]
)
sandbox = NullSandbox(workspace=tmp_path)
await sandbox.__aenter__()
try:
fara = _FaraStub()
registry = AgentRegistry()
registry.register(
AgentEntry(
agent=fara, # pyright: ignore[reportArgumentType]
tool_definition=DELEGATE_CUA_DEF,
capabilities=frozenset({Capability.WEB_BROWSING}),
)
)
agent = OmniAgent(
client=client,
model="m",
host_workspace=tmp_path,
sandbox=sandbox,
agent_registry=registry,
approval_policy=ApprovalPolicy.AUTO_APPROVE,
)
with patch(
"magentic_ui.teams.omniagent._registry._read_file",
new=AsyncMock(return_value={"content": "user note", "total_lines": 1}),
):
async for _ in agent.run_stream("test"):
pass
finally:
await sandbox.__aexit__(None, None, None)
assert fara.received_task is not None
assert "Look up news" in fara.received_task
assert "Background:\nUser in NYC" in fara.received_task
assert "File ./note.txt" in fara.received_task
assert "user note" in fara.received_task
@pytest.mark.asyncio
async def test_delegate_cua_malformed_args_do_not_crash(
self, tmp_path: Path
) -> None:
"""Model-emitted tool args may have wrong types (string instead of
list, dict instead of string). Dispatch coerces to safe defaults
rather than aborting the run."""
client = _mock_llm_client(
[
# files is a string (not a list); context is a dict (not a
# string). Without coercion, files would be iterated
# char-by-char and context.strip() would raise.
'<tool_call>{"name": "delegate_cua", "arguments": '
'{"task": "go", "context": {"oops": 1}, '
'"files": "./single.txt"}}</tool_call>',
"<answer>done</answer>",
]
)
sandbox = NullSandbox(workspace=tmp_path)
await sandbox.__aenter__()
try:
fara = _FaraStub()
registry = AgentRegistry()
registry.register(
AgentEntry(
agent=fara, # pyright: ignore[reportArgumentType]
tool_definition=DELEGATE_CUA_DEF,
capabilities=frozenset({Capability.WEB_BROWSING}),
)
)
agent = OmniAgent(
client=client,
model="m",
host_workspace=tmp_path,
sandbox=sandbox,
agent_registry=registry,
approval_policy=ApprovalPolicy.AUTO_APPROVE,
)
read_mock = AsyncMock(return_value={"content": "x", "total_lines": 1})
with patch(
"magentic_ui.teams.omniagent._registry._read_file",
new=read_mock,
):
async for _ in agent.run_stream("test"):
pass
finally:
await sandbox.__aexit__(None, None, None)
# Helper got the task; bad context/files were dropped, not iterated.
assert fara.received_task == "go"
read_mock.assert_not_awaited()
# ---------------------------------------------------------------------------
# Pool-full handling — sub-agent raises BrowserSlotPoolFullError, OmniAgent
# yields an InputRequest and retries WITHOUT losing its in-memory state
# (issue #587).
# ---------------------------------------------------------------------------
class _PoolFullThenSucceedFara:
"""Sub-agent that fails the first run with BrowserSlotPoolFullError,
then succeeds on the retry. Records the call count so we can assert
OmniAgent retries instead of giving up.
"""
def __init__(self) -> None:
self.call_count = 0
self.received_tasks: list[str] = []
async def run_stream(self, task: str = "", **_: Any):
from magentic_ui.agents.web_surfer.fara._types import StreamUpdate
from magentic_ui.tools.playwright.browser import BrowserSlotPoolFullError
self.call_count += 1
self.received_tasks.append(task)
if self.call_count == 1:
raise BrowserSlotPoolFullError("pool full")
yield StreamUpdate(text="weather is sunny", additional_properties={})
async def close(self) -> None:
pass
class TestPoolFullRetry:
@pytest.mark.asyncio
async def test_pool_full_yields_input_request_and_retries(
self, tmp_path: Path
) -> None:
"""When delegate_cua hits a full browser pool, OmniAgent yields an
InputRequest, awaits the user's reply, then retries the sub-agent
call. OmniAgent's chat history / viewport / tool outputs are
intentionally preserved across the retry — that's the whole point
of handling this inside OmniAgent rather than tearing down the TM.
"""
from magentic_ui.types import InputRequest
client = _mock_llm_client(
[
# Round 1: model delegates a single web task.
'<tool_call>{"name": "delegate_cua", "arguments": '
'{"task": "Look up news"}}</tool_call>',
# Round 2 (after successful retry): model wraps up.
"<answer>done</answer>",
]
)
sandbox = NullSandbox(workspace=tmp_path)
await sandbox.__aenter__()
try:
fara = _PoolFullThenSucceedFara()
registry = AgentRegistry()
registry.register(
AgentEntry(
agent=fara, # pyright: ignore[reportArgumentType]
tool_definition=DELEGATE_CUA_DEF,
capabilities=frozenset({Capability.WEB_BROWSING}),
)
)
agent = OmniAgent(
client=client,
model="m",
host_workspace=tmp_path,
sandbox=sandbox,
agent_registry=registry,
approval_policy=ApprovalPolicy.AUTO_APPROVE,
)
input_requests: list[InputRequest] = []
async for event in agent.run_stream("test"):
if isinstance(event, InputRequest):
input_requests.append(event)
# Simulate the user replying — content doesn't matter,
# OmniAgent retries regardless (the abort path is Stop).
event.respond("continue")
finally:
await sandbox.__aexit__(None, None, None)
# Exactly one InputRequest was emitted, with the user-facing text.
assert len(input_requests) == 1
assert "web browser" in input_requests[0].prompt.lower()
assert "continue" in input_requests[0].prompt.lower()
# Sub-agent was retried after the user reply: 1st call raised, 2nd
# call ran. Both received the same task — OmniAgent didn't mangle it.
assert fara.call_count == 2
assert fara.received_tasks == ["Look up news", "Look up news"]
# ---------------------------------------------------------------------------
# Delegate model-error abort — a delegated sub-agent whose model keeps
# failing should end the run with an error status instead of looping.
# ---------------------------------------------------------------------------
class _ModelErrorFara:
"""Sub-agent stub that always fails with a model_error handoff."""
def __init__(self) -> None:
self.call_count = 0
async def run_stream(self, task: str = "", **_: Any):
from magentic_ui.agents.message_schemas import (
HandoffReason,
HandoffStatus,
final_answer_props,
)
from magentic_ui.agents.web_surfer.fara._types import StreamUpdate
self.call_count += 1
info = {
"status": HandoffStatus.ERROR.value,
"reason": HandoffReason.MODEL_ERROR.value,
"last_url": None,
"facts": [],
}
yield StreamUpdate(
text="The model server rejected the API key.",
additional_properties=dict(
final_answer_props("web_surfer", handoff=info) # type: ignore[arg-type]
),
)
async def close(self) -> None:
pass
class TestDelegateModelErrorAbort:
@pytest.mark.asyncio
async def test_repeated_model_error_aborts_run(self, tmp_path: Path) -> None:
"""When the delegate keeps returning a model_error handoff, OmniAgent
aborts with a system error rather than re-delegating forever."""
# The model stubbornly re-delegates every round.
client = _mock_llm_client(
[
'<tool_call>{"name": "delegate_cua", "arguments": '
'{"task": "browse"}}</tool_call>'
]
* 5
)
sandbox = NullSandbox(workspace=tmp_path)
await sandbox.__aenter__()
try:
fara = _ModelErrorFara()
registry = AgentRegistry()
registry.register(
AgentEntry(
agent=fara, # pyright: ignore[reportArgumentType]
tool_definition=DELEGATE_CUA_DEF,
capabilities=frozenset({Capability.WEB_BROWSING}),
)
)
agent = OmniAgent(
client=client,
model="m",
host_workspace=tmp_path,
sandbox=sandbox,
agent_registry=registry,
approval_policy=ApprovalPolicy.AUTO_APPROVE,
)
system_errors = [
event.additional_properties
async for event in agent.run_stream("test")
if getattr(event, "additional_properties", None)
and event.additional_properties.get("type") == "system"
and event.additional_properties.get("status") == "error"
]
finally:
await sandbox.__aexit__(None, None, None)
# Aborted after the 2nd consecutive model error, not after all 5.
assert fara.call_count == 2
assert len(system_errors) == 1
assert "API key" in system_errors[0]["content"]