198 lines
7.1 KiB
Python
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]
|
|
)
|