- 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.
152 lines
5.0 KiB
Python
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
|