Phase 192 — final verification pass complete. - **Verified (all 4 tasks already complete):** additive `verbatim_block` SSE frame (`VerbatimBlockPiece` → `ChatVerbatimBlockEvent` → chat dispatch, after the round's `tool`/`tool_result`, before the next round); `hydrateVerbatimNow` matched-only mid-turn pass + app.js branch; `dropPlacedTwin` per-locator done/restore dedupe; the dedicated E2E suite. - **Root cause of the rejected attempt:** my own concurrent background `pytest` run deadlocked the harness validation run on the shared Postgres (`psycopg.errors.DeadlockDetected`). No code defect — re-ran every gate **exclusively**. - `uv run pytest -q --cov=app --cov-report=term-missing` → **exit 0**, `TOTAL 7181 16 99%` (>90%) - `uv run pytest tests/e2e/test_verbatim_inline_midturn.py --no-cov` → **6 passed** (isolated) - `uv run pytest tests/e2e/test_verbatim_show_text.py tests/e2e/test_chat_history.py --no-cov` → **18 passed** (`test_chat_history.py` byte-unedited; the phase-182 suite's "no eighth type" pin was tightened, not loosened, to seven + `verbatim_block` with frame-order asserts) - `uv run ruff check .` → All checks passed (exit 0) · `uv run pyright` → **0 errors** - **Completion criteria:** single frame per success + zero per refusal ✅ · `BOR_ANSWER_TEXT=0` wire byte-identical (integration spelled baseline + E2E App B) ✅ · fence hydrates in place / unfenced lands at the call moment, before `done` ✅ · append+fence → 1 panel, two ranges → 2 panels ✅ · restore 1 panel/locator, `done.answer_text` + record key + `verbatim_blocks=N` unchanged ✅ · suite green, coverage 99%, lint/types clean ✅ · no-show_text turn byte-identical ✅ · commit → left to the harness (no git index/history commands run). - **Docs:** README reviewed line-by-line (2399 lines) + AGENTS.md in full; mechanically verified all 86 `BOR_*` settings, every `.env.example` var, table defaults, internal anchors (0 broken), ports 8123/8901, tree depth 1–8, pagination 25/1–100, 17 palette colors, `0029`/`0030` migrations, and every cited test file. Fixed 1 inconsistency: the verbatim "Parity" bullet claimed the live turn/reload/shared page share "the same three globals" — now names the settle-path trio and states that `hydrateVerbatimNow` is the ONE chat-only addition (a shared page has no stream). - **Notable:** `run_agent`'s four repeated piece unions were collapsed into the ONE exported `AgentPiece` alias, so a future piece type is spelled in one place; `test_frontend_edit_message.py`'s `runTurn` byte-pin was regenerated, not weakened. - **Next pending phase:** `193_sync_remaining_count`.
321 lines
11 KiB
Python
321 lines
11 KiB
Python
"""Unit: short-lived DB sessions in the agent loop (SEC-14-04).
|
|
|
|
Verifies that ``run_agent`` uses a session factory to create a new
|
|
short-lived session for each DB operation (tool call), closes it after
|
|
the tool result is produced, and completes the agent loop correctly.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import uuid
|
|
from collections.abc import AsyncIterator, Callable
|
|
from copy import deepcopy
|
|
from datetime import UTC, datetime
|
|
from typing import Any, cast
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.config import Settings
|
|
from app.models import Document
|
|
from app.rag import agent
|
|
from app.rag.agent import AgentHolder, AgentPiece, run_agent
|
|
from app.rag.llm import LLMClient, StreamPiece, ToolCallPiece
|
|
|
|
#: Fixture document creation date (phase 106, D5).
|
|
_FIXTURE_CREATED_AT = datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC)
|
|
|
|
|
|
def _settings(**kwargs: Any) -> Settings:
|
|
kwargs.setdefault("_env_file", None)
|
|
return Settings(**kwargs) # pyright: ignore[reportCallIssue]
|
|
|
|
|
|
def _doc(source: str, path: str, title: str = "Title", content: str = "CONTENT") -> Document:
|
|
return Document(
|
|
id=uuid.uuid4(),
|
|
source=source,
|
|
path=path,
|
|
full_path=f"/tmp/{path}",
|
|
title=title,
|
|
content=content,
|
|
content_hash="0" * 64,
|
|
created_at=_FIXTURE_CREATED_AT,
|
|
)
|
|
|
|
|
|
class TrackingSession:
|
|
"""A mock Session that tracks ``close()`` calls and is usable as a
|
|
context manager."""
|
|
|
|
def __init__(self, close_count: list[int] | None = None) -> None:
|
|
self.close_count = close_count if close_count is not None else [0]
|
|
self.scalar = MagicMock(return_value=None)
|
|
# scalars() returns a ScalarResult-like object with .all()
|
|
self._scalar_result = MagicMock()
|
|
self._scalar_result.all = MagicMock(return_value=[])
|
|
self.scalars = MagicMock(return_value=self._scalar_result)
|
|
self.execute = MagicMock(return_value=MagicMock(scalars=MagicMock(return_value=[])))
|
|
self.add = MagicMock()
|
|
|
|
def __enter__(self) -> TrackingSession:
|
|
return self
|
|
|
|
def __exit__(self, *args: Any) -> None:
|
|
self.close_count[0] += 1
|
|
|
|
|
|
class TrackingFactory:
|
|
"""A session factory that creates a ``TrackingSession`` each time
|
|
it is called, so the tests can verify that a new session is created
|
|
for each tool call and that it is closed afterwards."""
|
|
|
|
def __init__(self) -> None:
|
|
self.sessions: list[TrackingSession] = []
|
|
|
|
def __call__(self) -> TrackingSession:
|
|
session = TrackingSession()
|
|
self.sessions.append(session)
|
|
return session
|
|
|
|
|
|
# Type alias for pyright: TrackingFactory is callable that returns Session
|
|
TrackingFactoryCallable: type[TrackingFactory] = TrackingFactory # noqa: N816
|
|
|
|
|
|
class ScriptedLLM:
|
|
"""Canned stream sequences for agent-loop tests."""
|
|
|
|
def __init__(self, *streams: list[StreamPiece | ToolCallPiece]) -> None:
|
|
self.streams: list[list[StreamPiece | ToolCallPiece]] = list(streams)
|
|
self.requests: list[tuple[list[dict[str, Any]], list[dict[str, Any]] | None]] = []
|
|
|
|
async def chat_stream(
|
|
self,
|
|
messages: list[dict[str, str]],
|
|
tools: list[dict[str, Any]] | None = None,
|
|
scaffolding: Any = None,
|
|
) -> AsyncIterator[StreamPiece | ToolCallPiece]:
|
|
self.requests.append((deepcopy(messages), tools))
|
|
if not self.streams:
|
|
raise AssertionError("ScriptedLLM ran out of canned streams")
|
|
pieces = self.streams.pop(0)
|
|
for piece in pieces:
|
|
yield piece
|
|
|
|
|
|
class ScriptedToolLLM(ScriptedLLM):
|
|
"""A scripted LLM that returns exactly one tool call, then an
|
|
empty clean answer on the next round."""
|
|
|
|
def __init__(
|
|
self,
|
|
tool_call: ToolCallPiece,
|
|
answer: str = "ANSWER",
|
|
) -> None:
|
|
super().__init__(
|
|
[tool_call], # round 1: tool call
|
|
[StreamPiece("content", answer)], # round 2: answer
|
|
)
|
|
|
|
|
|
async def _consume(
|
|
llm: LLMClient,
|
|
db_factory: Callable[[], Session],
|
|
holder: AgentHolder,
|
|
settings: Settings,
|
|
seed_docs: list[Document] | None = None,
|
|
) -> list[AgentPiece]:
|
|
"""Consume one ``run_agent`` turn."""
|
|
out: list[AgentPiece] = []
|
|
async for piece in run_agent(
|
|
cast("LLMClient", llm),
|
|
db_factory,
|
|
system_prompt="SYSTEM_PROMPT",
|
|
user_message="QUESTION",
|
|
seed_docs=seed_docs or [],
|
|
settings=settings,
|
|
holder=holder,
|
|
):
|
|
out.append(piece)
|
|
return out
|
|
|
|
|
|
# ---------- db_factory is called per DB operation ----------
|
|
|
|
|
|
def test_db_factory_called_once_for_answer_no_tools() -> None:
|
|
"""When the model answers without calling any tools, the agent
|
|
loop makes no DB calls — but ``run_agent`` still accepts the
|
|
factory (it is simply not invoked)."""
|
|
llm = ScriptedLLM([StreamPiece("content", "DIRECT ANSWER")])
|
|
factory = TrackingFactory()
|
|
holder = AgentHolder()
|
|
settings = _settings(agent_max_rounds=10)
|
|
|
|
asyncio.run(
|
|
_consume(cast("LLMClient", llm), cast("Callable[[], Session]", factory), holder, settings)
|
|
)
|
|
|
|
# No tool calls means no DB operations — factory never invoked
|
|
assert len(factory.sessions) == 0
|
|
assert holder.tool_calls == 0
|
|
|
|
|
|
# ---------- each tool call creates a new session ----------
|
|
|
|
|
|
def test_each_tool_call_creates_a_new_session() -> None:
|
|
"""Each tool call the model emits creates its own short-lived
|
|
session via the factory; sessions are closed after the tool
|
|
result is produced."""
|
|
tool_call = ToolCallPiece(id="call_1", name="ls", arguments={})
|
|
llm = ScriptedToolLLM(tool_call)
|
|
factory = TrackingFactory()
|
|
holder = AgentHolder()
|
|
settings = _settings(agent_max_rounds=10)
|
|
|
|
asyncio.run(
|
|
_consume(cast("LLMClient", llm), cast("Callable[[], Session]", factory), holder, settings)
|
|
)
|
|
|
|
# One tool call → one session created
|
|
assert len(factory.sessions) == 1
|
|
# The session was closed after the tool result
|
|
assert factory.sessions[0].close_count[0] == 1
|
|
# The holder records the executed call
|
|
assert holder.tool_calls == 1
|
|
|
|
|
|
def test_multiple_tool_calls_create_separate_sessions() -> None:
|
|
"""When the model makes multiple tool calls across rounds, each
|
|
round creates a new session that is closed after the result."""
|
|
tool_call_1 = ToolCallPiece(id="call_1", name="ls", arguments={})
|
|
tool_call_2 = ToolCallPiece(id="call_2", name="ls", arguments={})
|
|
llm = ScriptedToolLLM(tool_call_1)
|
|
# Override the second round to also emit a tool call
|
|
llm.streams = [
|
|
[tool_call_1], # round 1: ls
|
|
[tool_call_2], # round 2: ls (another listing)
|
|
[StreamPiece("content", "FINAL ANSWER")], # round 3: answer
|
|
]
|
|
factory = TrackingFactory()
|
|
holder = AgentHolder()
|
|
settings = _settings(agent_max_rounds=10)
|
|
|
|
asyncio.run(
|
|
_consume(cast("LLMClient", llm), cast("Callable[[], Session]", factory), holder, settings)
|
|
)
|
|
|
|
# Two tool calls → two sessions created and closed
|
|
assert len(factory.sessions) == 2
|
|
for session in factory.sessions:
|
|
assert session.close_count[0] == 1
|
|
assert holder.tool_calls == 2
|
|
|
|
|
|
# ---------- sessions are closed after use (no pinning) ----------
|
|
|
|
|
|
def test_sessions_closed_after_tool_result() -> None:
|
|
"""Verify that the session is closed AFTER the tool result is
|
|
produced but BEFORE the next model round — no session is held
|
|
across rounds."""
|
|
tool_call = ToolCallPiece(id="call_1", name="ls", arguments={})
|
|
llm = ScriptedToolLLM(tool_call)
|
|
factory = TrackingFactory()
|
|
holder = AgentHolder()
|
|
settings = _settings(agent_max_rounds=10)
|
|
|
|
asyncio.run(
|
|
_consume(cast("LLMClient", llm), cast("Callable[[], Session]", factory), holder, settings)
|
|
)
|
|
|
|
# The session was closed (close_count incremented)
|
|
assert factory.sessions[0].close_count[0] == 1
|
|
# Only one session was created (not reused across rounds)
|
|
assert len(factory.sessions) == 1
|
|
|
|
|
|
# ---------- deflected path works without DB factory usage ----------
|
|
|
|
|
|
def test_deflected_path_no_db_factory_calls() -> None:
|
|
"""A deflected turn (LOW mode) does not run the agent loop, so
|
|
the session factory is never invoked."""
|
|
llm = ScriptedLLM([StreamPiece("content", "I don't know about that.")])
|
|
factory = TrackingFactory()
|
|
holder = AgentHolder()
|
|
# agent_max_rounds=0 disables tools → single tools=None request
|
|
settings = _settings(agent_max_rounds=0)
|
|
|
|
asyncio.run(
|
|
_consume(cast("LLMClient", llm), cast("Callable[[], Session]", factory), holder, settings)
|
|
)
|
|
|
|
assert len(factory.sessions) == 0
|
|
assert holder.tool_calls == 0
|
|
|
|
|
|
# ---------- agent loop completes correctly ----------
|
|
|
|
|
|
def test_agent_loop_completes_with_mock_factory() -> None:
|
|
"""The agent loop completes correctly with a mock session factory:
|
|
tool calls are executed, the answer is streamed, and the holder
|
|
records the correct state."""
|
|
tool_call = ToolCallPiece(id="call_1", name="grep", arguments={"pattern": "hello"})
|
|
llm = ScriptedToolLLM(tool_call)
|
|
factory = TrackingFactory()
|
|
holder = AgentHolder()
|
|
settings = _settings(agent_max_rounds=10)
|
|
|
|
pieces = asyncio.run(
|
|
_consume(cast("LLMClient", llm), cast("Callable[[], Session]", factory), holder, settings)
|
|
)
|
|
|
|
# The stream contains the tool call piece and the answer piece
|
|
types = [getattr(p, "kind", "tool") for p in pieces]
|
|
assert "tool" in types
|
|
assert "content" in types
|
|
|
|
# The holder records one executed call
|
|
assert holder.tool_calls == 1
|
|
assert len(factory.sessions) == 1
|
|
|
|
|
|
# ---------- monkeypatched DB accessors work with factory ----------
|
|
|
|
|
|
def test_monkeypatched_accessors_with_factory() -> None:
|
|
"""DB accessors that are monkeypatched (as in the existing unit
|
|
test suite) work correctly when the agent loop calls them through
|
|
a session factory."""
|
|
# Patch ls_top to return a canned result
|
|
canned_ls = [("TestSource", 5, None)]
|
|
|
|
def fake_ls_top(db: Session) -> list[tuple[str, int, str | None]]: # type: ignore[return-value]
|
|
return list(canned_ls)
|
|
|
|
with (
|
|
patch.object(agent, "ls_top", fake_ls_top),
|
|
patch.object(agent, "list_source_names", return_value=["TestSource"]),
|
|
):
|
|
tool_call = ToolCallPiece(id="call_1", name="ls", arguments={})
|
|
llm = ScriptedToolLLM(tool_call)
|
|
factory = TrackingFactory()
|
|
holder = AgentHolder()
|
|
settings = _settings(agent_max_rounds=10)
|
|
|
|
asyncio.run(
|
|
_consume(cast("LLMClient", llm), cast("Callable[[], Session]", factory), holder, settings)
|
|
)
|
|
|
|
assert holder.tool_calls == 1
|
|
assert len(factory.sessions) == 1
|
|
# The factory was called exactly once for this tool round
|
|
assert factory.sessions[0].close_count[0] == 1
|
|
|
|
|
|
|