feat: 운영 안정성과 세션 음성 경험 개선

This commit is contained in:
Yun Chan 2026-07-31 00:13:08 +09:00
parent facc4ad2d9
commit c788343467
95 changed files with 8431 additions and 1785 deletions

View file

@ -4,7 +4,8 @@ import shutil
import subprocess
import unittest
from pathlib import Path
from unittest.mock import patch
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from jsonschema import Draft202012Validator
@ -36,6 +37,28 @@ class _FakeProcess:
self.returncode = -9
class _StreamStdin(_FakeStdin):
def __init__(self):
self.writes = []
def write(self, value):
self.writes.append(value)
async def drain(self):
return None
class _StreamStdout:
def __init__(self, objects):
self.lines = [
(json.dumps(obj, ensure_ascii=False) + "\n").encode("utf-8")
for obj in objects
]
async def readline(self):
return self.lines.pop(0) if self.lines else b""
def _capture_subprocess():
captured = []
@ -159,6 +182,7 @@ class _FakeStreamSession:
def __init__(self, events, model="test-model"):
self.events = events
self.model = model
self.turns = 0
self.closed = False
async def turn_stream(self, content, timeout=600.0):
@ -194,6 +218,42 @@ class GatewayModelTest(unittest.TestCase):
self.assertIs(engine_client.GenerateRequest, contract.GenerateRequest)
self.assertEqual(contract.ENGINE_GATEWAY_SSE_EVENTS, ("token", "done", "error"))
def test_engine_client_payload_includes_provider_model_and_reasoning_defaults(self):
client = engine_client.EngineClient("http://127.0.0.1:9099")
client.engine_mode = "codex_cli"
client.live_client_provider = None
client.default_model = "gpt-5.6-terra"
client.default_reasoning_effort = "medium"
payload = client._payload(
contract.GenerateRequest(
messages=[contract.EngineMessage(role="user", content="hello")]
)
)
self.assertEqual(payload["provider"], "codex_cli")
self.assertEqual(payload["model"], "gpt-5.6-terra")
self.assertEqual(payload["reasoning_effort"], "medium")
def test_engine_client_uses_dedicated_live_provider_without_foreign_model_defaults(self):
client = engine_client.EngineClient("http://127.0.0.1:9099")
client.engine_mode = "agy_cli"
client.default_model = "gemini-3.6-flash-high"
client.default_reasoning_effort = "high"
client.live_client_provider = "claude_cli"
payload = client._payload(
contract.GenerateRequest(
ai_role="client",
session_id="session-id",
messages=[contract.EngineMessage(role="user", content="hello")],
)
)
self.assertEqual(payload["provider"], "claude_cli")
self.assertNotIn("model", payload)
self.assertNotIn("reasoning_effort", payload)
def test_split_messages_returns_named_current_turn_prompt_parts(self):
parts = gateway._split_messages(
[
@ -209,11 +269,13 @@ class GatewayModelTest(unittest.TestCase):
self.assertIsInstance(parts, gateway.GatewayPromptParts)
self.assertEqual(parts.system_prompt, "system one\n\nsystem two")
self.assertEqual(parts.user_payload, "current client")
self.assertEqual(parts.current_user_payload, "current client")
def test_split_messages_injects_client_history_before_current_counselor_turn(self):
parts = gateway._split_messages(
[
contract.EngineMessage(role="system", content="client persona system"),
contract.EngineMessage(role="system", content="client persona system", cache=True),
contract.EngineMessage(role="system", content="dynamic state", cache=False),
contract.EngineMessage(role="user", content="상담자 이전 질문"),
contract.EngineMessage(role="assistant", content="내담자 이전 답변"),
contract.EngineMessage(role="user", content="이번 상담자 발화"),
@ -222,11 +284,15 @@ class GatewayModelTest(unittest.TestCase):
)
self.assertEqual(parts.system_prompt, "client persona system")
self.assertIn("[현재 턴 상태와 연기 지시]", parts.user_payload)
self.assertIn("dynamic state", parts.user_payload)
self.assertIn("[직전 대화]", parts.user_payload)
self.assertIn("상담자: 상담자 이전 질문", parts.user_payload)
self.assertIn("내담자: 내담자 이전 답변", parts.user_payload)
self.assertIn("[이번 상담자 발화]", parts.user_payload)
self.assertTrue(parts.user_payload.rstrip().endswith("이번 상담자 발화"))
self.assertNotIn("[직전 대화]", parts.current_user_payload)
self.assertIn("dynamic state", parts.current_user_payload)
def test_split_messages_does_not_inject_history_for_evaluator_requests(self):
parts = gateway._split_messages(
@ -250,6 +316,11 @@ class GatewayModelTest(unittest.TestCase):
self.assertEqual(parts.system_prompt, "system only")
self.assertEqual(parts.user_payload, "")
self.assertEqual(parts.current_user_payload, "")
def test_claude_process_enables_real_partial_streaming_without_disk_session_copy(self):
self.assertIn("--include-partial-messages", gateway.BASE_ARGS)
self.assertIn("--no-session-persistence", gateway.BASE_ARGS)
def test_sse_frame_helper_preserves_gateway_wire_contract(self):
self.assertEqual(
@ -496,6 +567,135 @@ class GatewayModelTest(unittest.TestCase):
finally:
asyncio.run(session.close())
def test_engine_session_passes_reasoning_effort_to_claude_cli(self):
captured, process_patch = _capture_subprocess()
with (
patch.object(gateway, "DEFAULT_MODEL", ""),
patch.object(gateway, "FALLBACK_MODEL", ""),
process_patch,
):
session = gateway.EngineSession(
model="opus",
reasoning_effort="high",
)
asyncio.run(session.start())
try:
self.assertIn("--effort", captured[0])
self.assertEqual(
captured[0][captured[0].index("--effort") + 1], "high"
)
finally:
asyncio.run(session.close())
def test_engine_session_emits_partial_stream_events_without_final_message_duplication(self):
process = _FakeProcess()
process.stdin = _StreamStdin()
process.stdout = _StreamStdout(
[
{
"type": "stream_event",
"event": {
"type": "content_block_delta",
"delta": {"type": "text_delta", "text": ""},
},
},
{
"type": "stream_event",
"event": {
"type": "content_block_delta",
"delta": {"type": "text_delta", "text": "녕!"},
},
},
{
"type": "assistant",
"message": {"content": [{"type": "text", "text": "안녕!"}]},
},
{"type": "result", "is_error": False, "total_cost_usd": 0.01},
]
)
session = gateway.EngineSession()
session.proc = process
async def collect():
return [event async for event in session.turn_stream("질문")]
events = asyncio.run(collect())
self.assertEqual(
events,
[
{"type": "delta", "text": ""},
{"type": "delta", "text": "녕!"},
{
"type": "done",
"text": "안녕!",
"cost_usd": 0.01,
"turns": 1,
"is_error": False,
"error": "안녕!",
},
],
)
def test_v1_generate_routes_non_claude_provider_through_registry(self):
result = SimpleNamespace(
text="registry response",
model="gpt-5.6-terra",
provider="codex_cli",
tokens_in=12,
tokens_out=3,
cost_usd=0.0,
inference_geo=None,
structured=None,
)
request = contract.GenerateRequest(
provider="codex_cli",
model="gpt-5.6-terra",
reasoning_effort="medium",
messages=[contract.EngineMessage(role="user", content="hello")],
)
with patch.object(
gateway,
"generate_with_provider",
AsyncMock(return_value=result),
) as generate:
response = asyncio.run(gateway.v1_generate(request))
self.assertEqual(response["provider"], "codex_cli")
self.assertEqual(response["model"], "gpt-5.6-terra")
generate.assert_awaited_once()
def test_v1_stream_forwards_non_claude_provider_deltas(self):
result = SimpleNamespace(
text="안녕",
model="gemini-3.6-flash-high",
provider="agy_cli",
tokens_in=12,
tokens_out=2,
cost_usd=0.0,
)
request = contract.GenerateRequest(
provider="agy_cli",
model="gemini-3.6-flash-high",
reasoning_effort="high",
messages=[contract.EngineMessage(role="user", content="hello")],
)
async def fake_stream(*args, **kwargs):
yield SimpleNamespace(type="delta", text="", result=None)
yield SimpleNamespace(type="delta", text="", result=None)
yield SimpleNamespace(type="done", text="", result=result)
with patch.object(gateway, "stream_with_provider", fake_stream):
response = asyncio.run(gateway.v1_stream(request))
body = asyncio.run(_read_streaming_response(response))
self.assertEqual(body.count("event: token"), 2)
self.assertIn('data: {"text": ""}', body)
self.assertIn('data: {"text": ""}', body)
self.assertIn("event: done", body)
self.assertIn('"provider": "agy_cli"', body)
def test_resolve_session_does_not_reuse_session_with_different_model(self):
captured, process_patch = _capture_subprocess()
existing = gateway.EngineSession(model="old-model")
@ -512,11 +712,13 @@ class GatewayModelTest(unittest.TestCase):
)
try:
self.assertIs(ephemeral, True)
self.assertIs(ephemeral, False)
self.assertIsNot(session, existing)
self.assertEqual(_model_arg(captured[0]), "new-model")
self.assertIs(gateway.SESSIONS["sid"], existing)
self.assertIs(gateway.SESSIONS["sid"], session)
self.assertEqual(existing.proc.returncode, 0)
finally:
gateway.SESSIONS.pop("sid", None)
asyncio.run(session.close())
def test_resolve_session_reuses_live_session_id_without_starting_claude(self):
@ -538,7 +740,7 @@ class GatewayModelTest(unittest.TestCase):
self.assertIs(ephemeral, False)
self.assertEqual(captured, [])
def test_resolve_session_creates_fresh_ephemeral_for_missing_session_id(self):
def test_resolve_session_binds_missing_client_session_id_to_resident_pool(self):
captured, process_patch = _capture_subprocess()
with (
@ -551,11 +753,12 @@ class GatewayModelTest(unittest.TestCase):
)
try:
self.assertIs(ephemeral, True)
self.assertNotIn(session.id, gateway.SESSIONS)
self.assertIs(ephemeral, False)
self.assertIs(gateway.SESSIONS["missing"], session)
self.assertEqual(len(captured), 1)
self.assertIn("--system-prompt", captured[0])
finally:
gateway.SESSIONS.pop("missing", None)
asyncio.run(session.close())
def test_v1_generate_reuses_session_id_without_ephemeral_close(self):
@ -581,7 +784,10 @@ class GatewayModelTest(unittest.TestCase):
self.assertEqual(validated.text, "reused response")
self.assertEqual(validated.provider, "claude_cli")
self.assertEqual(validated.cost_usd, 0.01)
self.assertEqual(calls, [("hello", gateway.GENERATE_TURN_TIMEOUT_SECONDS)])
self.assertEqual(
calls,
[("[이번 상담자 발화]\nhello", gateway.GENERATE_TURN_TIMEOUT_SECONDS)],
)
self.assertEqual(closes, [])
def test_v1_generate_closes_fresh_ephemeral_session(self):
@ -605,14 +811,23 @@ class GatewayModelTest(unittest.TestCase):
patch.object(gateway.EngineSession, "turn", fake_turn),
patch.object(gateway.EngineSession, "close", fake_close),
):
response = asyncio.run(gateway.v1_generate(_request(session_id="missing")))
response = asyncio.run(gateway.v1_generate(_request()))
validated = contract.GenerateResponse.model_validate(response)
self.assertEqual(validated.text, "fresh response")
self.assertEqual(validated.provider, "claude_cli")
self.assertEqual(validated.cost_usd, 0.02)
self.assertEqual(len(started), 1)
self.assertEqual(turned, [(started[0], "hello", gateway.GENERATE_TURN_TIMEOUT_SECONDS)])
self.assertEqual(
turned,
[
(
started[0],
"[이번 상담자 발화]\nhello",
gateway.GENERATE_TURN_TIMEOUT_SECONDS,
)
],
)
self.assertEqual(closed, [started[0]])
self.assertNotIn(started[0].id, gateway.SESSIONS)
@ -653,7 +868,7 @@ class GatewayModelTest(unittest.TestCase):
self.assertIn('"provider": "claude_cli"', body)
self.assertIn('"model": "stream-model"', body)
self.assertIn('"cost_usd": 0.03', body)
self.assertEqual(session.content, "hello")
self.assertEqual(session.content, "[이번 상담자 발화]\nhello")
self.assertEqual(session.timeout, 600.0)
self.assertTrue(session.closed)