SurfSense/surfsense_backend/tests/unit/capabilities/test_run_truncation.py
DESKTOP-RTLN3BA\$punk b6e378b070 feat(runs): introduce Run and ToolOutputSpill models for logging scraper invocations
- Added `Run` model to track scraper invocations, including metadata such as status, input, and output.
- Implemented `ToolOutputSpill` model to store context-editing spills separately from user-facing logs.
- Updated middleware to handle spill placeholders and integrate with the new models.
- Enhanced REST API to record runs and expose run history through new endpoints.
- Adjusted tests to validate the new run logging functionality and ensure proper integration with existing capabilities.
2026-07-04 22:55:55 -07:00

191 lines
5.5 KiB
Python

"""Tool-boundary truncation + run read-tool behavior (no DB).
Covers the pure pieces of the DB-backed run log: JSONL serialization, the
char-budgeted preview (including the single-oversized-item case and the
storage-failure degrade), and the ``read_run``/``search_run`` tools' paging,
search, ReDoS fallback, and workspace scoping — all with a fake session so the
unit suite never touches a database.
"""
from __future__ import annotations
import contextlib
import json
import pytest
from pydantic import BaseModel
from app.agents.chat.multi_agent_chat.subagents.shared import run_reader
from app.capabilities.core.access.agent import _build_preview
from app.capabilities.core.runs import (
RUN_OUTPUT_CHAR_CAP,
SerializedOutput,
serialize_output,
)
pytestmark = pytest.mark.unit
class _Item(BaseModel):
id: int
name: str
note: str | None = None
class _Output(BaseModel):
items: list[_Item]
class _Scalar(BaseModel):
value: str
def test_serialize_output_is_jsonl_and_excludes_none():
out = _Output(items=[_Item(id=1, name="a"), _Item(id=2, name="b", note="x")])
result = serialize_output(out)
lines = result.text.split("\n")
assert result.item_count == 2
assert len(lines) == 2
# exclude_none: the first item has no "note" key
assert "note" not in json.loads(lines[0])
assert json.loads(lines[1])["note"] == "x"
assert result.char_count == len(result.text)
def test_serialize_output_without_items_is_single_line():
result = serialize_output(_Scalar(value="hi"))
assert result.item_count == 1
assert json.loads(result.text) == {"value": "hi"}
def test_preview_is_char_budgeted_and_references_run():
# Many small items whose total blows the cap.
per_item = "y" * 500
items = [f'{{"i": {i}, "v": "{per_item}"}}' for i in range(500)]
body = "\n".join(items)
serialized = SerializedOutput(text=body, item_count=len(items), char_count=len(body))
preview = _build_preview(serialized, run_id="abc")
assert len(preview) < serialized.char_count
assert "run_abc" in preview
assert "read_run" in preview
# Only a prefix of items is shown.
assert preview.count('"i":') < len(items)
def test_preview_handles_single_oversized_item():
huge = "z" * (RUN_OUTPUT_CHAR_CAP * 2)
serialized = SerializedOutput(text=huge, item_count=1, char_count=len(huge))
preview = _build_preview(serialized, run_id="big")
# Still returns a clipped head rather than nothing.
assert "z" in preview
assert "run_big" in preview
assert len(preview) < serialized.char_count
def test_preview_degrades_when_storage_failed():
body = "\n".join(f'{{"i": {i}}}' for i in range(200))
serialized = SerializedOutput(text=body, item_count=200, char_count=len(body))
preview = _build_preview(serialized, run_id=None)
assert "storage error" in preview
assert "run_" not in preview
# --- read tools -----------------------------------------------------------
class _FakeResult:
def __init__(self, value):
self._value = value
def scalar_one_or_none(self):
return self._value
class _FakeSession:
def __init__(self, value, calls):
self._value = value
self._calls = calls
async def execute(self, stmt):
self._calls.append(str(stmt))
return _FakeResult(self._value)
def _patch_session(monkeypatch, value, calls):
@contextlib.asynccontextmanager
async def _maker():
yield _FakeSession(value, calls)
monkeypatch.setattr(run_reader, "shielded_async_session", _maker)
def _tools():
read_run, search_run = run_reader.build_run_reader_tools(workspace_id=7)
return read_run, search_run
_BODY = "\n".join(f'{{"i": {i}, "name": "item_{i}"}}' for i in range(10))
@pytest.mark.asyncio
async def test_read_run_paginates(monkeypatch):
calls: list[str] = []
_patch_session(monkeypatch, _BODY, calls)
read_run, _ = _tools()
out = await read_run.ainvoke(
{"ref": "run_" + "0" * 8 + "-0000-0000-0000-000000000000",
"offset": 2, "limit": 3}
)
assert "item_2" in out and "item_3" in out and "item_4" in out
assert "item_0" not in out and "item_5" not in out
# Scoped by workspace_id in the query.
assert "workspace_id" in calls[0]
@pytest.mark.asyncio
async def test_read_run_rejects_bad_ref(monkeypatch):
_patch_session(monkeypatch, _BODY, [])
read_run, _ = _tools()
out = await read_run.ainvoke({"ref": "not-a-ref"})
assert "not a valid run reference" in out
@pytest.mark.asyncio
async def test_read_run_not_found(monkeypatch):
_patch_session(monkeypatch, None, [])
read_run, _ = _tools()
out = await read_run.ainvoke(
{"ref": "run_" + "0" * 8 + "-0000-0000-0000-000000000000"}
)
assert "not found" in out
@pytest.mark.asyncio
async def test_search_run_matches(monkeypatch):
_patch_session(monkeypatch, _BODY, [])
_, search_run = _tools()
out = await search_run.ainvoke(
{"ref": "spill_" + "0" * 8 + "-0000-0000-0000-000000000000",
"pattern": "item_7"}
)
assert "item_7" in out
assert "item_1" not in out.split("item_7")[0]
@pytest.mark.asyncio
async def test_search_run_falls_back_on_bad_regex(monkeypatch):
_patch_session(monkeypatch, _BODY, [])
_, search_run = _tools()
# "(" is an invalid regex -> substring fallback, must not raise.
out = await search_run.ainvoke(
{"ref": "run_" + "0" * 8 + "-0000-0000-0000-000000000000", "pattern": "("}
)
assert "matched" in out or "No lines" in out