345 lines
12 KiB
Python
345 lines
12 KiB
Python
from typing import Any
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from src.config import settings
|
|
from src.schemas import (
|
|
DialecticOptions,
|
|
DocumentCreate,
|
|
DocumentMetadata,
|
|
MessageCreate,
|
|
ObservationInput,
|
|
PeerCreate,
|
|
ReasoningConfiguration,
|
|
ResolvedConfiguration,
|
|
SessionCreate,
|
|
WorkspaceCreate,
|
|
)
|
|
|
|
|
|
class TestWorkspaceValidations:
|
|
def test_valid_workspace_create(self):
|
|
workspace = WorkspaceCreate(name="test", metadata={})
|
|
assert workspace.name == "test"
|
|
assert workspace.metadata == {}
|
|
|
|
def test_app_name_too_short(self):
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
WorkspaceCreate(name="", metadata={})
|
|
error_dict = exc_info.value.errors()[0]
|
|
assert error_dict["type"] == "string_too_short"
|
|
|
|
def test_app_name_too_long(self):
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
WorkspaceCreate(name="a" * 513, metadata={})
|
|
error_dict = exc_info.value.errors()[0]
|
|
assert error_dict["type"] == "string_too_long"
|
|
|
|
def test_app_invalid_metadata_type(self):
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
WorkspaceCreate(
|
|
name="test",
|
|
metadata="not a dict", # pyright: ignore
|
|
)
|
|
error_dict = exc_info.value.errors()[0]
|
|
assert error_dict["type"] == "dict_type"
|
|
|
|
|
|
class TestPeerValidations:
|
|
def test_valid_peer_create(self):
|
|
peer = PeerCreate(name="test", metadata={})
|
|
assert peer.name == "test"
|
|
assert peer.metadata == {}
|
|
|
|
def test_valid_peer_create_configuration(self):
|
|
peer = PeerCreate(name="test", metadata={}, configuration={"test": True})
|
|
assert peer.name == "test"
|
|
assert peer.metadata == {}
|
|
assert peer.configuration == {"test": True}
|
|
|
|
def test_peer_name_too_short(self):
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
PeerCreate(name="", metadata={})
|
|
error_dict = exc_info.value.errors()[0]
|
|
assert error_dict["type"] == "string_too_short"
|
|
|
|
def test_peer_name_too_long(self):
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
PeerCreate(name="a" * 513, metadata={})
|
|
error_dict = exc_info.value.errors()[0]
|
|
assert error_dict["type"] == "string_too_long"
|
|
|
|
|
|
class TestSessionValidations:
|
|
def test_valid_session_create(self):
|
|
session = SessionCreate(name="test", metadata={})
|
|
assert session.name == "test"
|
|
assert session.metadata == {}
|
|
|
|
def test_session_name_too_short(self):
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
SessionCreate(name="", metadata={})
|
|
error_dict = exc_info.value.errors()[0]
|
|
assert error_dict["type"] == "string_too_short"
|
|
|
|
|
|
class TestMessageValidations:
|
|
def test_valid_message_create(self):
|
|
msg = MessageCreate(content="test", peer_id="12345", metadata={})
|
|
assert msg.content == "test"
|
|
assert msg.peer_name == "12345"
|
|
assert msg.metadata == {}
|
|
|
|
def test_message_content_too_long(self):
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
MessageCreate(content="a" * 50001, peer_id="12345", metadata={})
|
|
error_dict = exc_info.value.errors()[0]
|
|
assert error_dict["type"] == "string_too_long"
|
|
|
|
|
|
class TestDocumentValidations:
|
|
def test_valid_document_create(self):
|
|
metadata = DocumentMetadata(
|
|
message_ids=[1],
|
|
premises=[],
|
|
message_created_at="2021-01-01T00:00:00Z",
|
|
)
|
|
doc = DocumentCreate(
|
|
content="test content",
|
|
session_name="test",
|
|
level="explicit",
|
|
metadata=metadata,
|
|
embedding=[0.1, 0.2, 0.3],
|
|
)
|
|
assert doc.content == "test content"
|
|
assert doc.level == "explicit"
|
|
assert doc.metadata == metadata
|
|
|
|
def test_document_content_too_short(self):
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
DocumentCreate(
|
|
content="",
|
|
session_name="test",
|
|
level="explicit",
|
|
metadata=DocumentMetadata(
|
|
message_ids=[1],
|
|
premises=[],
|
|
message_created_at="2021-01-01T00:00:00Z",
|
|
),
|
|
embedding=[0.1, 0.2, 0.3],
|
|
)
|
|
error_dict = exc_info.value.errors()[0]
|
|
assert error_dict["type"] == "string_too_short"
|
|
|
|
def test_document_content_too_long(self):
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
DocumentCreate(
|
|
content="a" * 100001,
|
|
session_name="test",
|
|
level="explicit",
|
|
metadata=DocumentMetadata(
|
|
message_ids=[1],
|
|
premises=[],
|
|
message_created_at="2021-01-01T00:00:00Z",
|
|
),
|
|
embedding=[0.1, 0.2, 0.3],
|
|
)
|
|
error_dict = exc_info.value.errors()[0]
|
|
assert error_dict["type"] == "string_too_long"
|
|
|
|
|
|
class TestResolvedConfigurationMigration:
|
|
"""Test backward compatibility for queue items created before v3.0.0.
|
|
|
|
In v3.0.0, the 'deriver' field was renamed to 'reasoning'. Old queue items
|
|
may still have the 'deriver' field and need to be migrated at validation time.
|
|
"""
|
|
|
|
def _make_config(self, **overrides: dict[str, Any]):
|
|
"""Helper to create a valid config dict with overrides."""
|
|
base = {
|
|
"reasoning": {"enabled": True},
|
|
"peer_card": {"use": True, "create": True},
|
|
"summary": {
|
|
"enabled": True,
|
|
"messages_per_short_summary": 20,
|
|
"messages_per_long_summary": 60,
|
|
},
|
|
"dream": {"enabled": False},
|
|
}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
def test_old_queue_item_with_deriver_field(self):
|
|
"""Old queue items with 'deriver' should be migrated to 'reasoning'."""
|
|
old_payload = self._make_config()
|
|
del old_payload["reasoning"]
|
|
old_payload["deriver"] = {"enabled": True}
|
|
|
|
config = ResolvedConfiguration.model_validate(old_payload)
|
|
|
|
assert config.reasoning.enabled is True
|
|
|
|
def test_new_queue_item_with_reasoning_field(self):
|
|
"""New queue items with 'reasoning' should work normally."""
|
|
new_payload = self._make_config(reasoning={"enabled": False})
|
|
|
|
config = ResolvedConfiguration.model_validate(new_payload)
|
|
|
|
assert config.reasoning.enabled is False
|
|
|
|
def test_migration_does_not_override_reasoning(self):
|
|
"""If both 'deriver' and 'reasoning' exist, 'reasoning' takes precedence."""
|
|
payload = self._make_config(reasoning={"enabled": False})
|
|
payload["deriver"] = {"enabled": True}
|
|
|
|
config = ResolvedConfiguration.model_validate(payload)
|
|
|
|
assert config.reasoning.enabled is False
|
|
|
|
def test_missing_reasoning_and_deriver_fails(self):
|
|
"""Payload missing both 'reasoning' and 'deriver' should fail validation."""
|
|
payload = self._make_config()
|
|
del payload["reasoning"]
|
|
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
ResolvedConfiguration.model_validate(payload)
|
|
|
|
assert any(e["loc"] == ("reasoning",) for e in exc_info.value.errors())
|
|
|
|
|
|
class TestReasoningCustomInstructionsValidation:
|
|
def test_nonblank_custom_instructions_rejected_when_cap_is_zero(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(settings.DERIVER, "MAX_CUSTOM_INSTRUCTIONS_TOKENS", 0)
|
|
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
ReasoningConfiguration(custom_instructions="Prefer concrete facts.")
|
|
|
|
errors = exc_info.value.errors()
|
|
assert any(
|
|
error["loc"] == ("custom_instructions",)
|
|
and "custom_instructions are not enabled for this deployment"
|
|
in error["msg"]
|
|
for error in errors
|
|
)
|
|
|
|
def test_reasoning_configuration_rejects_oversized_custom_instructions(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(settings.DERIVER, "MAX_CUSTOM_INSTRUCTIONS_TOKENS", 1)
|
|
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
ReasoningConfiguration(
|
|
custom_instructions="repeat repeat repeat repeat repeat"
|
|
)
|
|
|
|
assert any(
|
|
error["loc"] == ("custom_instructions",)
|
|
for error in exc_info.value.errors()
|
|
)
|
|
|
|
def test_oversized_custom_instructions_are_rejected(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(settings.DERIVER, "MAX_CUSTOM_INSTRUCTIONS_TOKENS", 1)
|
|
|
|
payload = {
|
|
"reasoning": {
|
|
"enabled": True,
|
|
"custom_instructions": "repeat repeat repeat repeat repeat",
|
|
},
|
|
"peer_card": {"use": True, "create": True},
|
|
"summary": {
|
|
"enabled": True,
|
|
"messages_per_short_summary": 20,
|
|
"messages_per_long_summary": 60,
|
|
},
|
|
"dream": {"enabled": False},
|
|
}
|
|
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
ResolvedConfiguration.model_validate(payload)
|
|
|
|
assert any(
|
|
error["loc"] == ("reasoning", "custom_instructions")
|
|
for error in exc_info.value.errors()
|
|
)
|
|
|
|
@pytest.mark.parametrize("custom_instructions", ["", " \n\t "])
|
|
def test_blank_custom_instructions_do_not_require_token_cap(
|
|
self, monkeypatch: pytest.MonkeyPatch, custom_instructions: str
|
|
) -> None:
|
|
monkeypatch.setattr(settings.DERIVER, "MAX_CUSTOM_INSTRUCTIONS_TOKENS", 0)
|
|
|
|
configuration = ReasoningConfiguration(custom_instructions=custom_instructions)
|
|
|
|
assert configuration.custom_instructions == custom_instructions
|
|
|
|
|
|
class TestNulByteSanitization:
|
|
"""Postgres rejects NUL (0x00) in text columns and in jsonb strings.
|
|
|
|
Models emit these as `\\u0000` escapes in tool-call arguments, which the
|
|
JSON parser decodes into real NUL bytes, so model-generated text needs the
|
|
same treatment as user-supplied input.
|
|
"""
|
|
|
|
def test_document_content_strips_nul(self):
|
|
document = DocumentCreate(
|
|
content="the key is at c:\\\x00users\\amal",
|
|
metadata=DocumentMetadata(message_ids=[1], message_created_at="2026-08-28"),
|
|
embedding=[0.1],
|
|
)
|
|
|
|
assert document.content == "the key is at c:\\users\\amal"
|
|
|
|
def test_all_nul_document_content_is_rejected_not_emptied(self):
|
|
"""The validator runs before `min_length`, so content that is nothing
|
|
but NUL fails validation rather than being stored as an empty string."""
|
|
with pytest.raises(ValidationError):
|
|
DocumentCreate(
|
|
content="\x00\x00",
|
|
metadata=DocumentMetadata(
|
|
message_ids=[1], message_created_at="2026-08-28"
|
|
),
|
|
embedding=[0.1],
|
|
)
|
|
|
|
def test_message_content_strips_nul(self):
|
|
message = MessageCreate(peer_id="peer", content="before\x00after")
|
|
|
|
assert message.content == "beforeafter"
|
|
|
|
def test_metadata_strips_nul_at_every_depth(self):
|
|
message = MessageCreate(
|
|
peer_id="peer",
|
|
content="hi",
|
|
metadata={"a\x00b": {"c": ["d\x00e", 1]}},
|
|
)
|
|
|
|
assert message.metadata == {"ab": {"c": ["de", 1]}}
|
|
|
|
def test_observation_content_strips_nul(self):
|
|
observation = ObservationInput(content="before\x00after")
|
|
|
|
assert observation.content == "beforeafter"
|
|
|
|
def test_all_nul_observation_content_is_rejected_not_emptied(self):
|
|
"""Sanitization runs before `min_length`, so an all-NUL observation is
|
|
reported back to the model as a validation failure rather than saved
|
|
as an empty document."""
|
|
with pytest.raises(ValidationError):
|
|
ObservationInput(content="\x00\x00")
|
|
|
|
def test_all_nul_query_is_rejected_not_emptied(self):
|
|
"""`NulStripped` runs before the field's own constraints, so a query
|
|
that is nothing but NUL fails `min_length` instead of reaching the
|
|
dialectic as an empty prompt."""
|
|
options = DialecticOptions.model_validate({"query": "before\x00after"})
|
|
assert options.query == "beforeafter"
|
|
with pytest.raises(ValidationError):
|
|
DialecticOptions.model_validate({"query": "\x00"})
|