feat(rag): retry a failed LLM request before the first token lands — BOR_LLM_RETRIES/BOR_LLM_RETRY_DELAY with a live 'retrying' status

This commit is contained in:
2026-09-02 10:52:38 -04:00
parent f04ddbe1f8
commit 88293ed02f
44 changed files with 2488 additions and 56 deletions
+307 -7
View File
@@ -8,15 +8,21 @@ removed the per-tool budgets, the assistant/tool message history), the
kill switch (``agent_max_rounds=0`` single-call path), the round cap
forcing a final no-tools answer (an always-calling stream and an
always-rejected stream), re-lists and multi-reads executing without
budgets, dedupe, unknown tool / missing args / unknown path, and the
``<tools>`` prompt section (HIGH only).
budgets, dedupe, unknown tool / missing args / unknown path, the
``<tools>`` prompt section (HIGH only), and the phase-67 per-round
retries (a dead-then-recovered round restarts before its first piece
with a ``RetryPiece``; a mid-stream drop stays terminal — locked A2;
the forced final no-tools call retries too; ``llm_retries=0`` is one
plain attempt; retries are invisible to the round cap; consumer abandon
mid-retry-sleep leaks nothing).
"""
from __future__ import annotations
import asyncio
import json
import logging
import uuid
from collections.abc import AsyncIterator
from collections.abc import AsyncGenerator, AsyncIterator
from copy import deepcopy
from typing import Any, cast
@@ -31,7 +37,7 @@ from app.rag.agent import (
AgentHolder,
run_agent,
)
from app.rag.llm import LLMClient, StreamPiece, ToolCallPiece
from app.rag.llm import LLMClient, LLMError, RetryPiece, StreamPiece, ToolCallPiece
from app.rag.prompts import TOOLS_SECTION, _base, build_deflect_prompt, build_high_prompt
@@ -73,12 +79,12 @@ class ScriptedLLM:
async def _run(
llm: ScriptedLLM,
llm: ScriptedLLM | FailingLLM,
holder: AgentHolder,
settings: Settings,
seed_docs: list[Document] | None = None,
) -> list[StreamPiece | ToolCallPiece]:
out: list[StreamPiece | ToolCallPiece] = []
) -> list[StreamPiece | ToolCallPiece | RetryPiece]:
out: list[StreamPiece | ToolCallPiece | RetryPiece] = []
async for piece in run_agent(
cast("LLMClient", llm),
cast("Session", None),
@@ -541,6 +547,300 @@ def test_read_document_missing_arguments_refused(
assert llm.requests[1][1] == AGENT_TOOLS
# ---------- retries inside the agent loop (phase 67, locked A2) ----------
class FailingLLM:
"""A scripted fake whose Nth ``chat_stream`` call yields pieces and
then raises (phase 67): ``attempts`` is a list of ``(pieces, error)``
— an error after zero pieces = "the endpoint died before the first
token"; after some pieces = a mid-stream drop. Records every
request's messages/tools and the indices of the attempts whose stream
teardown ran (``closed``)."""
def __init__(
self,
attempts: list[tuple[list[StreamPiece | ToolCallPiece], Exception | None]],
) -> None:
self.attempts = list(attempts)
self.requests: list[tuple[list[dict[str, Any]], list[dict[str, Any]] | None]] = []
#: Indices of attempts whose stream teardown has run.
self.closed: list[int] = []
def chat_stream(
self,
messages: list[dict[str, str]],
tools: list[dict[str, Any]] | None = None,
) -> AsyncIterator[StreamPiece | ToolCallPiece]:
index = len(self.requests)
pieces, error = (
self.attempts[index]
if index < len(self.attempts)
else ([], LLMError("script exhausted"))
)
self.requests.append(
(deepcopy(messages), deepcopy(tools) if tools is not None else None)
)
return self._attempt(index, pieces, error)
async def _attempt(
self,
index: int,
pieces: list[StreamPiece | ToolCallPiece],
error: Exception | None,
) -> AsyncIterator[StreamPiece | ToolCallPiece]:
try:
for piece in pieces:
yield piece
if error is not None:
raise error
finally:
self.closed.append(index)
def _record_sleeps(monkeypatch: pytest.MonkeyPatch) -> list[float]:
"""Monkeypatch ``asyncio.sleep`` (what ``chat_stream_retried`` awaits
for the flat pre-retry delay) and record every awaited delay."""
sleeps: list[float] = []
async def fake_sleep(seconds: float) -> None:
sleeps.append(seconds)
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
return sleeps
def test_round_retried_before_first_piece(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A tool round that dies before its first piece is restarted with the
same messages: the stream carries a RetryPiece BEFORE the tool call,
the tool executes, the final answer streams, and the per-call log line
is still emitted exactly once (retries are invisible to the loop)."""
monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")])
holder = AgentHolder()
llm = FailingLLM(
[
([], LLMError("connection refused")),
([ToolCallPiece(id="call_1", name="list_documents", arguments={})], None),
([StreamPiece("content", "Done!")], None),
]
)
sleeps = _record_sleeps(monkeypatch)
with caplog.at_level(logging.INFO, logger="app.agent"):
pieces = asyncio.run(
_run(llm, holder, _settings(agent_max_rounds=2, llm_retry_delay=2.5))
)
assert pieces == [
RetryPiece(2, 4), # default llm_retries=3 → 4 attempts
ToolCallPiece(id="call_1", name="list_documents", arguments={}),
StreamPiece("content", "Done!"),
]
assert holder.tool_calls == 1
assert holder.read_docs == []
# The restart is byte-identical: same messages, same tools offered.
assert len(llm.requests) == 3
assert llm.requests[0] == llm.requests[1]
assert llm.requests[0][1] == AGENT_TOOLS
assert llm.requests[2][1] == AGENT_TOOLS # the answer round still offered
# The flat delay was awaited exactly once, before the retry.
assert sleeps == [2.5]
tool_logs = [r for r in caplog.records if r.getMessage().startswith("agent tool=")]
assert len(tool_logs) == 1 # the retry did not re-run the tool or log
assert tool_logs[0].getMessage() == "agent tool=list_documents args={} round=1/2"
def test_round_failure_after_first_piece_is_terminal(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Locked A2: a round that already streamed a piece fails the turn —
the LLMError propagates out of ``run_agent``, no RetryPiece, no
sleep, no second request, and the holder is untouched (the tool
never ran)."""
monkeypatch.setattr(agent, "list_catalog", lambda db: [])
holder = AgentHolder()
llm = FailingLLM(
[([StreamPiece("content", "partial ")], LLMError("mid-stream drop"))]
)
sleeps = _record_sleeps(monkeypatch)
async def drain() -> list[StreamPiece | ToolCallPiece | RetryPiece]:
out: list[StreamPiece | ToolCallPiece | RetryPiece] = []
with pytest.raises(LLMError, match="mid-stream drop"):
async for piece in run_agent(
cast("LLMClient", llm),
cast("Session", None),
system_prompt="SYSTEM_PROMPT",
user_message="QUESTION",
seed_docs=[],
settings=_settings(),
holder=holder,
):
out.append(piece)
return out
out = asyncio.run(drain())
assert out == [StreamPiece("content", "partial ")] # no RetryPiece
assert len(llm.requests) == 1 # no retry
assert sleeps == []
assert holder.read_docs == [] and holder.tool_calls == 0
def test_forced_final_no_tools_call_is_retried(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The forced final request (round cap reached) goes through the same
retry rule: a failure before its first piece yields a RetryPiece and
restarts with ``tools=None``; the answer from the retry streams."""
monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")])
holder = AgentHolder()
llm = FailingLLM(
[
([ToolCallPiece(id="call_1", name="list_documents", arguments={})], None),
([ToolCallPiece(id="call_2", name="list_documents", arguments={})], None),
([], LLMError("down at the cap")),
([StreamPiece("content", "forced answer")], None),
]
)
pieces = asyncio.run(_run(llm, holder, _settings(agent_max_rounds=2)))
assert [type(p) for p in pieces] == [
ToolCallPiece,
ToolCallPiece,
RetryPiece,
StreamPiece,
]
assert pieces[2] == RetryPiece(2, 4)
assert pieces[3] == StreamPiece("content", "forced answer")
assert len(llm.requests) == 4 # 2 tool rounds + the final + its retry
# The forced final (and its retry) carry no tools, whatever is left.
assert llm.requests[2][1] is None
assert llm.requests[3][1] is None
# …and the restart is byte-identical.
assert llm.requests[2][0] == llm.requests[3][0]
assert holder.tool_calls == 2
def test_zero_retries_is_one_plain_attempt(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The kill-switch path (``llm_retries=0``): a dead round raises
immediately — one request, no RetryPiece, no sleep (pre-phase-67
behavior)."""
monkeypatch.setattr(agent, "list_catalog", lambda db: [])
holder = AgentHolder()
llm = FailingLLM([([], LLMError("connection refused"))])
sleeps = _record_sleeps(monkeypatch)
async def drain() -> list[StreamPiece | ToolCallPiece | RetryPiece]:
out: list[StreamPiece | ToolCallPiece | RetryPiece] = []
with pytest.raises(LLMError, match="connection refused"):
async for piece in run_agent(
cast("LLMClient", llm),
cast("Session", None),
system_prompt="SYSTEM_PROMPT",
user_message="QUESTION",
seed_docs=[],
settings=_settings(llm_retries=0),
holder=holder,
):
out.append(piece)
return out
out = asyncio.run(drain())
assert out == [] # nothing streamed, no RetryPiece
assert len(llm.requests) == 1
assert sleeps == []
assert holder.read_docs == [] and holder.tool_calls == 0
def test_abandon_mid_retry_sleep_leaks_nothing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Consumer abandon while a retried round is parked in the pre-retry
sleep (client disconnect): the driving task is cancelled cleanly, the
production teardown ``aclose()`` on ``run_agent`` does not raise, the
inner attempt's stream was torn down, and the retry never starts."""
entered = asyncio.Event()
async def parking_sleep(seconds: float) -> None:
entered.set()
await asyncio.Event().wait() # park until the abandon arrives
monkeypatch.setattr(asyncio, "sleep", parking_sleep)
monkeypatch.setattr(agent, "list_catalog", lambda db: [])
holder = AgentHolder()
llm = FailingLLM(
[([], LLMError("endpoint down")), ([StreamPiece("content", "never")], None)]
)
async def run() -> None:
gen = run_agent(
cast("LLMClient", llm),
cast("Session", None),
system_prompt="SYSTEM_PROMPT",
user_message="QUESTION",
seed_docs=[],
settings=_settings(),
holder=holder,
)
async def consumer() -> list[StreamPiece | ToolCallPiece | RetryPiece]:
return [p async for p in gen]
task = asyncio.ensure_future(consumer())
await entered.wait() # the round's retry is parked in the sleep
assert not task.done()
task.cancel() # client disconnect: the driving task is cancelled
with pytest.raises(asyncio.CancelledError):
await task
# Production teardown (phase 48 pattern): must not raise. ``run_agent``
# is an async generator despite its AsyncIterator annotation.
await cast(
"AsyncGenerator[StreamPiece | ToolCallPiece | RetryPiece, None]", gen
).aclose()
asyncio.run(run())
assert len(llm.requests) == 1 # the retry never started
assert llm.closed == [0] # attempt 1's inner stream was torn down
assert holder.read_docs == [] and holder.tool_calls == 0
def test_retries_are_invisible_to_the_round_cap(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A failing-then-succeeding round consumes ONE round: with a cap of
2, the retried first round and the second tool round fill the cap —
the forced final follows the SECOND call, and the log lines read
round=1/2 and round=2/2."""
monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")])
holder = AgentHolder()
llm = FailingLLM(
[
([], LLMError("down")),
([ToolCallPiece(id="call_1", name="list_documents", arguments={})], None),
([ToolCallPiece(id="call_2", name="list_documents", arguments={})], None),
([StreamPiece("content", "forced answer")], None),
]
)
with caplog.at_level(logging.INFO, logger="app.agent"):
pieces = asyncio.run(_run(llm, holder, _settings(agent_max_rounds=2)))
assert [type(p) for p in pieces] == [
RetryPiece,
ToolCallPiece,
ToolCallPiece,
StreamPiece,
]
assert len(llm.requests) == 4 # 2 (round 1 + its retry) + 1 + the forced final
assert llm.requests[3][1] is None # the forced final, after round 2
assert holder.tool_calls == 2
msgs = [r.getMessage() for r in caplog.records]
assert "agent tool=list_documents args={} round=1/2" in msgs
assert "agent tool=list_documents args={} round=2/2" in msgs
assert any("round cap reached (rounds=2)" in m for m in msgs)
# ---------- prompts: <tools> section (HIGH only) ----------
+35
View File
@@ -112,6 +112,41 @@ def test_max_output_tokens_env_override(monkeypatch) -> None:
assert s.max_output_tokens == 1234
def test_llm_retry_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
"""Phase 67: a failed LLM request is retried by default — 3 retries
with a flat 5 s delay (the TODO-locked values, no backoff)."""
monkeypatch.delenv("BOR_LLM_RETRIES", raising=False)
monkeypatch.delenv("BOR_LLM_RETRY_DELAY", raising=False)
s = _settings()
assert s.llm_retries == 3
assert s.llm_retry_delay == 5.0
def test_llm_retry_env_overrides(monkeypatch: pytest.MonkeyPatch) -> None:
"""``BOR_LLM_RETRIES`` / ``BOR_LLM_RETRY_DELAY`` override the defaults;
``0`` retries is the no-retry kill switch (pre-phase-67 behavior)."""
monkeypatch.setenv("BOR_LLM_RETRIES", "0")
monkeypatch.setenv("BOR_LLM_RETRY_DELAY", "1.5")
s = _settings()
assert s.llm_retries == 0
assert s.llm_retry_delay == 1.5
def test_llm_retries_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> None:
"""``0`` is the kill switch — a negative value is a typo, so the
validator fails loudly at startup (the ``agent_max_rounds`` pattern)."""
monkeypatch.setenv("BOR_LLM_RETRIES", "-1")
with pytest.raises(ValidationError, match="llm_retries"):
_settings()
def test_llm_retry_delay_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> None:
"""A negative delay is a typo — fail loudly at startup."""
monkeypatch.setenv("BOR_LLM_RETRY_DELAY", "-0.5")
with pytest.raises(ValidationError, match="llm_retry_delay"):
_settings()
def test_agent_max_rounds_default_and_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
"""Phase 45: the per-tool budgets are gone — ``BOR_AGENT_MAX_ROUNDS``
(default 10) is the single agent-loop knob; ``0`` is the no-tools
+53
View File
@@ -605,3 +605,56 @@ def test_index_messages_comment_documents_the_meta_actions() -> None:
assert "every visitor" in comment.lower() or "everyone" in comment.lower(), (
"Retry is documented as available to all visitors"
)
# ---------- llm retry status (phase 67, task 04) ----------
def test_retry_frame_is_a_first_class_branch_between_tool_and_delta() -> None:
"""Phase 67 (owner-locked A4, task 02's contract): a `retry` SSE
frame (the server restarted the LLM request before its first piece
— locked A2) is a first-class branch in runTurn's handler, ordered
BETWEEN the `tool` and `delta` branches. It clears the 120s guard
(a frame arrived), resolves n/N from the frame, and writes the
owner-locked copy literal onto the EXISTING channels only — the
#send-status live region + the typing-indicator aria-label. No DOM
of its own: no addMessage, no appendToolLine, no error banner, no
UI-state change. The gate covers BOTH live states: a later agent
round may restart while the UI already streams."""
js = _js()
tool_idx = js.find('ev.type === "tool"')
retry_idx = js.find('ev.type === "retry"')
delta_idx = js.find('ev.type === "delta"')
assert -1 < tool_idx < retry_idx < delta_idx, (
"the turn handler must branch on retry frames, between tool and delta"
)
assert js.count('ev.type === "retry"') == 1, "exactly one retry branch"
branch = js[retry_idx:delta_idx]
assert "clearTurnTimeout()" in branch, "a frame arrived — the 120s guard clears"
assert "Number(ev.attempt)" in branch, "n = the attempt about to be tried"
assert "Number(ev.max_attempts)" in branch, "N = the configured total"
assert (
"`Communication interrupted — retrying (${attempt} of ${max})…`" in branch
), "the owner-locked copy literal (A4)"
assert "sendStatus.textContent = retryStatus" in branch, (
"the existing #send-status live region carries the status"
)
assert "#typing-indicator .bubble" in branch, ("the typing-indicator is reused")
assert 'setAttribute("aria-label", retryStatus)' in branch
assert "UI_STATE.thinking" in branch and "UI_STATE.streaming" in branch, (
"the gate covers BOTH live states (a later round may restart mid-stream)"
)
for forbidden in ("addMessage", "appendToolLine", "showErrorBanner", "setUiState"):
assert forbidden not in branch, f"transient status only — no {forbidden}"
def test_header_inventory_documents_the_retry_frame() -> None:
"""The app.js file-header doc comment inventories every SSE frame
type (house convention); phase 67 adds the `retry` frame there, with
the owner-locked copy quoted, so the A4 literal has exactly two
homes: the header doc and the handler branch."""
js = _js()
header = js[: js.find("import {")]
assert "`retry`" in header, "the header must list the retry frame"
assert "Communication interrupted — retrying" in header
assert header.count("Communication interrupted — retrying") == 1
+274
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import asyncio
import json
from collections.abc import AsyncGenerator
from types import SimpleNamespace
from typing import Any, cast
@@ -21,8 +22,10 @@ from app.rag.llm import (
EmbeddingError,
LLMClient,
LLMError,
RetryPiece,
StreamPiece,
ToolCallPiece,
chat_stream_retried,
)
@@ -800,3 +803,274 @@ def test_chat_whitespace_only_content_raises_llm_error() -> None:
llm, _ = _make_chat_client(_FakeCompletion(" \n\t "))
with pytest.raises(LLMError, match="empty content"):
asyncio.run(llm.chat([{"role": "user", "content": "q"}]))
# ---------- chat_stream_retried (phase 67, task 01) ----------
_RETRY_MSGS: list[dict[str, str]] = [{"role": "user", "content": "q"}]
class _ScriptedClient(LLMClient):
"""An LLMClient whose ``chat_stream`` is scripted per attempt — no
endpoint. ``attempts`` scripts the Nth call: ``(pieces, error)`` — the
stream yields *pieces*, then raises *error* if not None (an error after
zero pieces = "the endpoint died before the first token"; after some
pieces = a mid-stream drop). Records every request's messages/tools
and every attempt's stream teardown (the phase-48 close analog)."""
def __init__(
self, attempts: list[tuple[list[StreamPiece | ToolCallPiece], Exception | None]]
) -> None:
super().__init__(_settings())
self.attempts = list(attempts)
self.request_args: list[
tuple[list[dict[str, str]], list[dict[str, Any]] | None]
] = []
#: Indices of attempts whose stream teardown has run.
self.closed: list[int] = []
def chat_stream(
self,
messages: list[dict[str, str]],
tools: list[dict[str, Any]] | None = None,
) -> AsyncGenerator[StreamPiece | ToolCallPiece, None]:
index = len(self.request_args)
pieces, error = (
self.attempts[index]
if index < len(self.attempts)
else ([], LLMError("script exhausted"))
)
self.request_args.append(
(list(messages), list(tools) if tools is not None else None)
)
return self._attempt(index, pieces, error)
async def _attempt(
self, index: int, pieces: list[StreamPiece | ToolCallPiece],
error: Exception | None,
) -> AsyncGenerator[StreamPiece | ToolCallPiece, None]:
try:
for piece in pieces:
yield piece
if error is not None:
raise error
finally:
self.closed.append(index)
def _record_sleeps(monkeypatch: pytest.MonkeyPatch) -> list[float]:
"""Monkeypatch ``asyncio.sleep`` (what ``chat_stream_retried`` awaits
for the flat pre-retry delay) and record every awaited delay."""
sleeps: list[float] = []
async def fake_sleep(seconds: float) -> None:
sleeps.append(seconds)
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
return sleeps
def _collect_retried(
client: _ScriptedClient,
messages: list[dict[str, str]],
*,
tools: list[dict[str, Any]] | None = None,
retries: int,
delay: float,
) -> list[StreamPiece | ToolCallPiece | RetryPiece]:
async def run() -> list[StreamPiece | ToolCallPiece | RetryPiece]:
return [
p
async for p in chat_stream_retried(
client, messages, tools=tools, retries=retries, delay=delay
)
]
return asyncio.run(run())
def test_retried_retries_a_dead_attempt_before_the_first_piece(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Failure on attempt 1, success on attempt 2 → [RetryPiece(2, N)]
(the attempt about to be tried, 1-based) then the answer pieces; the
request is restarted byte-identical and the flat delay is awaited
exactly once."""
answer: list[StreamPiece | ToolCallPiece] = [
StreamPiece("content", "A "),
StreamPiece("content", "B"),
]
client = _ScriptedClient([([], LLMError("connection refused")), (answer, None)])
sleeps = _record_sleeps(monkeypatch)
pieces = _collect_retried(client, _RETRY_MSGS, retries=3, delay=2.5)
assert pieces == [RetryPiece(2, 4), *answer]
assert len(client.request_args) == 2
# The restart is byte-identical: same messages, same (absent) tools.
assert client.request_args[0] == client.request_args[1]
assert client.request_args[0][1] is None
assert sleeps == [2.5]
def test_retried_exhaustion_yields_all_retries_then_raises(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""retries=2 → 3 attempts; each pre-first-piece failure yields a
RetryPiece naming the attempt about to be tried (attempts 2 and 3 of
3), the final failure raises the terminal LLMError, and no sleep
follows the last attempt."""
client = _ScriptedClient(
[([], LLMError("down 1")), ([], LLMError("down 2")), ([], LLMError("down 3"))]
)
sleeps = _record_sleeps(monkeypatch)
async def run() -> list[RetryPiece]:
out: list[RetryPiece] = []
with pytest.raises(LLMError, match="down 3"):
async for p in chat_stream_retried(
client, _RETRY_MSGS, retries=2, delay=0.5
):
assert isinstance(p, RetryPiece)
out.append(p)
return out
out = asyncio.run(run())
assert out == [RetryPiece(2, 3), RetryPiece(3, 3)]
assert len(client.request_args) == 3
assert sleeps == [0.5, 0.5]
def test_retried_failure_after_first_piece_is_terminal(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Locked A2: a piece has already flowed → the LLMError is re-raised
immediately — no RetryPiece, no sleep, no second call (a partial
answer is never redone)."""
client = _ScriptedClient(
[([StreamPiece("content", "partial ")], LLMError("mid-stream drop"))],
)
sleeps = _record_sleeps(monkeypatch)
async def run() -> list[StreamPiece | ToolCallPiece | RetryPiece]:
out: list[StreamPiece | ToolCallPiece | RetryPiece] = []
with pytest.raises(LLMError, match="mid-stream drop"):
async for p in chat_stream_retried(
client, _RETRY_MSGS, retries=3, delay=5.0
):
out.append(p)
return out
out = asyncio.run(run())
assert out == [StreamPiece("content", "partial ")]
assert not any(isinstance(p, RetryPiece) for p in out)
assert len(client.request_args) == 1
assert sleeps == []
def test_retried_zero_retries_is_one_attempt_no_retry_pieces(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The kill-switch path (retries=0): exactly one attempt, the error
propagates, no RetryPiece, no sleep — the pre-phase-67 behavior."""
client = _ScriptedClient([([], LLMError("connection refused"))])
sleeps = _record_sleeps(monkeypatch)
with pytest.raises(LLMError, match="connection refused"):
_collect_retried(client, _RETRY_MSGS, retries=0, delay=5.0)
assert len(client.request_args) == 1
assert sleeps == []
def test_retried_healthy_stream_is_untouched(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""No failure → exactly one attempt, every piece kind (thinking / tool
call / content) passes through unchanged, no RetryPiece, no sleep —
a healthy turn is byte-identical to the plain chat_stream."""
answer = [
StreamPiece("thinking", "hmm"),
ToolCallPiece(id="call_1", name="list_documents", arguments={}),
StreamPiece("content", "Talos."),
]
client = _ScriptedClient([(answer, None)])
sleeps = _record_sleeps(monkeypatch)
tools = [
{
"type": "function",
"function": {"name": "list_documents", "parameters": {}},
}
]
pieces = _collect_retried(
client, _RETRY_MSGS, tools=tools, retries=3, delay=5.0
)
assert pieces == answer
assert client.request_args == [(_RETRY_MSGS, tools)]
assert sleeps == []
def test_retried_zero_delay_still_notifies(monkeypatch: pytest.MonkeyPatch) -> None:
"""The e2e fast path (BOR_LLM_RETRY_DELAY=0): the RetryPiece is still
emitted and the (zero) sleep is still awaited."""
client = _ScriptedClient(
[([], LLMError("down")), ([StreamPiece("content", "ok")], None)]
)
sleeps = _record_sleeps(monkeypatch)
pieces = _collect_retried(client, _RETRY_MSGS, retries=1, delay=0)
assert pieces == [RetryPiece(2, 2), StreamPiece("content", "ok")]
assert sleeps == [0]
def test_retried_abandon_mid_attempt_closes_the_attempt_stream() -> None:
"""Consumer abandon at a mid-attempt piece (the stop-generation path,
phase 48): no exception leaks and the attempt's stream is torn down
through the wrapper's explicit close."""
client = _ScriptedClient(
[([StreamPiece("content", "A "), StreamPiece("content", "B ")], None)]
)
async def run() -> None:
gen = chat_stream_retried(client, _RETRY_MSGS, retries=3, delay=1.0)
first = await gen.__anext__()
assert first == StreamPiece("content", "A ")
await gen.aclose() # the consumer stops after the first piece
asyncio.run(run())
assert client.closed == [0] # attempt 1's stream was closed
assert len(client.request_args) == 1 # no second attempt
def test_retried_abandon_during_retry_sleep_leaks_nothing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Consumer abandon while the generator is parked in the pre-retry
sleep (client disconnect): the driving task is cancelled cleanly,
the phase-48 teardown ``aclose()`` on the generator does not raise,
and the retry never starts."""
entered = asyncio.Event()
async def parking_sleep(seconds: float) -> None:
entered.set()
await asyncio.Event().wait() # park until the abandon arrives
monkeypatch.setattr(asyncio, "sleep", parking_sleep)
client = _ScriptedClient(
[([], LLMError("endpoint down")), ([StreamPiece("content", "never")], None)]
)
async def run() -> None:
gen = chat_stream_retried(client, _RETRY_MSGS, retries=3, delay=1.5)
async def consumer() -> list[StreamPiece | ToolCallPiece | RetryPiece]:
return [p async for p in gen]
task = asyncio.ensure_future(consumer())
await entered.wait() # the generator is inside the pre-retry sleep
assert not task.done()
task.cancel() # client disconnect: the driving task is cancelled
with pytest.raises(asyncio.CancelledError):
await task
# Production teardown (phase 48 pattern): the endpoint's finally
# closes the stream generator — must not raise.
await gen.aclose()
asyncio.run(run())
assert len(client.request_args) == 1 # the retry never started
assert client.closed == [0] # attempt 1's stream was torn down
+26 -1
View File
@@ -4,7 +4,12 @@ from __future__ import annotations
import json
from app.api.chat import sse_event
from app.schemas import ChatErrorEvent, ChatThinkingEvent, ChatToolEvent
from app.schemas import (
ChatErrorEvent,
ChatRetryEvent,
ChatThinkingEvent,
ChatToolEvent,
)
def _payload(frame: str) -> dict:
@@ -100,3 +105,23 @@ def test_tool_event_shape_is_type_name_argument_only() -> None:
dumped = ChatToolEvent(name="read_document", argument="S/p.md").model_dump()
assert set(dumped.keys()) == {"type", "name", "argument"}
assert dumped["type"] == "tool" # default — call sites never spell it out
def test_retry_frame_serializes_exactly() -> None:
"""Phase 67: the ``retry`` frame is exactly ``{type: "retry",
attempt: int, max_attempts: int}`` — a transient status the client's
readSSE handler branches on ("Communication interrupted — retrying
(n of N)…"), never an error. ``attempt`` is the attempt the server is
about to try next (1-based); the contract the frontend branch and the
E2E suite key off, locked here ahead of the JS implementation."""
frame = sse_event(ChatRetryEvent(attempt=2, max_attempts=4).model_dump())
assert frame == 'data: {"type": "retry", "attempt": 2, "max_attempts": 4}\n\n'
assert _payload(frame) == {"type": "retry", "attempt": 2, "max_attempts": 4}
def test_retry_event_shape_is_type_attempt_max_attempts_only() -> None:
dumped = ChatRetryEvent(attempt=2, max_attempts=4).model_dump()
assert set(dumped.keys()) == {"type", "attempt", "max_attempts"}
assert dumped["type"] == "retry" # default — call sites never spell it out
assert isinstance(dumped["attempt"], int)
assert isinstance(dumped["max_attempts"], int)