phase: 96_oneshot_resilience
All checks complete. Final report: **Phase 96 (one-shot resilience) — final verification pass, all green** (all 4 task files already in `complete/`; verified the working-tree implementation against the design) - `LLMClient.chat()` empty-content retry (D1–D3) via `_chat_once` + `_EmptyContentError` (carries `finish_reason`), under `BOR_LLM_RETRIES`/`BOR_LLM_RETRY_DELAY` — verified in diff - `missing_folder_summaries()` + `generate_folder_summaries(only_missing=…)` — verified; `folder_summary_table_empty` deleted, both sync gates switched to the gap probe - `.env.example` comments updated (chat-turn stream + one-shot summary calls) **Test / lint / coverage results** - `uv run pytest --cov=app --cov-report=term-missing` → **1988 passed**, coverage **99%** (gate >90%) - `uv run pytest tests/e2e/test_oneshot_llm_retry.py -v --no-cov` → **2 passed** (isolated) - Regressions, each isolated: `test_ls_tree_drilldown` 3 passed, `test_sync_button` 3 passed, `test_local_directory_sources` 3 passed, `test_llm_retry` 4 passed - `uv run ruff check . && uv run pyright` → clean (0 errors) **Completion criteria:** retry-then-recover unit-pinned ✓ · exhaustion + `BOR_LLM_RETRIES=0` byte-identical ✓ · streaming path untouched ✓ · gap-fill both sync paths, other rows byte-identical incl. `updated_at` ✓ · no-gap zero-burn ✓ · phase E2E green ✓ · regression E2Es green ✓ · full suite + >90% + lint/types ✓ · no completed-phase behavior change (full suite green) ✓. Commit left to the harness per executor rules (working tree, 16 files). **Deviations:** none. **Next pending phase:** `97_kb_tree_catalog`.
This commit is contained in:
@@ -31,9 +31,9 @@ from app.rag.folder_summaries import (
|
||||
SYSTEM_PROMPT,
|
||||
build_folder_summary_prompt,
|
||||
folder_of,
|
||||
folder_summary_table_empty,
|
||||
generate_folder_summaries,
|
||||
group_by_folder,
|
||||
missing_folder_summaries,
|
||||
summarize_folder,
|
||||
)
|
||||
from app.rag.llm import LLMError
|
||||
@@ -613,16 +613,198 @@ def test_generate_only_flushes_caller_commits(db: Session, clean_tables) -> None
|
||||
assert MIN_DOCS_PER_FOLDER == 2 # the ≥ 2 scope rule, pinned by name
|
||||
|
||||
|
||||
def test_folder_summary_table_empty_gate(db: Session, clean_tables) -> None:
|
||||
"""The sync-path gate probe (phase 94, task 02): empty → True
|
||||
(the first full sync after migration 0017 must still generate),
|
||||
one row → False (a populated table waits for a KB change)."""
|
||||
assert folder_summary_table_empty(db) is True # the truncated table
|
||||
_add_doc(db, "FSU", "a/one.md", "One")
|
||||
_add_doc(db, "FSU", "a/two.md", "Two")
|
||||
# ---------- missing_folder_summaries (phase 96, task 02) ----------
|
||||
|
||||
|
||||
def test_missing_fresh_table_is_exactly_the_candidate_set(
|
||||
db: Session, clean_tables
|
||||
) -> None:
|
||||
"""No stored rows → every candidate folder is a gap, sorted by
|
||||
``(source, folder_path)``; the single-doc FSU-solo root is not a
|
||||
candidate and can never be a gap."""
|
||||
_seed_catalogue(db)
|
||||
assert missing_folder_summaries(db) == [
|
||||
("FSU", ""),
|
||||
("FSU", "a"),
|
||||
("FSU", "a/b"),
|
||||
]
|
||||
assert ("FSU-solo", "") not in missing_folder_summaries(db)
|
||||
|
||||
|
||||
def test_missing_fully_populated_table_is_empty(db: Session, clean_tables) -> None:
|
||||
"""Every candidate row present → no gap (the zero-burn gate case)."""
|
||||
_seed_catalogue(db)
|
||||
asyncio.run(generate_folder_summaries(db, _FakeLLM()))
|
||||
db.commit()
|
||||
assert folder_summary_table_empty(db) is False # rows landed
|
||||
assert missing_folder_summaries(db) == []
|
||||
|
||||
|
||||
def test_missing_one_deleted_row_is_that_folder(db: Session, clean_tables) -> None:
|
||||
_seed_catalogue(db)
|
||||
asyncio.run(generate_folder_summaries(db, _FakeLLM()))
|
||||
db.commit()
|
||||
db.execute(
|
||||
text(
|
||||
"DELETE FROM folder_summaries "
|
||||
"WHERE source = 'FSU' AND folder_path = 'a'"
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
assert missing_folder_summaries(db) == [("FSU", "a")]
|
||||
|
||||
|
||||
def test_missing_empty_kb_empty_table_is_no_gap(db: Session, clean_tables) -> None:
|
||||
"""No catalogue → no candidates → ``[]`` — an empty table over an
|
||||
empty KB is not a gap (there is nothing to fill)."""
|
||||
assert missing_folder_summaries(db) == []
|
||||
|
||||
|
||||
def test_missing_single_doc_folder_is_never_listed(db: Session, clean_tables) -> None:
|
||||
"""A below-minimum folder without a row is NOT a gap — it is not a
|
||||
candidate (its one file line IS its summary)."""
|
||||
_add_doc(db, "FSU", "solo/one.md", "One")
|
||||
assert missing_folder_summaries(db) == []
|
||||
|
||||
|
||||
def test_missing_stale_row_is_not_a_gap(db: Session, clean_tables) -> None:
|
||||
"""A stored row for a folder that dropped below 2 docs is stale,
|
||||
not missing — the prune pass owns it, the gap detector ignores it."""
|
||||
_seed_catalogue(db)
|
||||
asyncio.run(generate_folder_summaries(db, _FakeLLM()))
|
||||
db.commit()
|
||||
db.add(FolderSummary(source="FSU", folder_path="gone/old", summary="stale"))
|
||||
db.commit()
|
||||
assert missing_folder_summaries(db) == []
|
||||
|
||||
|
||||
# ---------- generate_folder_summaries(only_missing=…) (phase 96, 02) ----------
|
||||
|
||||
|
||||
def _updated_at(db: Session, source: str, folder_path: str) -> object:
|
||||
"""The stored row's ``updated_at`` (raw SQL — bypasses the ORM
|
||||
identity map, so the before/after byte-identity comparison is
|
||||
honest)."""
|
||||
return db.execute(
|
||||
text(
|
||||
"SELECT updated_at FROM folder_summaries "
|
||||
"WHERE source = :s AND folder_path = :f"
|
||||
),
|
||||
{"s": source, "f": folder_path},
|
||||
).scalar_one()
|
||||
|
||||
|
||||
def test_only_missing_fills_exactly_the_missing_keys(
|
||||
db: Session, clean_tables
|
||||
) -> None:
|
||||
"""Two missing + two present → exactly the missing keys are
|
||||
generated (sorted order, one lite call each); the present rows are
|
||||
byte-identical after (text AND ``updated_at``); stats right."""
|
||||
_seed_catalogue(db)
|
||||
asyncio.run(generate_folder_summaries(db, _FakeLLM()))
|
||||
db.commit()
|
||||
full = _rows(db)
|
||||
a_stamp = _updated_at(db, "FSU", "a")
|
||||
db.execute(
|
||||
text(
|
||||
"DELETE FROM folder_summaries "
|
||||
"WHERE (source, folder_path) IN (('FSU', ''), ('FSU', 'a/b'))"
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
assert missing_folder_summaries(db) == [("FSU", ""), ("FSU", "a/b")]
|
||||
|
||||
llm = _FakeLLM()
|
||||
stats = asyncio.run(generate_folder_summaries(db, llm, only_missing=True))
|
||||
assert stats == {"generated": 2, "failed": 0, "pruned": 0}
|
||||
assert llm.calls == 2, "one call per MISSING key — zero for present rows"
|
||||
assert [user.splitlines()[0] for _s, user in llm.requests] == [
|
||||
"Folder: FSU",
|
||||
"Folder: FSU/a/b",
|
||||
], "the missing keys in sorted (source, folder_path) order"
|
||||
|
||||
assert _rows(db) == full, "the fill restores exactly the full candidate set"
|
||||
assert _updated_at(db, "FSU", "a") == a_stamp, (
|
||||
"the present row is byte-identical — never re-stamped by the fill"
|
||||
)
|
||||
|
||||
|
||||
def test_only_missing_no_gap_burns_zero_calls(db: Session, clean_tables) -> None:
|
||||
"""No gap → zero lite calls, zero rows touched, zero stats (the
|
||||
zero-burn invariant the unchanged-sync gate relies on)."""
|
||||
_seed_catalogue(db)
|
||||
asyncio.run(generate_folder_summaries(db, _FakeLLM()))
|
||||
db.commit()
|
||||
before = _rows(db)
|
||||
stamps = {f: _updated_at(db, "FSU", f) for f in ("", "a", "a/b")}
|
||||
llm = _FakeLLM()
|
||||
stats = asyncio.run(generate_folder_summaries(db, llm, only_missing=True))
|
||||
assert stats == {"generated": 0, "failed": 0, "pruned": 0}
|
||||
assert llm.calls == 0, "zero-burn: no gap, no lite call"
|
||||
assert _rows(db) == before
|
||||
for folder, stamp in stamps.items():
|
||||
assert _updated_at(db, "FSU", folder) == stamp, "no row re-stamped"
|
||||
|
||||
|
||||
def test_only_missing_still_prunes_stale_rows(db: Session, clean_tables) -> None:
|
||||
"""The prune pass runs in BOTH modes: the manually seeded stale row
|
||||
(folder gone from the catalogue) is pruned while the genuine
|
||||
missing folders are filled, and the present row stays untouched."""
|
||||
_seed_catalogue(db)
|
||||
db.add(FolderSummary(source="FSU", folder_path="a", summary="keep me"))
|
||||
db.add(FolderSummary(source="FSU", folder_path="gone/old", summary="stale"))
|
||||
db.commit()
|
||||
a_stamp = _updated_at(db, "FSU", "a")
|
||||
assert missing_folder_summaries(db) == [("FSU", ""), ("FSU", "a/b")]
|
||||
|
||||
llm = _FakeLLM()
|
||||
stats = asyncio.run(generate_folder_summaries(db, llm, only_missing=True))
|
||||
assert stats == {"generated": 2, "failed": 0, "pruned": 1}
|
||||
assert llm.calls == 2
|
||||
|
||||
stored = _rows(db)
|
||||
assert ("FSU", "gone/old") not in stored, (
|
||||
"the stale row is pruned even under only_missing"
|
||||
)
|
||||
assert stored[("FSU", "a")] == "keep me"
|
||||
assert _updated_at(db, "FSU", "a") == a_stamp
|
||||
assert stored[("FSU", "")] == REPLY and stored[("FSU", "a/b")] == REPLY
|
||||
|
||||
|
||||
def test_only_missing_fail_soft_keeps_prior_and_lands_others(
|
||||
db: Session, clean_tables
|
||||
) -> None:
|
||||
"""Per-folder fail-soft applies under ``only_missing`` too: the
|
||||
failing missing folder is counted and stays absent; the other
|
||||
missing folders still land; the present row is untouched."""
|
||||
_seed_catalogue(db)
|
||||
db.add(FolderSummary(source="FSU", folder_path="a", summary="keep me"))
|
||||
db.commit()
|
||||
llm = _FakeLLM(fail_folders=("FSU/a/b",))
|
||||
stats = asyncio.run(generate_folder_summaries(db, llm, only_missing=True))
|
||||
assert stats == {"generated": 1, "failed": 1, "pruned": 0}
|
||||
assert llm.calls == 2 # both missing folders were attempted
|
||||
|
||||
stored = _rows(db)
|
||||
assert stored[("FSU", "")] == REPLY, "the other missing folder still lands"
|
||||
assert ("FSU", "a/b") not in stored, "the failed folder stays absent"
|
||||
assert stored[("FSU", "a")] == "keep me", "the present row is untouched"
|
||||
|
||||
|
||||
def test_gap_probe_subsumes_the_table_empty_gate(db: Session, clean_tables) -> None:
|
||||
"""The deleted phase-94 table-empty gate probe, re-expressed through
|
||||
``missing_folder_summaries`` (phase 96, task 03 — the probe's unit
|
||||
coverage moved here): an empty table over a populated catalogue
|
||||
means EVERY candidate is missing (the targeted fill over all
|
||||
candidates IS a full generation — the first full sync after
|
||||
migration 0017 must still generate), a populated table means no
|
||||
gap (a populated table waits for a KB change or a gap)."""
|
||||
assert missing_folder_summaries(db) == [] # the truncated table, empty KB
|
||||
_add_doc(db, "FSU", "a/one.md", "One")
|
||||
_add_doc(db, "FSU", "a/two.md", "Two")
|
||||
assert missing_folder_summaries(db) == [("FSU", ""), ("FSU", "a")]
|
||||
asyncio.run(generate_folder_summaries(db, _FakeLLM()))
|
||||
db.commit()
|
||||
assert missing_folder_summaries(db) == [] # rows landed → no gap
|
||||
db.execute(text("DELETE FROM folder_summaries"))
|
||||
db.commit()
|
||||
assert folder_summary_table_empty(db) is True # emptied again
|
||||
assert missing_folder_summaries(db) == [("FSU", ""), ("FSU", "a")] # emptied again
|
||||
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
@@ -346,14 +347,27 @@ class _FakeCompletion:
|
||||
"""One fake non-streaming ChatCompletion (``choices[].message`` shape).
|
||||
|
||||
``content=None`` mirrors the real wire where the field can be absent or
|
||||
empty (reasoning-only replies, provider quirks).
|
||||
empty (reasoning-only replies, provider quirks). ``finish_reason``
|
||||
(phase 96) defaults to ``None`` — the provider omitting it — and the
|
||||
incident signature is ``"length"`` (the whole ``max_tokens`` budget
|
||||
spent in ``reasoning_content``).
|
||||
"""
|
||||
|
||||
def __init__(self, content: str | None, empty_choices: bool = False) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
content: str | None,
|
||||
empty_choices: bool = False,
|
||||
finish_reason: str | None = None,
|
||||
) -> None:
|
||||
if empty_choices:
|
||||
self.choices = []
|
||||
else:
|
||||
self.choices = [SimpleNamespace(message=SimpleNamespace(content=content))]
|
||||
self.choices = [
|
||||
SimpleNamespace(
|
||||
message=SimpleNamespace(content=content),
|
||||
finish_reason=finish_reason,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class _FakeCompletions:
|
||||
@@ -362,12 +376,25 @@ class _FakeCompletions:
|
||||
chunks: list | None = None,
|
||||
fail: Exception | None = None,
|
||||
completion: _FakeCompletion | None = None,
|
||||
completion_seq: list[_FakeCompletion] | None = None,
|
||||
) -> None:
|
||||
self.chunks = chunks or []
|
||||
self.fail = fail
|
||||
self.completion = completion
|
||||
#: Phase 96: a scripted per-``create()`` reply sequence (the retry
|
||||
#: matrix) — popped one per non-streaming call, in order.
|
||||
self.completion_seq = (
|
||||
list(completion_seq) if completion_seq is not None else None
|
||||
)
|
||||
self.kwargs: dict | None = None
|
||||
self.chat_kwargs: dict | None = None
|
||||
#: Every non-streaming ``create()`` call's kwargs (the attempt
|
||||
#: counter for the phase-96 retry matrix).
|
||||
self.chat_calls: list[dict] = []
|
||||
#: Every ``create()`` call (streaming + non-streaming), incl.
|
||||
#: calls that raised (``fail``) — the attempt counter when the
|
||||
#: failure happens inside the SDK call itself.
|
||||
self.create_calls: int = 0
|
||||
#: Every SDK-shaped stream handed out — teardown tests assert the
|
||||
#: phase-48 ``close()`` on them (phase 71 task 02: with/without
|
||||
#: a filter, the teardown path is the same object).
|
||||
@@ -375,6 +402,7 @@ class _FakeCompletions:
|
||||
|
||||
async def create(self, **kwargs) -> _FakeChatStream | _FakeCompletion:
|
||||
self.kwargs = kwargs
|
||||
self.create_calls += 1
|
||||
if self.fail is not None:
|
||||
raise self.fail
|
||||
if kwargs.get("stream"):
|
||||
@@ -382,6 +410,11 @@ class _FakeCompletions:
|
||||
self.streams.append(stream)
|
||||
return stream
|
||||
self.chat_kwargs = kwargs
|
||||
self.chat_calls.append(dict(kwargs))
|
||||
if self.completion_seq is not None:
|
||||
if not self.completion_seq:
|
||||
raise AssertionError("completion script exhausted")
|
||||
return self.completion_seq.pop(0)
|
||||
assert self.completion is not None
|
||||
return self.completion
|
||||
|
||||
@@ -904,9 +937,12 @@ def test_chat_stream_abandon_with_filter_closes_stream() -> None:
|
||||
def _make_chat_client(
|
||||
completion: _FakeCompletion | None = None,
|
||||
fail: Exception | None = None,
|
||||
completion_seq: list[_FakeCompletion] | None = None,
|
||||
**settings_kwargs: Any,
|
||||
) -> tuple[LLMClient, _FakeCompletions]:
|
||||
completions = _FakeCompletions(fail=fail, completion=completion)
|
||||
completions = _FakeCompletions(
|
||||
fail=fail, completion=completion, completion_seq=completion_seq
|
||||
)
|
||||
fake_openai = SimpleNamespace(chat=SimpleNamespace(completions=completions))
|
||||
llm = LLMClient(_settings(**settings_kwargs))
|
||||
llm._client = fake_openai # pyright: ignore[reportAttributeAccessIssue]
|
||||
@@ -984,18 +1020,196 @@ def test_chat_empty_choices_raises_llm_error() -> None:
|
||||
|
||||
|
||||
def test_chat_missing_content_raises_llm_error() -> None:
|
||||
"""A silent empty summary must never be stored — None content fails."""
|
||||
llm, _ = _make_chat_client(_FakeCompletion(None))
|
||||
"""A silent empty summary must never be stored — None content fails.
|
||||
Phase 96: pinned with the kill switch (``llm_retries=0``) so the
|
||||
pre-phase-96 single-attempt behavior and message are asserted
|
||||
verbatim (the retry matrix below pins the retried contract)."""
|
||||
llm, _ = _make_chat_client(_FakeCompletion(None), llm_retries=0)
|
||||
with pytest.raises(LLMError, match="empty content"):
|
||||
asyncio.run(llm.chat([{"role": "user", "content": "q"}]))
|
||||
|
||||
|
||||
def test_chat_whitespace_only_content_raises_llm_error() -> None:
|
||||
llm, _ = _make_chat_client(_FakeCompletion(" \n\t "))
|
||||
"""Whitespace-only content is empty (phase 96 kill-switch pin)."""
|
||||
llm, _ = _make_chat_client(_FakeCompletion(" \n\t "), llm_retries=0)
|
||||
with pytest.raises(LLMError, match="empty content"):
|
||||
asyncio.run(llm.chat([{"role": "user", "content": "q"}]))
|
||||
|
||||
|
||||
# ---------- one-shot empty-reply retry (phase 96, task 01) ----------
|
||||
|
||||
_DEFAULT_BASE = "https://aipi.reeseapps.com/v1"
|
||||
|
||||
|
||||
def _empty(finish_reason: str | None = "length") -> _FakeCompletion:
|
||||
"""An incident-shaped empty reply (``content=None``; ``finish_reason``
|
||||
defaults to ``"length"`` — the 2026-09-11 signature)."""
|
||||
return _FakeCompletion(None, finish_reason=finish_reason)
|
||||
|
||||
|
||||
def test_chat_empty_then_success_retries_and_recovers(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""First reply empty (the incident shape), second reply has content →
|
||||
exactly 2 attempts, ONE flat sleep of ``llm_retry_delay`` (default
|
||||
5.0), the trimmed second reply is returned, and ONE warning fired
|
||||
naming the model, the empty reply's ``finish_reason``, and the
|
||||
attempt count."""
|
||||
sleeps = _record_sleeps(monkeypatch)
|
||||
caplog.set_level(logging.WARNING, logger="app.llm")
|
||||
llm, completions = _make_chat_client(
|
||||
completion_seq=[
|
||||
_FakeCompletion(None, finish_reason="length"),
|
||||
_FakeCompletion(" Recovered.\n"),
|
||||
]
|
||||
)
|
||||
out = asyncio.run(llm.chat([{"role": "user", "content": "q"}]))
|
||||
assert out == "Recovered."
|
||||
assert completions.create_calls == 2
|
||||
assert len(completions.chat_calls) == 2
|
||||
# Both attempts are byte-identical (same request).
|
||||
assert completions.chat_calls[0] == completions.chat_calls[1]
|
||||
assert sleeps == [5.0] # one flat BOR_LLM_RETRY_DELAY (default)
|
||||
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
|
||||
assert len(warnings) == 1
|
||||
line = warnings[0].getMessage()
|
||||
assert "lite" in line # the summary model (default)
|
||||
assert "finish_reason=length" in line # the incident signature
|
||||
assert "attempt 1/4" in line # failed attempt 1 of 1 + 3 retries
|
||||
|
||||
|
||||
def test_chat_explicit_model_named_in_the_retry_warning(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""The warning names the model actually requested (an explicit
|
||||
*model* overrides the default)."""
|
||||
_record_sleeps(monkeypatch)
|
||||
caplog.set_level(logging.WARNING, logger="app.llm")
|
||||
llm, _ = _make_chat_client(
|
||||
completion_seq=[_empty(), _FakeCompletion("ok")],
|
||||
llm_summary_model="tiny",
|
||||
)
|
||||
out = asyncio.run(llm.chat([{"role": "user", "content": "q"}], model="special"))
|
||||
assert out == "ok"
|
||||
line = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING][0]
|
||||
assert "model=special" in line
|
||||
assert "tiny" not in line
|
||||
|
||||
|
||||
def test_chat_all_empty_exhausts_after_1_plus_retries_attempts(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Default ``llm_retries=3`` → exactly 4 attempts, 3 sleeps, then
|
||||
``LLMError`` naming the attempts. One empty reply omits
|
||||
``finish_reason`` (provider quirk) — the log line still formats
|
||||
(``finish_reason=None``) and never crashes the diagnostic path."""
|
||||
sleeps = _record_sleeps(monkeypatch)
|
||||
caplog.set_level(logging.WARNING, logger="app.llm")
|
||||
llm, completions = _make_chat_client(
|
||||
completion_seq=[_empty(), _empty(), _empty(None), _empty()]
|
||||
)
|
||||
with pytest.raises(LLMError) as exc:
|
||||
asyncio.run(llm.chat([{"role": "user", "content": "q"}]))
|
||||
assert str(exc.value) == (
|
||||
f"chat completion from {_DEFAULT_BASE} returned empty content on all "
|
||||
"4 attempts — refusing to store a silent summary"
|
||||
)
|
||||
assert completions.create_calls == 4
|
||||
assert sleeps == [5.0, 5.0, 5.0] # no sleep after the last attempt
|
||||
lines = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING]
|
||||
assert "attempt 1/4" in lines[0]
|
||||
assert "attempt 2/4" in lines[1]
|
||||
assert "attempt 3/4" in lines[2]
|
||||
assert "finish_reason=None" in lines[2] # the omitted-finish_reason reply
|
||||
|
||||
|
||||
def test_chat_all_empty_custom_retry_count_names_the_attempts(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""``llm_retries=1`` → exactly 2 attempts, 1 sleep, the exhaustion
|
||||
message names 2."""
|
||||
sleeps = _record_sleeps(monkeypatch)
|
||||
llm, completions = _make_chat_client(
|
||||
completion_seq=[_empty(), _empty()], llm_retries=1, llm_retry_delay=0.5
|
||||
)
|
||||
with pytest.raises(LLMError, match="all 2 attempts"):
|
||||
asyncio.run(llm.chat([{"role": "user", "content": "q"}]))
|
||||
assert completions.create_calls == 2
|
||||
assert sleeps == [0.5]
|
||||
|
||||
|
||||
def test_chat_first_success_never_retries(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Happy path untouched: exactly 1 ``create()`` call, ZERO sleeps,
|
||||
the trimmed content is returned byte-identically."""
|
||||
sleeps = _record_sleeps(monkeypatch)
|
||||
llm, completions = _make_chat_client(_FakeCompletion(" Summary text.\n"))
|
||||
out = asyncio.run(llm.chat([{"role": "user", "content": "q"}]))
|
||||
assert out == "Summary text."
|
||||
assert completions.create_calls == 1
|
||||
assert sleeps == []
|
||||
|
||||
|
||||
def test_chat_no_choices_reply_is_not_retried(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""D2: a choiceless reply raises immediately — 1 attempt, no sleep,
|
||||
no retry (only empty content is the retryable class)."""
|
||||
sleeps = _record_sleeps(monkeypatch)
|
||||
llm, completions = _make_chat_client(
|
||||
_FakeCompletion(None, empty_choices=True), llm_retries=3
|
||||
)
|
||||
with pytest.raises(LLMError, match="no choices"):
|
||||
asyncio.run(llm.chat([{"role": "user", "content": "q"}]))
|
||||
assert completions.create_calls == 1
|
||||
assert sleeps == []
|
||||
|
||||
|
||||
def test_chat_transport_failure_is_not_retried(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""D2: a transport failure raises immediately — 1 attempt, no sleep,
|
||||
no app-level retry (the openai SDK's own ``max_retries=2`` covers
|
||||
wire-level failures)."""
|
||||
sleeps = _record_sleeps(monkeypatch)
|
||||
llm, completions = _make_chat_client(fail=RuntimeError("HTTP 502 Bad Gateway"))
|
||||
with pytest.raises(LLMError, match="HTTP 502"):
|
||||
asyncio.run(llm.chat([{"role": "user", "content": "q"}]))
|
||||
assert completions.create_calls == 1
|
||||
assert completions.chat_calls == [] # the SDK call itself raised
|
||||
assert sleeps == []
|
||||
|
||||
|
||||
def test_chat_zero_retries_raises_legacy_message_byte_identical(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The kill switch (``llm_retries=0``): one attempt, zero sleeps, the
|
||||
PRE-phase-96 message byte-identically (asserted as the exact
|
||||
string, not a pattern)."""
|
||||
sleeps = _record_sleeps(monkeypatch)
|
||||
llm, completions = _make_chat_client(_FakeCompletion(None), llm_retries=0)
|
||||
with pytest.raises(LLMError) as exc:
|
||||
asyncio.run(llm.chat([{"role": "user", "content": "q"}]))
|
||||
assert str(exc.value) == (
|
||||
f"chat completion from {_DEFAULT_BASE} returned empty content — "
|
||||
"refusing to store a silent summary"
|
||||
)
|
||||
assert completions.create_calls == 1
|
||||
assert sleeps == []
|
||||
|
||||
|
||||
def test_chat_retry_delay_is_flat_never_backoff(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The recorded sleeps are the flat ``llm_retry_delay`` each time —
|
||||
never a growing backoff (the phase-67 convention)."""
|
||||
sleeps = _record_sleeps(monkeypatch)
|
||||
llm, _ = _make_chat_client(
|
||||
completion_seq=[_empty(), _empty(), _empty(), _empty()],
|
||||
llm_retries=3,
|
||||
llm_retry_delay=1.25,
|
||||
)
|
||||
with pytest.raises(LLMError, match="all 4 attempts"):
|
||||
asyncio.run(llm.chat([{"role": "user", "content": "q"}]))
|
||||
assert sleeps == [1.25, 1.25, 1.25]
|
||||
|
||||
|
||||
# ---------- chat_stream_retried (phase 67, task 01) ----------
|
||||
|
||||
_RETRY_MSGS: list[dict[str, str]] = [{"role": "user", "content": "q"}]
|
||||
|
||||
@@ -785,11 +785,19 @@ def _patch_sync_seams(
|
||||
monkeypatch.setattr(sync_api, "regenerate_overview", fake_overview)
|
||||
|
||||
async def fake_folder_summaries(
|
||||
db: object, llm: object, *, skip: bool = False
|
||||
db: object,
|
||||
llm: object,
|
||||
*,
|
||||
skip: bool = False,
|
||||
only_missing: bool = False,
|
||||
) -> dict[str, int]:
|
||||
return {"generated": 0, "failed": 0, "pruned": 0}
|
||||
|
||||
monkeypatch.setattr(sync_api, "generate_folder_summaries", fake_folder_summaries)
|
||||
# Phase 96 (task 03): the unchanged-walk gap probe is DB-free in
|
||||
# these state-machine tests — no gap, so the folder step stays
|
||||
# skipped exactly as before the gate change.
|
||||
monkeypatch.setattr(sync_api, "missing_folder_summaries", lambda session: [])
|
||||
|
||||
class _DummySession:
|
||||
def close(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user