The guard checked whether the installed SDK satisfied the pin. Nobody had ever checked whether anyone had READ it. Those are different questions, and the gap between them was a whole version range: pinned >=0.2.111,<0.3, premises source-verified through 0.2.110, installed 0.2.120. Every build in between was admissible and unexamined — `uv sync --upgrade` would have kept 806 tests green on an SDK no one had opened. Written red first: a guard handed 0.2.140 returned it without complaint. _VERIFIED_THROUGH is the ratchet. It records the newest build actually read at source, and a newer one fails naming the five premises to re-check. The pin is untouched and was never the defect — measurement dissolved the premise that it needed lifting. It was not too narrow but too wide, and a wider permission is not repaired by widening it further. The premises themselves were prose the failure message recited. Nothing tested them, so one that stopped being true would have surfaced on the one live paid run (S10, D6). They are now a table introspected against the installed package, with the printed prose derived from that same table so a checked attribute cannot go unreported or a reported one unchecked. The premise introspection structurally cannot see — that query() yields an AssistantMessage then a closing ResultMessage — is named apart, and is the honest reason the human reading still has to happen. Value-proved, not merely named: disabling the ratchet reds 1 test, stubbing the inventory to "no gaps" reds 3, re-hardcoding the prose reds 1, and lowering _VERIFIED_THROUGH below the installed build reds the real installed-version test rather than only a monkeypatched one. 0.2.139 read at source (0.2.120 -> 0.2.139, latest on PyPI today; STATE said 0.2.134, measured 08-09 and stale). The public query.py is byte-identical, every premise field keeps its type and default, and the parser changes are additive. One needed a look: 0.2.139 added a skills path defaulting setting_sources to ["user", "project"], which would have undone the S10 isolation fix — it fires only on None, so the explicit [] is out of reach. Prose carrying stale version claims moved with the reading, never ahead of it: each was re-verified at 0.2.139 before being restated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014dKDjVG7qrBh9NkAAxutqN
371 lines
18 KiB
Python
371 lines
18 KiB
Python
"""SDK version guard (C2.5, closes C-N3) — LOAD-BEARING (§11).
|
||
|
||
The seam this file keeps alive: every SDK attribute premise in
|
||
``sdk_client.py`` rests on a version whose source was actually READ.
|
||
|
||
Three questions hide inside "is the SDK fine?", and conflating them is what
|
||
let a whole version range go unexamined (measured 2026-08-09: installed
|
||
0.2.120, pin ``>=0.2.111,<0.3``, premises source-verified only through
|
||
0.2.110 — so 0.2.111–0.2.999 satisfied the pin while nobody had read them):
|
||
|
||
(a) is the build inside the PIN? — what ``uv`` is allowed to resolve
|
||
(b) is the build inside the VERIFIED? — what a human has actually read
|
||
(c) is something newer upstream? — REQUIRES NETWORK, never in pytest
|
||
|
||
A pin is a permission, not a proof, and it can reach further than the reading
|
||
that justified it. ``_VERIFIED_THROUGH`` is the ratchet for (b): a build newer
|
||
than the last one read at source goes RED naming the premises to re-verify,
|
||
even though the pin admits it. Question (c) is deliberately absent — an
|
||
offline suite cannot answer it, and a test that reached the network to try
|
||
would trade a silent gap for a flaky one.
|
||
|
||
Two instruments, because they fail differently: ``TestTheSdkSurfaceInventory``
|
||
introspects the INSTALLED package, so a premise naming an attribute that no
|
||
longer exists goes red on ANY build — but introspection only sees the names it
|
||
was told to look for, and cannot see a field whose MEANING changed underneath a
|
||
stable name. ``_VERIFIED_THROUGH`` covers exactly that blind spot by demanding
|
||
a human read. Neither subsumes the other.
|
||
|
||
Offline-safe: reads installed package metadata and already-imported classes —
|
||
no key, no network.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import dataclasses
|
||
import importlib.metadata
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
_PYPROJECT = Path(__file__).resolve().parents[1] / "pyproject.toml"
|
||
|
||
# The verified range — MUST match the pyproject pin (bound below).
|
||
_VERIFIED_FLOOR = (0, 2, 111)
|
||
_VERIFIED_CEILING = (0, 3)
|
||
|
||
# The newest build whose SOURCE was read for the premises below — question (b).
|
||
# Raising this is a claim that someone opened the package and checked, so it
|
||
# moves ONLY together with that reading. 0.2.139 read 2026-08-18: the public
|
||
# ``query.py`` is byte-identical to 0.2.120, every premise field is present with
|
||
# an unchanged type and default, and the parser changes are purely additive
|
||
# (a new ``origin`` passthrough, a new ``ConversationResetMessage``).
|
||
_VERIFIED_THROUGH = (0, 2, 139)
|
||
|
||
|
||
def _pin_for(floor: tuple[int, ...], ceiling: tuple[int, ...]) -> str:
|
||
"""The pyproject requirement string the verified range implies.
|
||
|
||
DERIVED, never a third hand-maintained copy: the range this guard enforces and
|
||
the pin it asserts on cannot drift apart, because moving the range moves the
|
||
expected pin with it (value-proof below).
|
||
"""
|
||
low = ".".join(str(part) for part in floor)
|
||
high = ".".join(str(part) for part in ceiling)
|
||
return f"claude-agent-sdk>={low},<{high}"
|
||
|
||
|
||
_PIN = _pin_for(_VERIFIED_FLOOR, _VERIFIED_CEILING)
|
||
|
||
# The sdk_client.py attribute premises, as a table the suite can CHECK rather
|
||
# than a sentence it can only print. Every entry is read by sdk_client.py; the
|
||
# inventory below asserts each one against the installed package, so a premise
|
||
# that quietly stopped being true fails HERE instead of on the one live run.
|
||
_SDK_SURFACE: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||
("AssistantMessage", ("error", "model", "content")),
|
||
("ResultMessage", ("usage", "total_cost_usd", "is_error", "subtype", "errors")),
|
||
(
|
||
"ClaudeAgentOptions",
|
||
("max_budget_usd", "setting_sources", "system_prompt", "max_turns", "model", "tools"),
|
||
),
|
||
("TextBlock", ("text",)),
|
||
)
|
||
|
||
# The premise introspection is STRUCTURALLY unable to see: an ordering fact
|
||
# about a stream, not an attribute on a class. It is named separately rather
|
||
# than dropped, because the honest reason the version ratchet still exists is
|
||
# that this line can only be checked by a human reading the source.
|
||
_SEMANTIC_PREMISES = ("query() yields AssistantMessage then a closing ResultMessage",)
|
||
|
||
|
||
def _premise_text(surface: tuple[tuple[str, tuple[str, ...]], ...]) -> tuple[str, ...]:
|
||
"""The prose the failure message prints — DERIVED from the checked table.
|
||
|
||
Not a second hand-maintained copy: an attribute added to the table appears in
|
||
the operator-facing message for free, and one removed cannot linger there
|
||
claiming a premise nobody verifies any more (value-proof below).
|
||
"""
|
||
return tuple(f"{cls}." + "/.".join(attrs) for cls, attrs in surface) + _SEMANTIC_PREMISES
|
||
|
||
|
||
_SDK_PREMISES = _premise_text(_SDK_SURFACE)
|
||
|
||
|
||
def _parse(raw: str) -> tuple[int, ...]:
|
||
return tuple(int(part) for part in raw.split(".")[:3])
|
||
|
||
|
||
def _dependencies_block(text: str) -> str:
|
||
"""The ``dependencies = [...]`` array of a pyproject, as text (ANCHOR).
|
||
|
||
Fail-closed: a pyproject that no longer declares the array raises rather than
|
||
returning "" — a silently empty anchor would make every assertion below vacuous
|
||
(the degeneration mode measured on the §12 guard).
|
||
"""
|
||
start = text.find("dependencies = [")
|
||
if start == -1:
|
||
raise ValueError("pyproject declares no 'dependencies = [' array — anchor lost")
|
||
end = text.find("]", start)
|
||
if end == -1:
|
||
raise ValueError("pyproject 'dependencies' array is unterminated — anchor lost")
|
||
return text[start : end + 1]
|
||
|
||
|
||
def _pin_is_declared(text: str, pin: str) -> bool:
|
||
"""True when ``pin`` is a real runtime dependency — not merely present somewhere.
|
||
|
||
Two narrowings, each one a measured degeneration rather than a precaution:
|
||
``pin in text`` was GREEN while the real dependency had drifted below the
|
||
verified floor and the literal survived in a trailing comment (measured
|
||
2026-07-31), so the match is scoped to the anchor AND requires the QUOTED
|
||
form with comments stripped — a comment sits inside the array too, so the
|
||
slice alone still does not detach.
|
||
"""
|
||
body = "\n".join(line.split("#", 1)[0] for line in _dependencies_block(text).splitlines())
|
||
return f'"{pin}"' in body
|
||
|
||
|
||
def missing_sdk_attributes(
|
||
surface: tuple[tuple[str, tuple[str, ...]], ...] = _SDK_SURFACE,
|
||
) -> tuple[str, ...]:
|
||
"""Premises the INSTALLED build fails to satisfy — introspected, never assumed.
|
||
|
||
Fail-closed on three distinct absences, because each would otherwise read as
|
||
"no gaps found": the class is gone from the package, the class is no longer a
|
||
dataclass (so its fields are not what this check knows how to read), or the
|
||
attribute is missing. Returning () must mean "checked and clean", never
|
||
"could not look" — that conflation is the whole defect class.
|
||
"""
|
||
import claude_agent_sdk
|
||
|
||
gaps: list[str] = []
|
||
for cls_name, attrs in surface:
|
||
cls = getattr(claude_agent_sdk, cls_name, None)
|
||
if cls is None:
|
||
gaps.append(f"{cls_name}: class absent from claude_agent_sdk")
|
||
continue
|
||
if not dataclasses.is_dataclass(cls):
|
||
gaps.append(f"{cls_name}: no longer a dataclass — fields unreadable")
|
||
continue
|
||
declared = {field.name for field in dataclasses.fields(cls)}
|
||
gaps.extend(f"{cls_name}.{attr}" for attr in attrs if attr not in declared)
|
||
return tuple(gaps)
|
||
|
||
|
||
def check_sdk_version() -> str:
|
||
"""Fail if the installed SDK is outside the verified range — naming the premises."""
|
||
raw = importlib.metadata.version("claude-agent-sdk")
|
||
if not (_VERIFIED_FLOOR <= _parse(raw) < _VERIFIED_CEILING):
|
||
raise AssertionError(
|
||
f"claude-agent-sdk {raw} is OUTSIDE the verified range "
|
||
f"{_PIN.removeprefix('claude-agent-sdk')}. "
|
||
"Re-verify the sdk_client.py attribute premises against the new version "
|
||
"BEFORE widening the pin (pyproject.toml + this guard together): "
|
||
+ "; ".join(_SDK_PREMISES)
|
||
)
|
||
if _parse(raw) > _VERIFIED_THROUGH:
|
||
# Question (b), the one the pin cannot answer: this build is PERMITTED
|
||
# and unread. Green here would mean `uv sync --upgrade` silently retires
|
||
# the verification that justified the pin in the first place.
|
||
through = ".".join(str(part) for part in _VERIFIED_THROUGH)
|
||
raise AssertionError(
|
||
f"claude-agent-sdk {raw} satisfies the pin "
|
||
f"{_PIN.removeprefix('claude-agent-sdk')} but is NEWER than the last build "
|
||
f"read at source ({through}). The pin permits it; nobody has verified it. "
|
||
"Read the new version's source, then raise _VERIFIED_THROUGH in the SAME "
|
||
"commit that installs it — these sdk_client.py premises are what to re-check: "
|
||
+ "; ".join(_SDK_PREMISES)
|
||
)
|
||
return raw
|
||
|
||
|
||
class TestSdkVersionGuard:
|
||
def test_the_installed_sdk_is_within_the_verified_range(self) -> None:
|
||
assert check_sdk_version() == importlib.metadata.version("claude-agent-sdk")
|
||
|
||
def test_a_version_above_the_ceiling_trips_the_guard(
|
||
self, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
monkeypatch.setattr(importlib.metadata, "version", lambda name: "0.3.0")
|
||
with pytest.raises(AssertionError) as err:
|
||
check_sdk_version()
|
||
message = str(err.value)
|
||
assert "sdk_client.py" in message
|
||
for premise in _SDK_PREMISES:
|
||
assert premise in message
|
||
|
||
def test_a_version_below_the_floor_trips_the_guard(
|
||
self, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
# 0.2.110 works but misses the 0.2.111 read-loop fixes (NDJSON
|
||
# whitespace on >64 KiB lines, plain-string content TypeError,
|
||
# zombie subprocess on cancellation) — below the floor is unverified.
|
||
monkeypatch.setattr(importlib.metadata, "version", lambda name: "0.2.110")
|
||
with pytest.raises(AssertionError):
|
||
check_sdk_version()
|
||
|
||
def test_a_version_inside_the_pin_but_beyond_the_verified_trips_the_guard(
|
||
self, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
# THE HOLE THIS RATCHET CLOSES (measured 2026-08-09): 0.2.140 satisfies
|
||
# the pin, so `uv sync --upgrade` installs it without a word, and before
|
||
# _VERIFIED_THROUGH existed the whole suite stayed GREEN on a build whose
|
||
# premises nobody had read. A permission is not a proof.
|
||
monkeypatch.setattr(importlib.metadata, "version", lambda name: "0.2.140")
|
||
with pytest.raises(AssertionError) as err:
|
||
check_sdk_version()
|
||
message = str(err.value)
|
||
assert "sdk_client.py" in message
|
||
# The message must separate the two questions, or the operator re-reads
|
||
# the pin — the thing that was never wrong — instead of the source.
|
||
assert "satisfies the pin" in message
|
||
assert "NEWER than the last build read at source" in message
|
||
for premise in _SDK_PREMISES:
|
||
assert premise in message
|
||
|
||
def test_the_build_read_at_source_itself_passes(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
# BOUNDARY CONTROL: a ratchet that rejected everything would pass the test
|
||
# above for the wrong reason. The verified build itself must be accepted.
|
||
through = ".".join(str(part) for part in _VERIFIED_THROUGH)
|
||
monkeypatch.setattr(importlib.metadata, "version", lambda name: through)
|
||
assert check_sdk_version() == through
|
||
|
||
def test_the_verified_reading_lies_inside_the_pin_it_justifies(self) -> None:
|
||
# A reading outside the installable range would vouch for a build uv can
|
||
# never resolve — the ratchet would be green and inert.
|
||
assert _VERIFIED_FLOOR <= _VERIFIED_THROUGH < _VERIFIED_CEILING
|
||
|
||
def test_the_pyproject_pin_matches_the_verified_range(self) -> None:
|
||
# The seam: what uv installs and what this guard vouches for are ONE range.
|
||
assert _pin_is_declared(_PYPROJECT.read_text(encoding="utf-8"), _PIN)
|
||
|
||
|
||
class TestTheSdkSurfaceInventory:
|
||
"""The premise table, checked against the INSTALLED package (§11).
|
||
|
||
Until now the premises existed only as a sentence printed on failure — prose
|
||
the suite could recite but never test. Any of them could have stopped being
|
||
true and nothing would have gone red until the one live run (S10, D6) spent
|
||
real money to find out.
|
||
"""
|
||
|
||
def test_every_premise_holds_on_the_installed_build(self) -> None:
|
||
assert missing_sdk_attributes() == ()
|
||
|
||
def test_the_table_is_not_empty_of_the_things_it_claims_to_check(self) -> None:
|
||
# POSITIVE CONTROL, ahead of the negative: "no gaps" is also what an
|
||
# EMPTY table returns. Without this, the assertion above would hold just
|
||
# as well for a premise list somebody had quietly deleted.
|
||
assert len(_SDK_SURFACE) == 4
|
||
assert sum(len(attrs) for _, attrs in _SDK_SURFACE) == 15
|
||
|
||
def test_every_checked_attribute_is_named_in_the_operator_facing_message(self) -> None:
|
||
printed = "; ".join(_SDK_PREMISES)
|
||
for cls_name, attrs in _SDK_SURFACE:
|
||
for attr in attrs:
|
||
assert attr in printed, f"{cls_name}.{attr} is checked but never reported"
|
||
|
||
|
||
class TestTheInventoryGoesRedWhenTheSurfaceMoves:
|
||
"""LOAD-BEARING (§11): prove the inventory can SEE absence, on a mutated COPY.
|
||
|
||
A checker that reports no gaps is making a claim about the package; it is
|
||
indistinguishable from a checker that cannot look. Each proof below breaks the
|
||
surface deliberately and requires the specific gap to be named — mirroring the
|
||
known-positive discipline the version ratchet above already follows.
|
||
"""
|
||
|
||
def test_red_when_a_premise_names_an_attribute_the_build_lacks(self) -> None:
|
||
gaps = missing_sdk_attributes((("ResultMessage", ("total_cost_usd", "no_such_field")),))
|
||
# Not merely "non-empty": the REAL attribute must survive as satisfied and
|
||
# only the fabricated one be reported, or a checker that flagged everything
|
||
# would pass this too.
|
||
assert gaps == ("ResultMessage.no_such_field",)
|
||
|
||
def test_red_when_the_class_disappears_from_the_package(self) -> None:
|
||
gaps = missing_sdk_attributes((("NoSuchMessage", ("text",)),))
|
||
assert gaps == ("NoSuchMessage: class absent from claude_agent_sdk",)
|
||
|
||
def test_red_when_the_checked_name_is_not_a_dataclass(self) -> None:
|
||
# `query` is a REAL export of the package — a genuine known-positive, not a
|
||
# fabricated stand-in. It is a function, so its fields are not readable the
|
||
# way this check reads fields, and the honest answer is a reported gap
|
||
# rather than a silent () from a `dataclasses.fields` TypeError.
|
||
import claude_agent_sdk
|
||
|
||
assert hasattr(claude_agent_sdk, "query"), "control is broken: the export is gone"
|
||
gaps = missing_sdk_attributes((("query", ("text",)),))
|
||
assert gaps == ("query: no longer a dataclass — fields unreadable",)
|
||
|
||
def test_red_when_the_prose_stops_covering_the_table(self) -> None:
|
||
# VALUE-proof on the derivation: an attribute added to the table appears in
|
||
# the operator-facing message WITHOUT a second edit. Were the prose a hand-
|
||
# maintained copy, this new attribute would be checked and never reported —
|
||
# the exact drift that made the old sentence outlive its verification.
|
||
widened = _premise_text((("ResultMessage", ("usage", "a_newly_relied_on_field")),))
|
||
assert "a_newly_relied_on_field" in "; ".join(widened)
|
||
assert "a_newly_relied_on_field" not in "; ".join(_SDK_PREMISES)
|
||
|
||
def test_the_semantic_premise_survives_derivation(self) -> None:
|
||
# The stream-ordering premise has no attribute to introspect, so nothing
|
||
# would go red if it silently dropped out of the derived prose.
|
||
assert _SEMANTIC_PREMISES[0] in "; ".join(_SDK_PREMISES)
|
||
|
||
|
||
class TestTheSeamGoesRedWhenDetached:
|
||
"""LOAD-BEARING (§11): the assertions above must FAIL on a detached pyproject.
|
||
|
||
Red-proofs run against a mutated COPY of the pyproject text — never against
|
||
``pyproject.toml`` itself. Each one is a former manual spot-check made permanent:
|
||
a detach-proof that dies with the session is not a proof.
|
||
"""
|
||
|
||
def test_red_when_the_pin_survives_only_outside_the_dependencies_block(self) -> None:
|
||
# The MEASURED degeneration: the real dependency drops below the verified
|
||
# floor while the exact pin string lives on in a trailing comment. The
|
||
# unanchored `_PIN in text` assertion stayed GREEN through exactly this.
|
||
text = _PYPROJECT.read_text(encoding="utf-8")
|
||
mutated = text.replace(f'"{_PIN}",', f'"claude-agent-sdk>=0.2.110,<0.3", # was: {_PIN}')
|
||
assert mutated != text, "mutation did not apply — the red-proof would be vacuous"
|
||
assert _PIN in mutated, "the literal must survive, or this proves the wrong thing"
|
||
assert not _pin_is_declared(mutated, _PIN)
|
||
|
||
def test_red_when_the_verified_floor_drifts_from_the_pin(self) -> None:
|
||
# VALUE-proof, not merely a detach-proof: moving the range this guard
|
||
# enforces moves the pin it demands, so the two cannot part company.
|
||
drifted = _pin_for((0, 2, 110), _VERIFIED_CEILING)
|
||
assert drifted != _PIN
|
||
assert not _pin_is_declared(_PYPROJECT.read_text(encoding="utf-8"), drifted)
|
||
|
||
def test_red_when_the_verified_ceiling_drifts_from_the_pin(self) -> None:
|
||
drifted = _pin_for(_VERIFIED_FLOOR, (0, 4))
|
||
assert drifted != _PIN
|
||
assert not _pin_is_declared(_PYPROJECT.read_text(encoding="utf-8"), drifted)
|
||
|
||
def test_the_anchor_is_fail_closed_when_the_array_is_renamed(self) -> None:
|
||
# An anchor that degenerates to "" would make every assertion above vacuous.
|
||
text = _PYPROJECT.read_text(encoding="utf-8").replace("dependencies = [", "deps = [")
|
||
with pytest.raises(ValueError, match="anchor lost"):
|
||
_dependencies_block(text)
|
||
|
||
def test_the_anchor_does_not_degenerate_to_the_whole_file(self) -> None:
|
||
# Mutate the ANCHOR itself: a slice that silently widened to the entire
|
||
# pyproject would re-admit the substring-anywhere defect unnoticed.
|
||
text = _PYPROJECT.read_text(encoding="utf-8")
|
||
block = _dependencies_block(text)
|
||
# Positive control: the marker EXISTS in the whole file, in exactly the form the
|
||
# negative searches for — otherwise "absent from the slice" would hold for a
|
||
# pyproject that never had a [project] table to be excluded.
|
||
assert "[project]" in text
|
||
assert len(block) < len(text)
|
||
assert "[project]" not in block
|