mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
bc3fa60011
Passthrough servers (parserless vLLM/llama.cpp, LM Studio, bare gateways) emit reasoning as literal <think>/<reasoning> blocks inside content, and only three of nine drained lanes stripped them: web_fetch tool results persisted raw think blocks into every following turn (#940), judge verdicts parsed through tag noise, and a draft verdict inside a think block could shadow the real one at the output guard. One rule at the seam now. drain_stream accumulates content in RUNS bounded by interleaving signals (provider-parsed reasoning deltas, tool-call deltas) with the interactive consumer's within-chunk ordering — reasoning, then content, then the tool-call close — and splits each run through split_inline_reasoning, the one-shot form of the interactive lane's ThinkTagSplitter: a pure raw split, exactly equivalent to the streaming form on every catalog case. One trim policy exists and the drain owns it: blank edge lines are trimmed once over the joined runs when a tag was consumed, so tag residue dies at the edges while genuine inter-run paragraph separators survive. Extracted text is appended to result.reasoning after any server-parsed reasoning with a blank-line boundary and rides the native lane as the reasoning_text synth block. Orphan CLOSE tags deliberately pass through byte-identical: a close whose open never arrived is indistinguishable from prose QUOTING the tag, and drained lanes routinely quote third-party text — reclassifying would let a malicious page containing the literal tag destroy the extraction that cites it. The title lane keeps a local rfind peel as display-string formatting. The citations footer folds only onto non-blank content — sourcing for an answer that does not exist is dropped rather than handed to emptiness checks as a footer-only "answer". Every private strip is deleted: the title lane's strip, the summarizer strip, _strip_reasoning itself, and the optimizer's five regexes (_strip_markdown_fence is now the one fence rule, applied to normalized model output only, never to or-fallback values). Think-only and whitespace-only responses drain to blank content, and every lane's no-answer fallback gates on blankness: web_fetch returns an honest extraction-error card, the intent judge takes the empty-retry ladder, the task-agent synthesis reports "(no output)", and the optimizer keeps the current observer system and prompt verbatim on no-answer passes. Final-say reads (optimizer analyst, eval final_content, the notify hook) use trajectory.final_assistant_text — the last assistant turn only, never an earlier narration presented as the conclusion — while last_assistant_text is the salvage walk (task_agent partial-work recovery), skipping tool-call-only, all-reasoning, and whitespace-only turns. Perception memoizes every completed description immediately, including an empty one — one perceive per key, ever — under a commit-lock guard so an empty result never overwrites a concurrently memoized real description; an all-reasoning perception model pins the placeholder until restart, and the remediation is server-side (a reasoning parser or the template thinking toggle on the perception alias). A true double-reasoning shape (inline-extracted text alongside a native reasoning block) logs chars-only at the drain, where it is distinguishable from the routine reasoning_delta mirror. The dialect's semantics are pinned as one table (tests/_reasoning_dialect.py) driven through shared fixtures (think_tag_stream, seam_provider): one-shot conformance, the exact one-shot/streaming equivalence property, the drain seam rules including quoted-tag safety, run-boundary and separator-preservation pins, per-lane pins for all nine lanes, and the empty-content assistant wire shape. Closes #965. Closes #940.
217 lines
7.4 KiB
Python
217 lines
7.4 KiB
Python
"""Unit tests for the perception wire-fallback (turnstone/core/perception.py)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import pytest
|
|
|
|
from tests._session_helpers import as_stream, mock_completion_result
|
|
from turnstone.core import perception
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
|
|
|
|
class _StubProvider:
|
|
"""Minimal LLMProvider stand-in: counts calls, can fail the first N.
|
|
|
|
``describe`` routes through ``model_turn``, so the stub carries the lane
|
|
surface (``provider_name``, ``get_capabilities``) and returns a full
|
|
``CompletionResult`` shape, and it records the ``resolve_attachments``
|
|
callback the translator would use to materialize the by-reference parts.
|
|
"""
|
|
|
|
provider_name = "openai-compatible"
|
|
|
|
def __init__(self, *, content: str = "a description", fail_times: int = 0) -> None:
|
|
self.calls = 0
|
|
self._content = content
|
|
self._fail_times = fail_times
|
|
self.last_messages: list[dict[str, Any]] | None = None
|
|
self.last_resolve: Any = None
|
|
|
|
def get_capabilities(self, model: str) -> Any:
|
|
from turnstone.core.providers._protocol import ModelCapabilities
|
|
|
|
return ModelCapabilities()
|
|
|
|
def create_streaming(
|
|
self,
|
|
*,
|
|
client: Any,
|
|
model: str,
|
|
messages: list[dict[str, Any]],
|
|
resolve_attachments: Any = None,
|
|
**_: Any,
|
|
) -> Any:
|
|
self.calls += 1
|
|
self.last_messages = messages
|
|
self.last_resolve = resolve_attachments
|
|
if self.calls <= self._fail_times:
|
|
raise RuntimeError("backend down")
|
|
# Shared field inventory: when model_turn's re-ingest reads a new
|
|
# CompletionResult field, mock_completion_result is the ONE
|
|
# definition to extend and this suite moves with it.
|
|
return as_stream(mock_completion_result(self._content))
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_cache() -> Iterator[None]:
|
|
perception._clear_perception_cache_for_test()
|
|
yield
|
|
perception._clear_perception_cache_for_test()
|
|
|
|
|
|
def _parts() -> list[dict[str, Any]]:
|
|
return [{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}]
|
|
|
|
|
|
def test_describe_lowers_prompt_then_by_reference_parts() -> None:
|
|
prov = _StubProvider(content="desc")
|
|
out = perception.describe(provider=prov, client=object(), model="m", parts=_parts()) # type: ignore[arg-type]
|
|
assert out == "desc"
|
|
assert prov.last_messages is not None
|
|
content = prov.last_messages[0]["content"]
|
|
assert content[0]["type"] == "text" # prompt leads
|
|
# The attachment rides by reference; the translator materializes it via
|
|
# the threaded resolver, which must return the prebuilt parts verbatim.
|
|
assert content[1]["attachment_id"] == "perception-input"
|
|
assert prov.last_resolve is not None
|
|
assert prov.last_resolve(["perception-input"]) == {"perception-input": _parts()}
|
|
|
|
|
|
def test_describe_empty_parts_skips_backend() -> None:
|
|
prov = _StubProvider()
|
|
assert perception.describe(provider=prov, client=object(), model="m", parts=[]) == "" # type: ignore[arg-type]
|
|
assert prov.calls == 0
|
|
|
|
|
|
def test_describe_cached_memoizes_by_principal_alias_and_hash() -> None:
|
|
prov = _StubProvider(content="desc")
|
|
kw: dict[str, Any] = {
|
|
"provider": prov,
|
|
"client": object(),
|
|
"model": "m",
|
|
"principal_id": "user-a",
|
|
"alias": "omni",
|
|
"content_hash": "h1",
|
|
"parts": _parts(),
|
|
}
|
|
assert perception.describe_cached(**kw) == "desc"
|
|
assert perception.describe_cached(**kw) == "desc"
|
|
assert prov.calls == 1 # second served from cache
|
|
perception.describe_cached(**{**kw, "content_hash": "h2"})
|
|
assert prov.calls == 2 # distinct hash → fresh perceive
|
|
perception.describe_cached(**{**kw, "principal_id": "user-b"})
|
|
assert prov.calls == 3 # same content under another user's grant → fresh perceive
|
|
|
|
|
|
def test_describe_cached_does_not_cache_failures() -> None:
|
|
prov = _StubProvider(content="recovered", fail_times=1)
|
|
kw: dict[str, Any] = {
|
|
"provider": prov,
|
|
"client": object(),
|
|
"model": "m",
|
|
"principal_id": "user-a",
|
|
"alias": "omni",
|
|
"content_hash": "h",
|
|
"parts": _parts(),
|
|
}
|
|
assert perception.describe_cached(**kw) == "" # backend down → "" (uncached)
|
|
assert perception.describe_cached(**kw) == "recovered" # retried, succeeds
|
|
assert prov.calls == 2
|
|
|
|
|
|
def test_describe_peek_returns_none_when_absent() -> None:
|
|
assert (
|
|
perception.describe_peek(
|
|
principal_id="user-a",
|
|
alias="omni",
|
|
content_hash="missing",
|
|
)
|
|
is None
|
|
)
|
|
|
|
|
|
def test_describe_peek_returns_cached_without_recompute() -> None:
|
|
prov = _StubProvider(content="desc")
|
|
kw: dict[str, Any] = {
|
|
"provider": prov,
|
|
"client": object(),
|
|
"model": "m",
|
|
"principal_id": "user-a",
|
|
"alias": "omni",
|
|
"content_hash": "h",
|
|
"parts": _parts(),
|
|
}
|
|
perception.describe_cached(**kw) # populate the memo
|
|
assert prov.calls == 1
|
|
# Peek serves the memoized text and never re-invokes the backend — this is
|
|
# what lets the wire resolver skip the PDF rasterize on a cross-send hit.
|
|
assert (
|
|
perception.describe_peek(
|
|
principal_id="user-a",
|
|
alias="omni",
|
|
content_hash="h",
|
|
)
|
|
== "desc"
|
|
)
|
|
assert (
|
|
perception.describe_peek(
|
|
principal_id="user-b",
|
|
alias="omni",
|
|
content_hash="h",
|
|
)
|
|
is None
|
|
)
|
|
assert prov.calls == 1
|
|
|
|
|
|
def test_describe_cached_memoizes_empty_descriptions() -> None:
|
|
# A completed-but-empty description (an all-reasoning pass) memoizes
|
|
# like any other result: one perceive per key, ever — bounded cost.
|
|
# The pin-until-restart residual is deliberate; the remediation is
|
|
# server-side (reasoning parser / template thinking toggle).
|
|
prov = _StubProvider(content="")
|
|
kw: dict[str, Any] = {
|
|
"provider": prov,
|
|
"client": object(),
|
|
"model": "m",
|
|
"principal_id": "user-a",
|
|
"alias": "omni",
|
|
"content_hash": "h-empty",
|
|
"parts": _parts(),
|
|
}
|
|
assert perception.describe_cached(**kw) == ""
|
|
assert perception.describe_cached(**kw) == ""
|
|
assert prov.calls == 1 # second call served from the memo
|
|
assert (
|
|
perception.describe_peek(principal_id="user-a", alias="omni", content_hash="h-empty") == ""
|
|
)
|
|
|
|
|
|
def test_racing_empty_result_never_clobbers_memoized_real_description(monkeypatch) -> None:
|
|
# The describe call runs unlocked: a racer can memoize a REAL
|
|
# description while another call is producing "". The empty commit
|
|
# must yield to the existing memo, never overwrite it.
|
|
key_kwargs = {"principal_id": "user-a", "alias": "omni", "content_hash": "h-race"}
|
|
|
|
def _racing_describe(**_kw: Any) -> str:
|
|
with perception._cache_lock:
|
|
perception._cache[perception._cache_key(**key_kwargs)] = "real from racer"
|
|
return ""
|
|
|
|
monkeypatch.setattr(perception, "describe", _racing_describe)
|
|
out = perception.describe_cached(
|
|
provider=_StubProvider(content=""),
|
|
client=object(),
|
|
model="m",
|
|
parts=_parts(),
|
|
**key_kwargs,
|
|
)
|
|
assert out == "real from racer"
|
|
assert (
|
|
perception.describe_peek(**key_kwargs) == "real from racer"
|
|
) # the billed real description survived
|