Files
ZNJJ-api-server/test/services/test_stream_and_session_safety.py
T
Eric Wang 24e2cfbe10 fix(backends): make streamed replies and session state consistent
- Validate stage prefixes before forwarding model tokens. Invalid prefixes
  may use the existing repair budget; once output starts, validation failure
  aborts the turn without appending a regenerated reply. Bound the token
  queue and close inner streams when consumption stops.
- Replace delete-time lock eviction with reference-counted session locks
  covering holders and waiters. Serialize set/get with chat/stream/delete
  and release cancelled waiters without replacing a live lock.
- Store person synonyms under workflow-native driver keys and expose
  consistent alias views to single/group reads and graph prompt injection.
- Separate committed field patches from public formUpdate visibility.
  Commit callbacks keep repository fields synchronized even when updates
  are hidden or the consumer closes after graph completion.
- Disable OpenAI SDK automatic retries. Own bounded retry attempts in the
  application, use 1/2/4 second backoff, retry streams only before text is
  emitted, enforce attempt timeouts, and close upstream/client resources.
- Translate FastGPT SDK max_retries (total attempts) from the configured
  extra-retry count, and verify the real SDK loop uses four attempts.
- Document the per-LLM budget and streaming failure semantics without
  claiming the whole multi-call HTTP turn has the same timeout.

Validation: 267 offline tests passed, including actual SDK requests through
MockTransport, stream repair/abort, timeout/close, lock races, aliases and
formUpdate visibility; existing four dependency deprecation warnings.
2026-10-23 15:54:51 +08:00

152 lines
5.0 KiB
Python

import asyncio
import pytest
from src.agent.collect import CollectReplyError, validated_reply
from src.agent.llm import FakeLLMService
from src.backends.chat import ChatInput, ChatResult, TextDelta
from src.backends.langgraph import LangGraphBackend
from src.services.app_service import AccidentAppService
from src.services.repository import InMemorySessionRepository
class ValidatingGraph:
def __init__(self):
self.committed = None
async def ainvoke(self, data, *, config, context):
raw, stage = await validated_reply(context.llm, 'system', 'user', '1002')
self.committed = stage
return {'response_text': raw, 'turn_patch': {}}
class ScriptedStream:
def __init__(self, replies):
self.replies = iter(replies)
self.calls = 0
async def complete(self, *args, **kwargs):
return '{}'
async def stream_tokens(self, *args, **kwargs):
self.calls += 1
for chunk in next(self.replies):
yield chunk
@pytest.mark.asyncio
async def test_invalid_prefix_is_not_sent_and_repair_is_the_only_reply():
graph = ValidatingGraph()
llm = ScriptedStream([
['<state>9999</state>invalid'],
['<sta', 'te>1002</state>', 'repaired'],
])
backend = LangGraphBackend(graph=graph, llm=llm)
events = [e async for e in backend.stream(ChatInput('s', 'hello'))]
assert ''.join(e.text for e in events if isinstance(e, TextDelta)) == '<state>1002</state>repaired'
assert graph.committed == '1002'
assert llm.calls == 2
@pytest.mark.asyncio
async def test_failure_after_output_aborts_without_appending_a_repair():
graph = ValidatingGraph()
llm = ScriptedStream([
['<state>1002</state>', 'partial', '<state>9999</state>'],
['<state>1002</state>must not appear'],
])
backend = LangGraphBackend(graph=graph, llm=llm)
events = []
with pytest.raises(CollectReplyError):
async for event in backend.stream(ChatInput('s', 'hello')):
events.append(event)
assert llm.calls == 1
assert graph.committed is None
assert 'must not appear' not in ''.join(e.text for e in events)
@pytest.mark.asyncio
async def test_repair_exhaustion_emits_no_invalid_text():
llm = ScriptedStream([['<state>9999</state>x'], ['malformed']])
backend = LangGraphBackend(graph=ValidatingGraph(), llm=llm)
events = []
with pytest.raises(CollectReplyError):
async for event in backend.stream(ChatInput('s', 'hello')):
events.append(event)
assert events == []
class BlockingBackend:
def __init__(self):
self.entered = asyncio.Queue()
self.release = asyncio.Event()
self.active = 0
self.peak = 0
async def complete(self, request):
self.active += 1
self.peak = max(self.peak, self.active)
await self.entered.put(request.text)
try:
await self.release.wait()
return ChatResult('<state>1002</state>ok', form_update={'vehicle_count': 1})
finally:
self.active -= 1
@pytest.mark.asyncio
async def test_delete_waiter_and_new_chat_keep_one_lock():
backend = BlockingBackend()
service = AccidentAppService(backend, InMemorySessionRepository())
dropping, drop_release = asyncio.Event(), asyncio.Event()
async def drop(_):
dropping.set()
await drop_release.wait()
deletion = asyncio.create_task(service.delete_session('s', drop))
await dropping.wait()
first = asyncio.create_task(service.chat(ChatInput('s', 'a')))
await asyncio.sleep(0)
drop_release.set()
await deletion
assert await asyncio.wait_for(backend.entered.get(), 1) == 'a'
second = asyncio.create_task(service.chat(ChatInput('s', 'b')))
await asyncio.sleep(0)
assert backend.entered.empty()
backend.release.set()
await asyncio.wait_for(asyncio.gather(first, second), 2)
assert backend.peak == 1
assert not service._locks._entries
@pytest.mark.asyncio
async def test_set_waits_for_chat_and_cannot_be_overwritten_by_old_result():
backend = BlockingBackend()
service = AccidentAppService(backend, InMemorySessionRepository())
chat = asyncio.create_task(service.chat(ChatInput('s', 'a')))
await backend.entered.get()
setting = asyncio.create_task(service.set_info('s', 'jdcsl', '2'))
await asyncio.sleep(0)
assert not setting.done()
backend.release.set()
await asyncio.gather(chat, setting)
assert await service.get_info('s', 'jdcsl') == '"2"'
@pytest.mark.asyncio
async def test_cancelled_waiter_does_not_leak_or_replace_lock():
backend = BlockingBackend()
service = AccidentAppService(backend, InMemorySessionRepository())
chat = asyncio.create_task(service.chat(ChatInput('s', 'a')))
await backend.entered.get()
waiting = asyncio.create_task(service.get_info('s', 'jdcsl'))
await asyncio.sleep(0)
waiting.cancel()
with pytest.raises(asyncio.CancelledError):
await waiting
assert len(service._locks._entries) == 1
backend.release.set()
await chat
assert not service._locks._entries