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
263 lines
8.6 KiB
Python
263 lines
8.6 KiB
Python
"""Tests for fara state I/O — message round-trip serialization + trim integration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
from PIL import Image
|
|
|
|
from magentic_ui.agents.web_surfer.fara._fara_qwen3 import (
|
|
FaraQwen3Agent,
|
|
FaraQwen3AgentConfig,
|
|
FaraQwen3AgentState,
|
|
)
|
|
from magentic_ui.agents.web_surfer.fara._state_io import (
|
|
message_from_dict,
|
|
message_to_dict,
|
|
)
|
|
from magentic_ui.agents.web_surfer.fara._types import ImageObj, LLMMessage
|
|
|
|
|
|
def _img(color: str = "red") -> ImageObj:
|
|
return ImageObj.from_pil(Image.new("RGB", (8, 8), color))
|
|
|
|
|
|
def test_text_only_message_round_trips():
|
|
msg = LLMMessage(role="user", content="hello world", metadata={"k": "v"})
|
|
out = message_from_dict(message_to_dict(msg))
|
|
assert out.role == "user"
|
|
assert out.content == "hello world"
|
|
assert out.metadata == {"k": "v"}
|
|
|
|
|
|
def test_assistant_text_message_no_metadata():
|
|
msg = LLMMessage(role="assistant", content="reply")
|
|
out = message_from_dict(message_to_dict(msg))
|
|
assert out.role == "assistant"
|
|
assert out.content == "reply"
|
|
assert out.metadata is None
|
|
|
|
|
|
def test_system_message_round_trips():
|
|
msg = LLMMessage(role="system", content="you are a helpful agent")
|
|
out = message_from_dict(message_to_dict(msg))
|
|
assert out.role == "system"
|
|
assert out.content == "you are a helpful agent"
|
|
|
|
|
|
def test_image_plus_text_round_trips():
|
|
img = _img("blue")
|
|
msg = LLMMessage(
|
|
role="user",
|
|
content=[img, "describe this"],
|
|
metadata={"is_original": True},
|
|
)
|
|
out = message_from_dict(message_to_dict(msg))
|
|
|
|
assert out.role == "user"
|
|
assert out.metadata == {"is_original": True}
|
|
assert isinstance(out.content, list)
|
|
assert len(out.content) == 2
|
|
|
|
restored_img, restored_text = out.content
|
|
assert isinstance(restored_img, ImageObj)
|
|
assert restored_img.image.size == (8, 8)
|
|
# Pixel data preserved
|
|
assert restored_img.image.getpixel((0, 0)) == (0, 0, 255)
|
|
assert restored_text == "describe this"
|
|
|
|
|
|
def test_multiple_images_in_one_message():
|
|
msg = LLMMessage(
|
|
role="user",
|
|
content=[_img("red"), "first", _img("green"), "second"],
|
|
)
|
|
out = message_from_dict(message_to_dict(msg))
|
|
|
|
assert isinstance(out.content, list)
|
|
assert len(out.content) == 4
|
|
assert isinstance(out.content[0], ImageObj)
|
|
assert out.content[1] == "first"
|
|
assert isinstance(out.content[2], ImageObj)
|
|
assert out.content[3] == "second"
|
|
# Distinct pixel values prove we didn't share encoded bytes
|
|
assert out.content[0].image.getpixel((0, 0)) == (255, 0, 0)
|
|
assert out.content[2].image.getpixel((0, 0)) == (0, 128, 0)
|
|
|
|
|
|
def test_dict_form_is_json_serializable():
|
|
msg = LLMMessage(role="user", content=[_img(), "x"], metadata={"a": 1})
|
|
encoded = message_to_dict(msg)
|
|
# Round-trip through json.dumps to confirm no non-serializable bits leaked
|
|
text = json.dumps(encoded)
|
|
decoded = json.loads(text)
|
|
out = message_from_dict(decoded)
|
|
assert isinstance(out.content, list)
|
|
assert isinstance(out.content[0], ImageObj)
|
|
assert out.content[1] == "x"
|
|
|
|
|
|
def test_unknown_part_type_is_skipped():
|
|
payload = {
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "ok"},
|
|
{"type": "wat", "data": "ignored"},
|
|
],
|
|
"metadata": None,
|
|
}
|
|
out = message_from_dict(payload)
|
|
assert isinstance(out.content, list)
|
|
assert out.content == ["ok"]
|
|
|
|
|
|
def test_missing_content_falls_back_to_empty_string():
|
|
out = message_from_dict({"role": "assistant"})
|
|
assert out.role == "assistant"
|
|
assert out.content == ""
|
|
|
|
|
|
def test_missing_role_defaults_to_user():
|
|
out = message_from_dict({"content": "hi"})
|
|
assert out.role == "user"
|
|
assert out.content == "hi"
|
|
|
|
|
|
def test_image_b64_part_without_data_is_skipped():
|
|
payload = {
|
|
"role": "user",
|
|
"content": [{"type": "image_b64"}, {"type": "text", "text": "kept"}],
|
|
}
|
|
out = message_from_dict(payload)
|
|
assert isinstance(out.content, list)
|
|
assert out.content == ["kept"]
|
|
|
|
|
|
@pytest.mark.parametrize("role", ["user", "assistant", "system"])
|
|
def test_round_trip_preserves_role(role):
|
|
msg = LLMMessage(role=role, content="x") # type: ignore[arg-type]
|
|
assert message_from_dict(message_to_dict(msg)).role == role
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Integration with FaraQwen3Agent.maybe_remove_old_screenshots
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _agent(max_n_images: int = 3) -> FaraQwen3Agent:
|
|
"""Construct an agent without initialize() — only the trim helpers are used."""
|
|
cfg = FaraQwen3AgentConfig(
|
|
client_config={"model": "x", "base_url": "http://localhost:0/v1"},
|
|
max_n_images=max_n_images,
|
|
)
|
|
a = FaraQwen3Agent(cfg)
|
|
a._state = FaraQwen3AgentState()
|
|
return a
|
|
|
|
|
|
def _user_with_screenshot(text: str, color: str, **meta) -> LLMMessage:
|
|
return LLMMessage(
|
|
role="user",
|
|
content=[_img(color), text],
|
|
metadata=meta or None,
|
|
)
|
|
|
|
|
|
def _build_long_history() -> list[LLMMessage]:
|
|
"""5 turns of [user(screenshot), assistant(text)] + a system header."""
|
|
history: list[LLMMessage] = [
|
|
LLMMessage(role="system", content="you are an agent"),
|
|
_user_with_screenshot("first task", "red", is_original=True),
|
|
]
|
|
for i in range(4):
|
|
history.append(LLMMessage(role="assistant", content=f"step {i}"))
|
|
history.append(_user_with_screenshot(f"obs {i}", "blue"))
|
|
return history
|
|
|
|
|
|
def _count_images(history: list[LLMMessage]) -> int:
|
|
n = 0
|
|
for m in history:
|
|
if isinstance(m.content, list):
|
|
n += sum(1 for c in m.content if isinstance(c, ImageObj))
|
|
return n
|
|
|
|
|
|
def test_trim_then_round_trip_preserves_image_count():
|
|
agent = _agent(max_n_images=3)
|
|
history = _build_long_history()
|
|
assert _count_images(history) == 5 # before trim
|
|
|
|
trimmed = agent.maybe_remove_old_screenshots(history, includes_current=True)
|
|
assert _count_images(trimmed) == 3 # max_n_images cap
|
|
|
|
encoded = [message_to_dict(m) for m in trimmed]
|
|
decoded = [message_from_dict(d) for d in encoded]
|
|
|
|
assert _count_images(decoded) == 3
|
|
assert len(decoded) == len(trimmed)
|
|
# Roles preserved in order
|
|
assert [m.role for m in decoded] == [m.role for m in trimmed]
|
|
|
|
|
|
def test_trim_preserves_original_user_text_after_trim_and_round_trip():
|
|
"""Old user message marked is_original keeps its text even when image is trimmed."""
|
|
agent = _agent(max_n_images=2)
|
|
history = _build_long_history() # is_original on the first user message
|
|
|
|
trimmed = agent.maybe_remove_old_screenshots(history, includes_current=True)
|
|
decoded = [message_from_dict(message_to_dict(m)) for m in trimmed]
|
|
|
|
# Find the is_original message in the decoded history
|
|
originals = [m for m in decoded if (m.metadata or {}).get("is_original") is True]
|
|
assert originals, "is_original user message must survive trim+round-trip"
|
|
msg = originals[0]
|
|
# Image was stripped (it's old), but text remains
|
|
assert isinstance(msg.content, list)
|
|
assert all(not isinstance(c, ImageObj) for c in msg.content)
|
|
assert "first task" in msg.content
|
|
|
|
|
|
def test_round_trip_then_trim_matches_trim_then_round_trip():
|
|
"""Order independence: trim and serialization commute for our test history."""
|
|
agent = _agent(max_n_images=3)
|
|
history = _build_long_history()
|
|
|
|
a = agent.maybe_remove_old_screenshots(
|
|
[message_from_dict(message_to_dict(m)) for m in history],
|
|
includes_current=True,
|
|
)
|
|
b = [
|
|
message_from_dict(message_to_dict(m))
|
|
for m in agent.maybe_remove_old_screenshots(history, includes_current=True)
|
|
]
|
|
|
|
assert _count_images(a) == _count_images(b) == 3
|
|
assert [m.role for m in a] == [m.role for m in b]
|
|
|
|
|
|
def test_full_state_file_shape_and_size():
|
|
"""Encoded shape is JSON-serializable and structurally well-formed."""
|
|
agent = _agent(max_n_images=3)
|
|
trimmed = agent.maybe_remove_old_screenshots(
|
|
_build_long_history(), includes_current=True
|
|
)
|
|
|
|
payload = {
|
|
"last_url": "https://example.com/page",
|
|
"scroll": [0, 1234],
|
|
"chat_history": [message_to_dict(m) for m in trimmed],
|
|
"facts": ["fact A", "fact B"],
|
|
}
|
|
blob = json.dumps(payload)
|
|
parsed = json.loads(blob)
|
|
|
|
assert parsed["last_url"] == "https://example.com/page"
|
|
assert parsed["scroll"] == [0, 1234]
|
|
assert isinstance(parsed["chat_history"], list)
|
|
assert parsed["facts"] == ["fact A", "fact B"]
|
|
|
|
restored = [message_from_dict(d) for d in parsed["chat_history"]]
|
|
assert _count_images(restored) == 3
|