test_production_composition.py 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
  1. from __future__ import annotations
  2. import json
  3. from pathlib import Path
  4. import pytest
  5. from agent import AgentRunner, FileSystemTraceStore
  6. from script_build_host.domain.records import Principal
  7. from script_build_host.infrastructure.config import ScriptBuildSettings
  8. from script_build_host.production import (
  9. ProductionRuntimeDependencies,
  10. compose_production_host,
  11. )
  12. class _Principals:
  13. async def current(self, request_context: object | None = None) -> Principal:
  14. if request_context is None:
  15. raise PermissionError("request context is required")
  16. return Principal("owner")
  17. class _Authorizer:
  18. async def require_source_access(self, _principal: Principal, **_source: int) -> None:
  19. return None
  20. async def require_access(self, _principal: Principal, _script_build_id: int) -> None:
  21. return None
  22. def _runtime(tmp_path: Path) -> ProductionRuntimeDependencies:
  23. async def llm_call(*_args: object, **_kwargs: object) -> dict[str, str]:
  24. return {"content": "unused", "finish_reason": "stop"}
  25. async def resolve_public(_host: str, _port: int) -> tuple[str, ...]:
  26. return ("8.8.8.8",)
  27. return ProductionRuntimeDependencies(
  28. runner=AgentRunner(
  29. trace_store=FileSystemTraceStore(str(tmp_path / "traces")),
  30. llm_call=llm_call,
  31. ),
  32. principal_provider=_Principals(),
  33. build_authorizer=_Authorizer(),
  34. outbound_resolver=resolve_public,
  35. )
  36. def _settings(tmp_path: Path, **overrides: object) -> ScriptBuildSettings:
  37. decode = tmp_path / "decode.json"
  38. knowledge = tmp_path / "knowledge.json"
  39. decode.write_text(json.dumps({"items": []}), encoding="utf-8")
  40. knowledge.write_text(json.dumps({"items": []}), encoding="utf-8")
  41. values: dict[str, object] = {
  42. "environment": "test",
  43. "read_database_url": f"sqlite+aiosqlite:///{tmp_path / 'read.db'}",
  44. "read_database_socks_proxy": None,
  45. "write_database_url": f"sqlite+aiosqlite:///{tmp_path / 'write.db'}",
  46. "agent_data_root": tmp_path / "agent-data",
  47. "persona_root": tmp_path / "persona",
  48. "section_pattern_root": tmp_path / "sections",
  49. "decode_index_root": decode,
  50. "knowledge_root": knowledge,
  51. "prompt_fallback_root": tmp_path / "prompts",
  52. "websocket_allowed_origins": ("https://ui.example",),
  53. }
  54. values.update(overrides)
  55. return ScriptBuildSettings(**values) # type: ignore[arg-type]
  56. @pytest.mark.asyncio
  57. async def test_production_composition_wires_real_stores_and_closes_resources(
  58. tmp_path: Path,
  59. ) -> None:
  60. host = compose_production_host(_settings(tmp_path, phase_two_max_tasks=7), _runtime(tmp_path))
  61. await host.validate_startup()
  62. manifest = await host.runtime_manifest.load()
  63. assert manifest["decode_index"]["file_count"] == 1
  64. assert manifest["knowledge"]["file_count"] == 1
  65. paths = set(host.composition.app.openapi()["paths"])
  66. assert "/api/pattern/script_builds" in paths
  67. assert any(path.startswith("/api/v2/script-builds/") for path in paths)
  68. assert (
  69. host.composition.coordinator.task_store.base_path
  70. == (tmp_path / "agent-data" / "task-ledger").resolve()
  71. )
  72. assert host.composition.phase_two_planning.limits.max_tasks == 7
  73. await host.close()
  74. await host.close()
  75. assert host.http_client.is_closed
  76. def test_production_requires_decode_service_and_durable_trace_store(tmp_path: Path) -> None:
  77. production = _settings(
  78. tmp_path,
  79. environment="production",
  80. read_database_url="mysql+asyncmy://reader:read@db.example/app",
  81. write_database_url="mysql+asyncmy://writer:write@db.example/app",
  82. final_database_url="mysql+asyncmy://publisher:publish@db.example/app",
  83. )
  84. with pytest.raises(RuntimeError, match="DECODE_ENDPOINT"):
  85. compose_production_host(production, _runtime(tmp_path))
  86. runtime = _runtime(tmp_path)
  87. runtime.runner.trace_store = None
  88. with pytest.raises(RuntimeError, match="durable trace store"):
  89. compose_production_host(_settings(tmp_path), runtime)
  90. external = _runtime(tmp_path)
  91. external.runner.trace_store = object()
  92. with pytest.raises(RuntimeError, match="must provide a DurableTraceStoreVerifier"):
  93. compose_production_host(_settings(tmp_path), external)
  94. @pytest.mark.asyncio
  95. async def test_production_expands_one_internal_model_to_all_script_roles(tmp_path: Path) -> None:
  96. host = compose_production_host(
  97. _settings(tmp_path, role_model="qwen3.7-max", role_max_iterations=90),
  98. _runtime(tmp_path),
  99. )
  100. manifest = host.composition.mission_service.phase_two_model_manifest
  101. presets = manifest["presets"]
  102. assert isinstance(presets, dict)
  103. assert len(presets) == 16
  104. assert presets["script_root_worker"]["model"] == "qwen3.7-max"
  105. assert presets["script_root_validator"]["max_iterations"] == 25
  106. await host.close()