Files
turnstone/tests/test_trajectory_model.py
T
Patrick Buckley 70165807c7 fix(reasoning): close the unmarked chain-of-thought leak, gate the tag scan by backend (#940) (#978)
Some serving setups emit model reasoning inline with no think tags and no
reasoning_content at all — nothing any parser can segregate (measured live
on the dev vLLM: 20/20 sampled completions, streamed and not, proxied and
direct). The drain seam correctly passes unmarked prose through, so it
became the artifact on every bounded-artifact lane: workstream titles
("Thinking Process:"), compaction summaries that were ~90% chain-of-
thought, and the web-fetch tool results #940 reports — which then ride
every following turn as context.

Three coordinated changes:

* Utility lanes ask for no reasoning. _utility_completion (title,
  compaction, web-fetch extraction) pins the alias's declared thinking
  toggle off and withholds every reasoning-effort channel — the relayed
  session knob, the lane rung, the definition default, and the graded
  template key — via lane_without_thinking / lane_thinking_suppressed,
  the same suppression omni transcription already used (now shared as
  thinking_off_template_kwargs). Measured end-to-end: the extraction
  that returned 3.7k chars of reasoning returns a 258-char answer.

* server_parses_reasoning capability. A backend that segregates
  reasoning into its own channel declares it, and the inline tag scan
  turns off on every lane: the drain seam, the interactive splitter
  (which now reads the ACTIVE stream's capabilities via the creation-
  time handoff register, never the primary alias's), and the title
  lane's cosmetic peel — so prose that merely quotes a tag can no
  longer be misrouted, and the utility suppression stands down where
  reasoning costs the artifact nothing. The built-in commercial
  capability tables declare it wholesale (known models and table-miss
  defaults); local compat lanes keep the passthrough default the scan
  exists for. Bool-typed capability overrides coerce string spellings
  instead of truthiness-flipping on hand-edited JSON.

* Title selection follows the prompt's contract, not line position:
  the last line within the word cap that ends in a word character —
  rejecting explanation sentences, sign-offs, parentheticals, and
  reasoning headings in any script (terminal punctuation carries
  unspaced scripts where whitespace word counts are meaningless) —
  else the last non-empty line. 20/20 captured live responses title
  correctly (9/20 before, unchanged since well before the seam
  unification: the old and new pipelines scored identically on every
  sample, so the regression source was the backend's output shape,
  not #965).

Also folded in from the review round: a think tag split across a
reasoning-delta boundary reassembles in the drain (partial-tag tail
carry; tool boundaries still flush), Turn.text joins text blocks with a
newline so multi-block answers stop fusing words in notification bodies
and every flattened read, the notify hook reads final_assistant_text
directly instead of through a one-line shim, web-fetch extraction uses
the shared _non_blank_or fallback, and the judge/output-guard suites use
real ModelCapabilities instead of truthy mock attributes.

Closes #940.
2026-08-05 12:58:55 -07:00

83 lines
2.8 KiB
Python

"""Unit tests for the canonical ``Turn`` model (turnstone.core.trajectory)."""
from __future__ import annotations
from turnstone.core.trajectory import (
AttachmentRef,
ProviderNative,
Role,
TextBlock,
ToolCall,
Turn,
TurnMeta,
)
def test_role_values() -> None:
assert {r.value for r in Role} == {"user", "assistant", "tool", "system"}
# Constructible from the wire string (used by the row→Turn adapter).
assert Role("assistant") is Role.ASSISTANT
def test_text_joins_only_textblocks() -> None:
turn = Turn(
Role.USER,
(TextBlock("look at "), AttachmentRef("sha-1", "image"), TextBlock("this")),
)
# Attachment blocks contribute nothing to the FTS/text projection, and
# adjacent text blocks join with a NEWLINE — they are distinct spans,
# and a bare concatenation fused words across block boundaries in
# every flattened read (notification bodies, final_assistant_text).
assert turn.text == "look at \nthis"
def test_user_helper() -> None:
turn = Turn.user("hello")
assert turn.role is Role.USER
assert turn.content == (TextBlock("hello"),)
assert turn.text == "hello"
def test_tool_helper_carries_error_flag() -> None:
ok = Turn.tool("call_1", "result")
err = Turn.tool("call_2", "boom", is_error=True)
assert ok.role is Role.TOOL and ok.tool_call_id == "call_1" and ok.is_error is False
assert err.is_error is True and err.text == "boom"
def test_assistant_with_tool_calls_and_native() -> None:
tc = ToolCall(id="call_1", name="get_weather", arguments='{"city": "Paris"}')
native = ProviderNative(producer="anthropic", blocks=({"type": "thinking"},))
turn = Turn.assistant("on it", tool_calls=(tc,), native=native)
assert turn.role is Role.ASSISTANT
assert turn.tool_calls == (tc,)
assert turn.native is native
assert turn.text == "on it"
def test_assistant_empty_text_has_no_content_block() -> None:
# A tool-only assistant turn carries no TextBlock.
turn = Turn.assistant("", tool_calls=(ToolCall("call_1", "x", "{}"),))
assert turn.content == ()
assert turn.text == ""
def test_system_turn_source_marks_operator_context() -> None:
base = Turn.system("base prompt")
op = Turn.system("output-guard flagged this", source="output_guard")
assert base.source is None # base prompt
assert op.source == "output_guard" # operator-context turn
def test_turnmeta_defaults_are_independent() -> None:
# default_factory must not share a single dict across instances.
a = Turn.user("a")
b = Turn.user("b")
a.meta.extra["k"] = "v"
assert b.meta.extra == {}
assert isinstance(a.meta, TurnMeta)
def test_provider_native_defaults_to_empty_blocks() -> None:
assert ProviderNative(producer="google").blocks == ()