런타임 계약과 학습자 흐름 보강

This commit is contained in:
Yun Chan 2026-06-29 08:12:14 +09:00
parent f456b8997a
commit 206018b088
56 changed files with 4306 additions and 1008 deletions

View file

@ -135,6 +135,7 @@ def _contract_schema_doc():
},
"$defs": defs,
"x-engine-gateway-sse-events": list(contract.ENGINE_GATEWAY_SSE_EVENTS),
"x-engine-gateway-default-model-sentinel": contract.ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL,
}
@ -171,6 +172,16 @@ class _FakeStreamSession:
class GatewayModelTest(unittest.TestCase):
def test_contract_owns_gateway_default_model_sentinel(self):
self.assertEqual(contract.ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, "gateway-default")
self.assertIsNone(contract.normalize_engine_gateway_model(None))
self.assertIsNone(contract.normalize_engine_gateway_model(""))
self.assertIsNone(contract.normalize_engine_gateway_model(" gateway-default "))
self.assertEqual(
contract.normalize_engine_gateway_model(" request-model "),
"request-model",
)
def setUp(self):
gateway.SESSIONS.clear()
@ -183,6 +194,30 @@ class GatewayModelTest(unittest.TestCase):
self.assertIs(engine_client.GenerateRequest, contract.GenerateRequest)
self.assertEqual(contract.ENGINE_GATEWAY_SSE_EVENTS, ("token", "done", "error"))
def test_split_messages_returns_named_current_turn_prompt_parts(self):
parts = gateway._split_messages(
[
contract.EngineMessage(role="system", content="system one"),
contract.EngineMessage(role="system", content=""),
contract.EngineMessage(role="system", content="system two"),
contract.EngineMessage(role="assistant", content="previous counselor"),
contract.EngineMessage(role="user", content="previous client"),
contract.EngineMessage(role="user", content="current client"),
]
)
self.assertIsInstance(parts, gateway.GatewayPromptParts)
self.assertEqual(parts.system_prompt, "system one\n\nsystem two")
self.assertEqual(parts.user_payload, "current client")
def test_split_messages_preserves_no_user_payload_boundary(self):
parts = gateway._split_messages(
[contract.EngineMessage(role="system", content="system only")]
)
self.assertEqual(parts.system_prompt, "system only")
self.assertEqual(parts.user_payload, "")
def test_sse_frame_helper_preserves_gateway_wire_contract(self):
self.assertEqual(
contract.sse_frame("token", contract.StreamTokenEvent(text="hello")),
@ -286,6 +321,46 @@ class GatewayModelTest(unittest.TestCase):
for line in golden["compatibility_lines"]:
self.assertIsNone(compatibility_decoder.feed_line(line))
def test_generate_response_structured_payload_prefers_structured_field(self):
response = contract.GenerateResponse(
text='{"reply":"text"}',
provider="test",
model="test-model",
structured={"reply": "structured"},
)
self.assertEqual(
contract.structured_payload_from_response(response),
{"reply": "structured"},
)
def test_generate_response_structured_payload_accepts_fenced_and_embedded_json(self):
fenced = contract.GenerateResponse(
text='```json\n{"reply":"fenced"}\n```',
provider="test",
model="test-model",
)
embedded = contract.GenerateResponse(
text='prefix {"reply":"embedded"} suffix',
provider="test",
model="test-model",
)
self.assertEqual(contract.structured_payload_from_response(fenced), {"reply": "fenced"})
self.assertEqual(
contract.structured_payload_from_response(embedded),
{"reply": "embedded"},
)
def test_generate_response_structured_payload_rejects_non_object_json(self):
response = contract.GenerateResponse(
text='["not", "object"]',
provider="test",
model="test-model",
)
self.assertIsNone(contract.structured_payload_from_response(response))
def test_engine_gateway_json_schema_artifact_validates_golden_fixture(self):
schema = _load_golden_schema()
golden = _load_golden_contract()
@ -321,6 +396,10 @@ class GatewayModelTest(unittest.TestCase):
payload = json.loads(completed.stdout)
self.assertTrue(payload["ok"])
self.assertEqual(
payload["defaultModelSentinel"],
contract.ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL,
)
self.assertEqual(payload["streamEvents"], ["token", "done", "error"])
self.assertEqual(payload["decodedPacketCount"], 4)
@ -504,6 +583,21 @@ class GatewayModelTest(unittest.TestCase):
self.assertEqual(closed, [started[0]])
self.assertNotIn(started[0].id, gateway.SESSIONS)
def test_v1_generate_rejects_missing_user_before_session_resolution(self):
req = contract.GenerateRequest(
messages=[contract.EngineMessage(role="system", content="system only")]
)
with (
patch.object(gateway, "_resolve_session") as resolve_session,
self.assertRaises(gateway.HTTPException) as raised,
):
asyncio.run(gateway.v1_generate(req))
self.assertEqual(raised.exception.status_code, 400)
self.assertEqual(raised.exception.detail, "no user message in payload")
resolve_session.assert_not_called()
def test_v1_stream_frames_token_and_done_events(self):
session = _FakeStreamSession(
[
@ -530,6 +624,21 @@ class GatewayModelTest(unittest.TestCase):
self.assertEqual(session.timeout, 600.0)
self.assertTrue(session.closed)
def test_v1_stream_rejects_missing_user_before_session_resolution(self):
req = contract.GenerateRequest(
messages=[contract.EngineMessage(role="system", content="system only")]
)
with (
patch.object(gateway, "_resolve_session") as resolve_session,
self.assertRaises(gateway.HTTPException) as raised,
):
asyncio.run(gateway.v1_stream(req))
self.assertEqual(raised.exception.status_code, 400)
self.assertEqual(raised.exception.detail, "no user message in payload")
resolve_session.assert_not_called()
def test_v1_stream_frames_engine_error_event(self):
session = _FakeStreamSession(
[