feat: 운영 안정성과 세션 음성 경험 개선
This commit is contained in:
parent
facc4ad2d9
commit
c788343467
95 changed files with 8431 additions and 1785 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue