from dataclasses import asdict import json import pytest from agent.orchestration.models import ( AcceptanceCriterion, TaskSpec, json_values, ) from agent.orchestration.wire import TaskSpecView def criterion(criterion_id="criterion-1", description="The result is correct", hard=True): return AcceptanceCriterion(criterion_id, description, hard) def test_acceptance_criterion_normalizes_text_and_requires_boolean_hard(): value = AcceptanceCriterion(" criterion-1 ", " The result is correct ") assert value.criterion_id == "criterion-1" assert value.description == "The result is correct" assert value.hard is True for criterion_id in ("", " ", None, 1): with pytest.raises(ValueError, match="criterion_id"): AcceptanceCriterion(criterion_id, "description") for description in ("", " ", None, 1): with pytest.raises(ValueError, match="description"): AcceptanceCriterion("criterion-1", description) for hard in (0, 1, "true", None): with pytest.raises(ValueError, match="hard"): AcceptanceCriterion("criterion-1", "description", hard) def test_acceptance_criterion_from_dict_generates_only_missing_id(): generated = AcceptanceCriterion.from_dict({"description": "description"}) assert generated.criterion_id assert AcceptanceCriterion.from_dict( {"id": " legacy-id ", "description": "description"} ).criterion_id == "legacy-id" for explicit_id in ("", " ", None): with pytest.raises(ValueError, match="criterion_id"): AcceptanceCriterion.from_dict( {"criterion_id": explicit_id, "description": "description"} ) @pytest.mark.parametrize("version", [True, False, 0, -1, 1.0, "1", None]) def test_task_spec_requires_a_positive_integer_version(version): with pytest.raises(ValueError, match="positive integer"): TaskSpec(version, "objective", [criterion()]) @pytest.mark.parametrize("objective", ["", " ", None, 1]) def test_task_spec_requires_a_non_empty_string_objective(objective): with pytest.raises(ValueError, match="objective"): TaskSpec(1, objective, [criterion()]) def test_task_spec_requires_criteria_with_unique_normalized_ids(): with pytest.raises(ValueError, match="at least one"): TaskSpec(1, "objective") with pytest.raises(ValueError, match="unique"): TaskSpec( 1, "objective", [criterion(" duplicate "), criterion("duplicate", "another check")], ) value = TaskSpec( 1, "objective", [criterion("case"), criterion("CASE", "another check")], ) assert [item.criterion_id for item in value.acceptance_criteria] == ["case", "CASE"] def test_task_spec_freezes_collections_and_does_not_track_source_mutation(): criteria = [criterion()] context_refs = ["context-1"] value = TaskSpec(1, " objective ", criteria, context_refs) criteria.append(criterion("criterion-2", "another check")) context_refs.append("context-2") assert value.objective == "objective" assert isinstance(value.acceptance_criteria, tuple) assert isinstance(value.context_refs, tuple) assert len(value.acceptance_criteria) == 1 assert value.context_refs == ("context-1",) @pytest.mark.parametrize( "context_refs", ["context", {"context": True}, None, [""], [" "], [1], [[]]], ) def test_task_spec_requires_immutable_string_context_refs(context_refs): with pytest.raises(ValueError, match="context_refs|collections"): TaskSpec(1, "objective", [criterion()], context_refs) with pytest.raises(ValueError, match="context_refs|collections"): TaskSpec.from_dict({ "objective": "objective", "acceptance_criteria": [{"description": "description"}], "context_refs": context_refs, }) value = TaskSpec(1, "objective", [criterion()], [" context-1 "]) assert value.context_refs == ("context-1",) def test_task_spec_from_dict_is_strict_and_generates_stable_id_for_instance(): value = TaskSpec.from_dict( { "objective": "objective", "acceptance_criteria": [{"description": "description"}], } ) assert value.version == 1 assert value.acceptance_criteria[0].criterion_id restored = TaskSpec.from_dict(json.loads(json.dumps(json_values(asdict(value))))) assert restored.acceptance_criteria[0].criterion_id == value.acceptance_criteria[0].criterion_id with pytest.raises(ValueError, match="positive integer"): TaskSpec.from_dict( { "version": "1", "objective": "objective", "acceptance_criteria": [{"description": "description"}], } ) @pytest.mark.parametrize("criteria", [None, "criterion", {"description": "description"}]) def test_task_spec_from_dict_rejects_invalid_criteria_collections(criteria): with pytest.raises(ValueError, match="acceptance_criteria"): TaskSpec.from_dict({ "objective": "objective", "acceptance_criteria": criteria, }) def test_task_spec_wire_shape_keeps_json_arrays(): value = TaskSpec( 1, "objective", [criterion()], ["context-1"], created_at="2026-01-01T00:00:00+00:00", ) payload = json_values(asdict(value)) wire = TaskSpecView.model_validate(payload).model_dump(mode="json") assert wire["acceptance_criteria"] == [ { "criterion_id": "criterion-1", "description": "The result is correct", "hard": True, } ] assert wire["context_refs"] == ["context-1"] assert isinstance(json.loads(json.dumps(payload))["acceptance_criteria"], list)