Files
2026-07-13 12:39:17 +08:00

198 lines
7.1 KiB
Python

"""Tests for JSON round-trip safety of SandboxSessionState.
Verifies that SandboxSessionState can survive serialization to JSON and
deserialization back without losing subclass identity, subclass-specific
fields, or the ``type`` discriminator under ``exclude_unset``.
"""
from __future__ import annotations
import json
import uuid
from pathlib import Path
from typing import ClassVar, Literal
import pytest
from pydantic import ValidationError
from agents.sandbox import Manifest
from agents.sandbox.session import SandboxSessionState
from agents.sandbox.snapshot import LocalSnapshot
# ---------------------------------------------------------------------------
# Test-only stubs
# ---------------------------------------------------------------------------
class _StubSessionState(SandboxSessionState):
__test__ = False
type: Literal["stub-roundtrip"] = "stub-roundtrip"
custom_field: str
class _PlainTypeSessionState(SandboxSessionState):
__test__ = False
type: str = "plain-type"
class _EmptyDefaultSessionState(SandboxSessionState):
__test__ = False
type: Literal[""] = ""
class _SimpleSessionState(SandboxSessionState):
__test__ = False
type: Literal["simple-roundtrip"] = "simple-roundtrip"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_session_state() -> _StubSessionState:
return _StubSessionState(
session_id=uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
snapshot=LocalSnapshot(id="snap-1", base_path=Path("/tmp/snapshots")),
manifest=Manifest(),
custom_field="my-value",
)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestSandboxSessionStateRoundTrip:
def test_parse_reconstructs_subclass_from_json(self) -> None:
"""SandboxSessionState.parse() must reconstruct the correct subclass from a dict."""
original = _make_session_state()
payload = json.loads(original.model_dump_json())
reconstructed = SandboxSessionState.parse(payload)
assert type(reconstructed) is _StubSessionState
assert reconstructed.custom_field == "my-value"
def test_model_validate_json_loses_subclass(self) -> None:
"""Pydantic's model_validate_json against the base class loses subclass identity.
This documents the limitation that parse() exists to solve.
"""
original = _make_session_state()
json_str = original.model_dump_json()
base_instance = SandboxSessionState.model_validate_json(json_str)
assert type(base_instance) is SandboxSessionState
assert not hasattr(base_instance, "custom_field")
def test_type_survives_exclude_unset(self) -> None:
"""The ``type`` discriminator must survive model_dump(exclude_unset=True).
Since ``type`` is set via a class-level default it is not in
model_fields_set. Without the model_serializer, exclude_unset=True
drops it, making SandboxSessionState.parse() fail.
"""
state = _make_session_state()
dumped = state.model_dump(exclude_unset=True)
assert "type" in dumped
assert dumped["type"] == "stub-roundtrip"
def test_model_dump_preserves_snapshot_subclass_fields(self) -> None:
"""model_dump() must preserve snapshot subclass fields (e.g. LocalSnapshot.base_path).
Without SerializeAsAny, Pydantic serializes using the declared field
type (SnapshotBase), silently dropping subclass-specific fields.
"""
state = _make_session_state()
dumped = state.model_dump()
assert "base_path" in dumped["snapshot"]
def test_parse_returns_subclass_instances_as_is(self) -> None:
state = _make_session_state()
assert SandboxSessionState.parse(state) is state
def test_parse_upgrades_base_instance_through_registry(self) -> None:
state = _SimpleSessionState(
session_id=uuid.UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
snapshot=LocalSnapshot(id="snap-1", base_path=Path("/tmp/snapshots")),
manifest=Manifest(),
)
base_instance = SandboxSessionState.model_validate(state.model_dump())
reconstructed = SandboxSessionState.parse(base_instance)
assert type(reconstructed) is _SimpleSessionState
assert reconstructed.session_id == uuid.UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
@pytest.mark.parametrize(
("payload", "error_type", "message"),
[
({}, ValueError, "must include a string `type`"),
({"type": "missing"}, ValueError, "unknown sandbox session state type `missing`"),
("not-a-state", TypeError, "session state payload must be"),
],
)
def test_parse_rejects_invalid_payloads(
self,
payload: object,
error_type: type[Exception],
message: str,
) -> None:
with pytest.raises(error_type, match=message):
SandboxSessionState.parse(payload)
def test_subclass_registration_skips_non_literal_or_empty_type_defaults(self) -> None:
assert "plain-type" not in SandboxSessionState._subclass_registry
assert "" not in SandboxSessionState._subclass_registry
def test_subclass_registration_skips_missing_type_field(self) -> None:
class _NoTypeFieldSessionState(SandboxSessionState):
type: ClassVar[str] = "no-type-field" # type: ignore[misc]
assert "no-type-field" not in SandboxSessionState._subclass_registry
assert "type" not in _NoTypeFieldSessionState.model_fields
@pytest.mark.parametrize(
("raw_ports", "expected"),
[
(None, ()),
(8080, (8080,)),
([8080, 9000, 8080], (8080, 9000)),
],
)
def test_exposed_ports_are_normalized(
self, raw_ports: object, expected: tuple[int, ...]
) -> None:
state = _StubSessionState(
snapshot=LocalSnapshot(id="snap-1", base_path=Path("/tmp/snapshots")),
manifest=Manifest(),
custom_field="my-value",
exposed_ports=raw_ports, # type: ignore[arg-type]
)
assert state.exposed_ports == expected
@pytest.mark.parametrize(
("raw_ports", "message"),
[
("8080", "exposed_ports must be an iterable"),
([8080, "9000"], "exposed_ports must contain integers"),
([0], "exposed_ports entries must be between 1 and 65535"),
([65536], "exposed_ports entries must be between 1 and 65535"),
],
)
def test_exposed_ports_reject_invalid_values(self, raw_ports: object, message: str) -> None:
with pytest.raises((TypeError, ValidationError), match=message):
_StubSessionState(
snapshot=LocalSnapshot(id="snap-1", base_path=Path("/tmp/snapshots")),
manifest=Manifest(),
custom_field="my-value",
exposed_ports=raw_ports, # type: ignore[arg-type]
)