From 754ed04caaf3d80966911c55938d04431fa40c87 Mon Sep 17 00:00:00 2001 From: Eloi Date: Mon, 13 Jul 2026 12:19:57 +0200 Subject: [PATCH] fix: make SDK evolution evidence fail closed --- docs/sdk-evolution-agent.md | 27 +- examples/sdk_evolution_agent/behavior.py | 758 +++++++++++++- examples/sdk_evolution_agent/cli.py | 23 +- examples/sdk_evolution_agent/collectors.py | 44 + examples/sdk_evolution_agent/current_state.py | 1 + examples/sdk_evolution_agent/report.py | 57 +- examples/sdk_evolution_agent/stages.py | 137 ++- tests/test_sdk_evolution_agent.py | 961 +++++++++++++++++- 8 files changed, 1870 insertions(+), 138 deletions(-) diff --git a/docs/sdk-evolution-agent.md b/docs/sdk-evolution-agent.md index 6d5f5aa..03b43c1 100644 --- a/docs/sdk-evolution-agent.md +++ b/docs/sdk-evolution-agent.md @@ -82,6 +82,7 @@ Each run writes a timestamped directory under `reports/sdk-evolution/` with: - `api_diffs.json` - `behavior_probes.json` - `behavior_diffs.json` +- `behavior_summary.json` - `current_state.json` - `direction_analysis.json` - `architecture_decision.json` @@ -135,8 +136,27 @@ removed / changed diff is valid; a missing diff object is not. Behavior probes intentionally separate observed SDK surface churn from adapter contract breakage. `behavior_probes.json` records fields and parameters seen in current and candidate packages, while `behavior_diffs.json` compares the -required adapter contract. Optional field changes remain visible in the report -and API diffs, but only breaking adapter-contract diffs block implementation. +required adapter contract. `behavior_summary.json` records the deterministic +assessment, counts, and reasons used by the implementation gate. Its status is +`pass` for complete unchanged evidence, `changed` for complete non-breaking +changes, `incomplete` for probe errors, skips, malformed records, or missing +exact-version comparisons, and `fail` for a failed required contract or a +breaking diff. A package with no resolver-selected update needs only a valid +current baseline at the locked version (or the observed installed version when +no lock entry exists); it does not need a candidate probe. An ambient SDK that +has drifted away from the lock therefore produces `incomplete`, not `pass`. +Optional field changes remain visible in the report and API diffs without being +treated as a contract failure. + +Resolver transitions are parsed once as exact `(package, from, to)` triples and +shared by snapshot collection, behavior assessment, and implementation gates. +An API diff or behavior comparison for a different package or version does not +satisfy the expected transition. Snapshot import and execution errors are also +shown explicitly in `report.md` instead of being hidden behind the snapshot +count. Behavior expectations come from deterministic package and resolver +evidence; a behavior payload cannot narrow that scope itself. The report and +`behavior_summary.json` recompute their assessment from raw probes and diffs, so +a stale or contradictory cached summary is never presented as the run result. ## Implementation Gates @@ -152,7 +172,8 @@ Implementation is still blocked when: - the reviewer rejects the evidence or design, - a resolver-selected update lacks a candidate API diff, - required release-note evidence could not be collected, -- candidate behavior probes show a breaking adapter-contract difference, +- behavior evidence is failed, incomplete, malformed, internally inconsistent, + or uses the wrong package/version transition, - required structured output or permission behavior is unsupported by the selected runtime, - recursive self-adaptation is required but no safe migration plan exists. diff --git a/examples/sdk_evolution_agent/behavior.py b/examples/sdk_evolution_agent/behavior.py index b45a264..5594972 100644 --- a/examples/sdk_evolution_agent/behavior.py +++ b/examples/sdk_evolution_agent/behavior.py @@ -12,17 +12,36 @@ import textwrap from collections.abc import Mapping, Sequence from pathlib import Path -from typing import Any +from typing import Any, cast +from examples.sdk_evolution_agent.collectors import ResolverTransition, parse_refresh_transitions from examples.sdk_evolution_agent.models import BehaviorDiff, BehaviorProbeResult from examples.sdk_evolution_agent.snapshots import DEFAULT_MODULES, isolated_env +_RESULT_STATUSES = frozenset({"pass", "fail", "skip"}) +_DIFF_SEVERITIES = frozenset({"none", "changed", "breaking"}) +_RESULT_SCOPES = frozenset( + {"current-baseline", "current-environment", "candidate", "isolated-venv"} +) +_DETAIL_STRING_SEQUENCE_FIELDS = frozenset( + { + "fields", + "missing", + "required_fields", + "required_run_params", + "required_start_params", + "run_params", + "start_params", + } +) + def collect_behavior_evidence( packages: Sequence[Mapping[str, object]], update_versions: Mapping[str, str], *, inspect_candidates: bool = False, + expected_transitions: Sequence[ResolverTransition] | None = None, ) -> dict[str, Any]: """Collect current/candidate behavior probes and compare them. @@ -41,11 +60,7 @@ def collect_behavior_evidence( continue locked_version = _string_or_none(package.get("locked_version")) installed_version = _string_or_none(package.get("installed_version")) - if ( - inspect_candidates - and locked_version - and locked_version != installed_version - ): + if inspect_candidates and locked_version and locked_version != installed_version: results.extend(probe_candidate_in_venv(name, locked_version, scope="current-baseline")) else: results.extend(probe_current_package(name, version=installed_version)) @@ -56,11 +71,19 @@ def collect_behavior_evidence( else: results.append(_skipped_candidate_probe(name, candidate)) diffs = diff_behavior_results(results) - return { + transitions = tuple( + expected_transitions + if expected_transitions is not None + else _expected_transitions(packages, update_versions) + ) + expectations = build_behavior_expectations(packages, transitions) + payload: dict[str, Any] = { "results": [result for result in results], "diffs": [diff for diff in diffs], - "summary": summarize_behavior(diffs), + **expectations, } + payload["summary"] = assess_behavior_payload(payload, expectations=expectations) + return payload def probe_current_package( @@ -83,34 +106,80 @@ def probe_candidate_in_venv( ) -> tuple[BehaviorProbeResult, ...]: """Run behavior probes against a candidate package in an isolated virtualenv.""" - with tempfile.TemporaryDirectory(prefix="ark-sdk-behavior-") as directory: - venv = Path(directory) / ".venv" - # Scrub the environment for every subprocess that touches freshly - # downloaded upstream code (same scrub as the API snapshots): a - # throwaway HOME and only PATH, so a malicious or buggy candidate - # package cannot read the caller's credentials/config. - env = isolated_env(Path(directory)) - subprocess.run((python, "-m", "venv", str(venv)), check=True, timeout=timeout, env=env) - bin_dir = "Scripts" if sys.platform == "win32" else "bin" - venv_python = venv / bin_dir / "python" - subprocess.run( - (str(venv_python), "-m", "pip", "install", f"{package}=={version}"), - check=True, - text=True, - capture_output=True, - timeout=timeout, - env=env, + step = "virtual-environment-creation" + try: + with tempfile.TemporaryDirectory(prefix="ark-sdk-behavior-") as directory: + venv = Path(directory) / ".venv" + # Scrub the environment for every subprocess that touches freshly + # downloaded upstream code (same scrub as the API snapshots): a + # throwaway HOME and only PATH, so a malicious or buggy candidate + # package cannot read the caller's credentials/config. + env = isolated_env(Path(directory)) + subprocess.run( + (python, "-m", "venv", str(venv)), + check=True, + text=True, + capture_output=True, + timeout=timeout, + env=env, + ) + bin_dir = "Scripts" if sys.platform == "win32" else "bin" + venv_python = venv / bin_dir / "python" + step = "package-installation" + subprocess.run( + (str(venv_python), "-m", "pip", "install", f"{package}=={version}"), + check=True, + text=True, + capture_output=True, + timeout=timeout, + env=env, + ) + step = "probe-execution" + completed = subprocess.run( + (str(venv_python), "-c", _PROBE_SCRIPT, package, version, scope), + check=True, + text=True, + capture_output=True, + timeout=timeout, + env=env, + ) + except subprocess.TimeoutExpired as exc: + return ( + _probe_execution_failure( + package, + version, + scope, + step, + f"timed out after {exc.timeout}s", + ), ) - completed = subprocess.run( - (str(venv_python), "-c", _PROBE_SCRIPT, package, version, scope), - check=True, - text=True, - capture_output=True, - timeout=timeout, - env=env, + except subprocess.CalledProcessError as exc: + detail = _bounded_text(exc.stderr or exc.stdout or str(exc)) + return (_probe_execution_failure(package, version, scope, step, detail),) + except OSError as exc: + return (_probe_execution_failure(package, version, scope, step, exc),) + + try: + return _parse_probe_output( + completed.stdout, + package=package, + version=version, + scope=scope, + ) + except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc: + output = _bounded_text(completed.stdout) + detail = f"malformed probe output: {exc}" + if output: + detail += f"; stdout={output}" + return ( + _probe_execution_failure( + package, + version, + scope, + "probe-output-validation", + detail, + ), ) - raw = json.loads(completed.stdout) - return tuple(BehaviorProbeResult(**item) for item in raw) def diff_behavior_results(results: Sequence[BehaviorProbeResult]) -> tuple[BehaviorDiff, ...]: @@ -125,9 +194,14 @@ def diff_behavior_results(results: Sequence[BehaviorProbeResult]) -> tuple[Behav after = scopes.get("candidate") or scopes.get("isolated-venv") if before is None or after is None: continue - if (before.status == "skip") != (after.status == "skip"): - # One side was not probed (candidate installs are opt-in): absence - # of evidence is not a behavior change and must not read as one. + if ( + before.status == "skip" + or after.status == "skip" + or _is_probe_execution_error(before) + or _is_probe_execution_error(after) + ): + # Missing or failed execution is incomplete evidence, not an observed + # behavior change. The raw-result assessment preserves that state. continue if before.status == after.status and _contract_details(before) == _contract_details(after): severity = "none" @@ -156,19 +230,525 @@ def diff_behavior_results(results: Sequence[BehaviorProbeResult]) -> tuple[Behav return tuple(diffs) -def summarize_behavior(diffs: Sequence[BehaviorDiff]) -> dict[str, Any]: - """Return a compact behavior summary for reports and gates.""" +def build_behavior_expectations( + packages: Sequence[Mapping[str, object]], + transitions: Sequence[ResolverTransition], +) -> dict[str, Any]: + """Build canonical behavior expectations from deterministic package evidence.""" + + expected_packages: list[str] = [] + expected_baselines: dict[str, str | None] = {} + for package in packages: + name = str(package.get("name") or "") + if not name or name in expected_baselines: + continue + locked_version = _string_or_none(package.get("locked_version")) + installed_version = _string_or_none(package.get("installed_version")) + expected_packages.append(name) + expected_baselines[name] = ( + locked_version if locked_version is not None else installed_version + ) + return { + "expected_packages": expected_packages, + "expected_transitions": [ + { + "package": transition.package, + "from_version": transition.from_version, + "to_version": transition.to_version, + } + for transition in sorted(set(transitions)) + ], + "expected_baselines": expected_baselines, + } + + +def behavior_expectations_from_evidence(evidence: Mapping[str, object]) -> dict[str, Any]: + """Derive authoritative behavior expectations from the deterministic evidence bundle.""" + + raw_packages = evidence.get("packages") + packages: list[Mapping[str, object]] = [] + issues: list[str] = [] + if not _is_sequence_payload(raw_packages): + issues.append("deterministic evidence packages must be an array") + else: + for index, package in enumerate(cast(Sequence[object], raw_packages)): + if not isinstance(package, Mapping): + issues.append(f"deterministic evidence package {index} must be an object") + continue + name = package.get("name") + if not isinstance(name, str) or not name: + issues.append(f"deterministic evidence package {index} must have a non-empty name") + continue + packages.append(package) + expectations = build_behavior_expectations(packages, parse_refresh_transitions(evidence)) + expectations["expectation_issues"] = issues + return expectations + + +def assess_behavior_payload( + behavior: Mapping[str, object], + *, + expectations: Mapping[str, object] | None = None, +) -> dict[str, Any]: + """Recompute the canonical assessment from raw evidence and trusted expectations.""" + + context = expectations if expectations is not None else behavior + summary = summarize_behavior( + behavior.get("results"), + behavior.get("diffs"), + expected_packages=context.get("expected_packages"), + expected_transitions=context.get("expected_transitions"), + expected_baselines=context.get("expected_baselines"), + ) + if expectations is None: + return summary + + raw_expectation_issues = context.get("expectation_issues", []) + issues = ( + list(raw_expectation_issues) + if _is_sequence_payload(raw_expectation_issues) + and all(isinstance(issue, str) for issue in cast(Sequence[object], raw_expectation_issues)) + else ["deterministic behavior expectation issues are malformed"] + ) + issues.extend( + [ + f"behavior payload {key} contradicts deterministic evidence" + for key in ("expected_packages", "expected_transitions", "expected_baselines") + if behavior.get(key) != expectations.get(key) + ] + ) + if not issues: + return summary + summary["malformed_count"] = int(summary["malformed_count"]) + len(issues) + summary["reasons"] = list(dict.fromkeys([*summary["reasons"], *issues])) + if summary["status"] != "fail": + summary["status"] = "incomplete" + return summary + + +def summarize_behavior( + results: object, + diffs: object, + *, + expected_packages: object, + expected_transitions: object, + expected_baselines: object, +) -> dict[str, Any]: + """Assess raw probes and diffs without collapsing missing evidence into pass.""" + + reasons: list[str] = [] + malformed_count = 0 + + packages: list[str] = [] + if not _is_sequence_payload(expected_packages): + malformed_count += 1 + reasons.append("expected_packages must be an array") + else: + for index, package in enumerate(cast(Sequence[object], expected_packages)): + if not isinstance(package, str) or not package: + malformed_count += 1 + reasons.append(f"expected package {index} must be a non-empty string") + elif package in packages: + malformed_count += 1 + reasons.append(f"expected package {package} is duplicated") + else: + packages.append(package) + + baselines: dict[str, str | None] = {} + if not isinstance(expected_baselines, Mapping): + malformed_count += 1 + reasons.append("expected_baselines must be an object") + else: + for package, version in expected_baselines.items(): + if not isinstance(package, str) or not package: + malformed_count += 1 + reasons.append("expected baseline package names must be non-empty strings") + continue + if version is not None and (not isinstance(version, str) or not version): + malformed_count += 1 + reasons.append( + f"expected baseline for {package} must be a non-empty string or null" + ) + continue + baselines[package] = version + + package_set = set(packages) + for package in sorted(package_set - baselines.keys()): + malformed_count += 1 + reasons.append(f"expected_baselines is missing {package}") + for package in sorted(baselines.keys() - package_set): + malformed_count += 1 + reasons.append(f"expected_baselines contains unexpected package {package}") + + normalized_results: list[BehaviorProbeResult] = [] + if not _is_sequence_payload(results): + malformed_count += 1 + reasons.append("behavior results must be an array") + else: + for index, item in enumerate(cast(Sequence[object], results)): + try: + normalized_results.append(_coerce_probe_result(item)) + except (KeyError, TypeError, ValueError) as exc: + malformed_count += 1 + reasons.append(f"behavior result {index} is malformed: {_bounded_text(exc)}") + + normalized_diffs: list[BehaviorDiff] = [] + if not _is_sequence_payload(diffs): + malformed_count += 1 + reasons.append("behavior diffs must be an array") + else: + for index, item in enumerate(cast(Sequence[object], diffs)): + try: + normalized_diffs.append(_coerce_behavior_diff(item)) + except (KeyError, TypeError, ValueError) as exc: + malformed_count += 1 + reasons.append(f"behavior diff {index} is malformed: {_bounded_text(exc)}") + + expected_diffs = diff_behavior_results(normalized_results) + if sorted(normalized_diffs, key=_behavior_diff_key) != sorted( + expected_diffs, key=_behavior_diff_key + ): + malformed_count += 1 + reasons.append("behavior diffs contradict raw probe results") + + contract_failures: list[BehaviorProbeResult] = [] + probe_errors: list[BehaviorProbeResult] = [] + skipped: list[BehaviorProbeResult] = [] + for result in normalized_results: + if result.status == "skip": + skipped.append(result) + reasons.append(_probe_reason(result, "skipped")) + elif result.status == "fail" and _is_probe_execution_error(result): + probe_errors.append(result) + reasons.append(_probe_reason(result, "could not execute")) + elif result.status == "fail": + contract_failures.append(result) + reasons.append(_probe_reason(result, "failed the required contract")) + + breaking = [diff for diff in expected_diffs if diff.severity == "breaking"] + changed = [diff for diff in expected_diffs if diff.severity == "changed"] + unchanged = [diff for diff in expected_diffs if diff.severity == "none"] + for diff in breaking: + reasons.append( + f"{diff.package}:{diff.probe} {diff.from_version} -> {diff.to_version} " + f"is breaking: {_bounded_text(diff.summary)}" + ) + + transitions: list[ResolverTransition] = [] + if not _is_sequence_payload(expected_transitions): + malformed_count += 1 + reasons.append("expected_transitions must be an array") + else: + for index, item in enumerate(cast(Sequence[object], expected_transitions)): + try: + transition = _coerce_transition(item) + except (KeyError, TypeError, ValueError) as exc: + malformed_count += 1 + reasons.append(f"expected transition {index} is malformed: {_bounded_text(exc)}") + continue + transitions.append(transition) + if transition.package not in package_set: + malformed_count += 1 + reasons.append( + f"expected transition contains unexpected package {transition.package}" + ) + elif ( + transition.package in baselines + and baselines[transition.package] != transition.from_version + ): + malformed_count += 1 + reasons.append( + f"{transition.package} transition baseline {transition.from_version} " + f"contradicts expected baseline {baselines[transition.package]}" + ) + + missing_comparison_count = 0 + transition_packages = {transition.package for transition in transitions} + for package in sorted(package_set - transition_packages): + observations = [ + result + for result in normalized_results + if result.package == package + and result.scope in {"current-baseline", "current-environment"} + ] + if not observations: + missing_comparison_count += 1 + reasons.append(f"{package} has no observed current baseline") + continue + if package not in baselines: + continue + expected_version = baselines[package] + if not any( + result.package == package + and result.version == expected_version + and result.scope in {"current-baseline", "current-environment"} + for result in observations + ): + missing_comparison_count += 1 + observed_versions = sorted( + { + result.version if result.version is not None else "" + for result in observations + } + ) + expected_label = expected_version if expected_version is not None else "" + reasons.append( + f"{package} current observation does not match expected baseline " + f"{expected_label} (observed: {', '.join(observed_versions)})" + ) + + for transition in sorted(set(transitions)): + before = [ + result + for result in normalized_results + if result.package == transition.package + and result.version == transition.from_version + and result.scope in {"current-baseline", "current-environment"} + ] + after = [ + result + for result in normalized_results + if result.package == transition.package + and result.version == transition.to_version + and result.scope in {"candidate", "isolated-venv"} + ] + before_probes = {result.probe for result in before} + after_probes = {result.probe for result in after} + compared_probes = { + diff.probe + for diff in expected_diffs + if diff.package == transition.package + and diff.from_version == transition.from_version + and diff.to_version == transition.to_version + } + if not before_probes or before_probes != after_probes or compared_probes != before_probes: + missing_comparison_count += 1 + reasons.append( + f"{transition.package} lacks a paired behavior comparison for " + f"{transition.from_version} -> {transition.to_version}" + ) + + if contract_failures or breaking: + status = "fail" + elif probe_errors or skipped or missing_comparison_count or malformed_count: + status = "incomplete" + elif changed: + status = "changed" + else: + status = "pass" - breaking = [diff for diff in diffs if diff.severity == "breaking"] - changed = [diff for diff in diffs if diff.severity == "changed"] return { + "status": status, "breaking_count": len(breaking), "changed_count": len(changed), - "unchanged_count": len([diff for diff in diffs if diff.severity == "none"]), - "status": "fail" if breaking else "changed" if changed else "pass", + "unchanged_count": len(unchanged), + "contract_failure_count": len(contract_failures), + "probe_error_count": len(probe_errors), + "skipped_count": len(skipped), + "missing_comparison_count": missing_comparison_count, + "malformed_count": malformed_count, + "reasons": list(dict.fromkeys(reasons)), } +def _is_sequence_payload(value: object) -> bool: + return isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray) + + +def _coerce_probe_result(item: object) -> BehaviorProbeResult: + if isinstance(item, BehaviorProbeResult): + result = item + elif isinstance(item, Mapping): + _require_string_fields(item, "package", "scope", "probe", "status", "summary") + version = item.get("version") + if version is not None and not isinstance(version, str): + raise TypeError("version must be a string or null") + details = item.get("details", {}) + if not isinstance(details, Mapping): + raise TypeError("details must be an object") + result = BehaviorProbeResult( + package=item["package"], + version=version, + scope=item["scope"], + probe=item["probe"], + status=item["status"], + summary=item["summary"], + details=dict(details), + ) + else: + raise TypeError("result must be an object") + if not result.package or not result.scope or not result.probe: + raise ValueError("package, scope, and probe must be non-empty") + if result.version is not None and not isinstance(result.version, str): + raise TypeError("version must be a string or null") + if result.scope not in _RESULT_SCOPES: + raise ValueError(f"unknown probe scope {result.scope!r}") + if result.status not in _RESULT_STATUSES: + raise ValueError(f"unknown probe status {result.status!r}") + if not isinstance(result.summary, str): + raise TypeError("summary must be a string") + if not isinstance(result.details, Mapping): + raise TypeError("details must be an object") + details = _normalize_probe_details(result.details) + return BehaviorProbeResult( + package=result.package, + version=result.version, + scope=result.scope, + probe=result.probe, + status=result.status, + summary=result.summary, + details=details, + ) + + +def _coerce_behavior_diff(item: object) -> BehaviorDiff: + if isinstance(item, BehaviorDiff): + diff = item + elif isinstance(item, Mapping): + _require_string_fields( + item, + "package", + "probe", + "severity", + "summary", + "before_status", + "after_status", + ) + from_version = item.get("from_version") + to_version = item.get("to_version") + if from_version is not None and not isinstance(from_version, str): + raise TypeError("from_version must be a string or null") + if to_version is not None and not isinstance(to_version, str): + raise TypeError("to_version must be a string or null") + diff = BehaviorDiff( + package=item["package"], + from_version=from_version, + to_version=to_version, + probe=item["probe"], + severity=item["severity"], + summary=item["summary"], + before_status=item["before_status"], + after_status=item["after_status"], + ) + else: + raise TypeError("diff must be an object") + if not diff.package or not diff.probe: + raise ValueError("diff package and probe must be non-empty") + if diff.from_version is not None and not isinstance(diff.from_version, str): + raise TypeError("diff from_version must be a string or null") + if diff.to_version is not None and not isinstance(diff.to_version, str): + raise TypeError("diff to_version must be a string or null") + if not isinstance(diff.summary, str): + raise TypeError("diff summary must be a string") + if diff.severity not in _DIFF_SEVERITIES: + raise ValueError(f"unknown diff severity {diff.severity!r}") + if diff.before_status not in _RESULT_STATUSES or diff.after_status not in _RESULT_STATUSES: + raise ValueError("diff contains an unknown probe status") + return diff + + +def _coerce_transition(item: ResolverTransition | Mapping[str, object]) -> ResolverTransition: + if isinstance(item, ResolverTransition): + transition = item + elif isinstance(item, Mapping): + _require_string_fields(item, "package", "from_version", "to_version") + transition = ResolverTransition( + package=cast(str, item["package"]), + from_version=cast(str, item["from_version"]), + to_version=cast(str, item["to_version"]), + ) + else: + raise TypeError("transition must be an object") + if not all( + isinstance(value, str) + for value in (transition.package, transition.from_version, transition.to_version) + ): + raise TypeError("transition fields must be strings") + if not transition.package or not transition.from_version or not transition.to_version: + raise ValueError("transition fields must be non-empty") + return transition + + +def _require_string_fields(item: Mapping[str, object], *fields: str) -> None: + for field in fields: + if field not in item: + raise KeyError(field) + if not isinstance(item[field], str): + raise TypeError(f"{field} must be a string") + + +def _normalize_probe_details(details: Mapping[str, object]) -> dict[str, Any]: + normalized = dict(details) + for field in _DETAIL_STRING_SEQUENCE_FIELDS & details.keys(): + value = details[field] + if not _is_sequence_payload(value) or not all( + isinstance(item, str) for item in cast(Sequence[object], value) + ): + raise TypeError(f"details.{field} must be an array of strings") + normalized[field] = list(cast(Sequence[str], value)) + if "error" in details and not isinstance(details["error"], str): + raise TypeError("details.error must be a string") + if "failure_step" in details and not isinstance(details["failure_step"], str): + raise TypeError("details.failure_step must be a string") + return normalized + + +def _behavior_diff_key(diff: BehaviorDiff) -> tuple[str, str, str, str, str, str, str, str]: + return ( + diff.package, + diff.from_version or "", + diff.to_version or "", + diff.probe, + diff.severity, + diff.summary, + diff.before_status, + diff.after_status, + ) + + +def _expected_transitions( + packages: Sequence[Mapping[str, object]], + update_versions: Mapping[str, str], +) -> tuple[ResolverTransition, ...]: + transitions: list[ResolverTransition] = [] + for package in packages: + name = str(package.get("name") or "") + candidate = update_versions.get(name) + if not name or not candidate: + continue + baseline = _string_or_none(package.get("locked_version")) or _string_or_none( + package.get("installed_version") + ) + transitions.append( + ResolverTransition( + package=name, + from_version=baseline or "", + to_version=str(candidate), + ) + ) + return tuple(transitions) + + +def _probe_reason(result: BehaviorProbeResult, classification: str) -> str: + return ( + f"{result.package}:{result.probe} {result.scope} {classification}: " + f"{_bounded_text(result.summary, limit=240)}" + ) + + +def _is_probe_execution_error(result: BehaviorProbeResult) -> bool: + return ( + result.status == "fail" + and "error" in result.details + and not _has_contract_failure_evidence(result) + ) + + +def _has_contract_failure_evidence(result: BehaviorProbeResult) -> bool: + missing = result.details.get("missing") + return bool(missing) and _is_sequence_payload(missing) + + def _probe_package( package: str, *, @@ -346,16 +926,28 @@ def _contract_details(result: BehaviorProbeResult) -> dict[str, Any]: details = result.details if "missing" not in details: return details - contract: dict[str, Any] = {"missing": sorted(details.get("missing") or [])} + contract: dict[str, Any] = {"missing": _normalized_contract_field(details.get("missing"))} if "required_fields" in details: - contract["required_fields"] = sorted(details.get("required_fields") or []) + contract["required_fields"] = _normalized_contract_field(details.get("required_fields")) if "required_run_params" in details: - contract["required_run_params"] = sorted(details.get("required_run_params") or []) + contract["required_run_params"] = _normalized_contract_field( + details.get("required_run_params") + ) if "required_start_params" in details: - contract["required_start_params"] = sorted(details.get("required_start_params") or []) + contract["required_start_params"] = _normalized_contract_field( + details.get("required_start_params") + ) return contract +def _normalized_contract_field(value: object) -> object: + if _is_sequence_payload(value) and all( + isinstance(item, str) for item in cast(Sequence[object], value) + ): + return sorted(cast(Sequence[str], value)) + return value + + def _failed( package: str, version: str | None, @@ -363,23 +955,81 @@ def _failed( probe: str, exc: Exception, ) -> BehaviorProbeResult: + error = _bounded_text(exc) return BehaviorProbeResult( package=package, version=version, scope=scope, probe=probe, status="fail", - summary=str(exc), - details={"error": str(exc)}, + summary=error, + details={"error": error, "failure_step": "adapter-probe"}, + ) + + +def _probe_execution_failure( + package: str, + version: str, + scope: str, + step: str, + error: object, +) -> BehaviorProbeResult: + detail = _bounded_text(error) + summary = _bounded_text(f"{step} failed: {detail}", limit=560) + return BehaviorProbeResult( + package=package, + version=version, + scope=scope, + probe=_probe_identity(package), + status="fail", + summary=summary, + details={"error": summary, "failure_step": step}, ) +def _parse_probe_output( + output: str, + *, + package: str, + version: str, + scope: str, +) -> tuple[BehaviorProbeResult, ...]: + raw = json.loads(output) + if not isinstance(raw, list) or not raw: + raise ValueError("probe output must be a non-empty array") + results: list[BehaviorProbeResult] = [] + for item in raw: + result = _coerce_probe_result(item) + if result.package != package: + raise ValueError( + f"probe package {result.package!r} does not match requested package {package!r}" + ) + if result.version != version: + raise ValueError( + f"probe version {result.version!r} does not match requested version {version!r}" + ) + if result.scope != scope: + raise ValueError( + f"probe scope {result.scope!r} does not match requested scope {scope!r}" + ) + results.append(result) + return tuple(results) + + +def _probe_identity(package: str) -> str: + if package == "openai-codex-cli-bin": + return "binary-distribution" + if package in {"claude-agent-sdk", "openai-codex", "google-antigravity"}: + return "adapter-contract" + return "package-import" + + def _skipped_candidate_probe(package: str, version: str) -> BehaviorProbeResult: return BehaviorProbeResult( package=package, version=version, scope="candidate", - probe="adapter-contract", + probe=_probe_identity(package), status="skip", summary=( "Candidate behavior probe skipped: probing a candidate installs and " @@ -390,6 +1040,14 @@ def _skipped_candidate_probe(package: str, version: str) -> BehaviorProbeResult: ) +def _bounded_text(value: object, *, limit: int = 480) -> str: + if isinstance(value, bytes): + text = value.decode(errors="replace") + else: + text = str(value) + return " ".join(text.split())[:limit] + + def _string_or_none(value: object) -> str | None: if value is None: return None diff --git a/examples/sdk_evolution_agent/cli.py b/examples/sdk_evolution_agent/cli.py index 87461df..2282fcc 100644 --- a/examples/sdk_evolution_agent/cli.py +++ b/examples/sdk_evolution_agent/cli.py @@ -3,7 +3,6 @@ from __future__ import annotations import argparse -import re from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -14,6 +13,8 @@ CommandRunner, PypiClient, collect_evidence, + parse_refresh_transitions, + refresh_update_versions, run_lock_update, run_verification_commands, ) @@ -183,7 +184,8 @@ async def run_agent( pypi_client=pypi_client, command_runner=command_runner, ) - update_versions = _refresh_update_versions(evidence) + transitions = parse_refresh_transitions(evidence) + update_versions = refresh_update_versions(evidence) snapshots = _collect_snapshots(evidence, inspect_candidates=options.inspect_candidates) api_diffs = [to_jsonable(diff) for diff in diff_snapshot_groups(snapshots)] release_notes = [ @@ -195,6 +197,7 @@ async def run_agent( evidence.get("packages", []), update_versions, inspect_candidates=options.inspect_candidates, + expected_transitions=transitions, ) ) direction, architecture, review = await run_analysis_pipeline( @@ -517,7 +520,7 @@ def _collect_snapshots(evidence: dict[str, Any], *, inspect_candidates: bool = F # off, every snapshot uses the already-installed version via snapshot_current_api, # which imports nothing new. snapshots = [] - update_versions = _refresh_update_versions(evidence) + update_versions = refresh_update_versions(evidence) refresh_preview_seen = evidence.get("refresh_preview") is not None for package in evidence.get("packages", []): if not isinstance(package, dict): @@ -543,14 +546,6 @@ def _collect_snapshots(evidence: dict[str, Any], *, inspect_candidates: bool = F def _refresh_update_versions(evidence: dict[str, Any]) -> dict[str, str]: - preview = evidence.get("refresh_preview") - if not isinstance(preview, dict): - return {} - text = f"{preview.get('stdout') or ''}\n{preview.get('stderr') or ''}" - return { - package: version - for package, version in re.findall( - r"Update\s+([A-Za-z0-9_.-]+)\s+v\S+\s+->\s+v(\S+)", - text, - ) - } + """Compatibility wrapper around the shared exact transition parser.""" + + return refresh_update_versions(evidence) diff --git a/examples/sdk_evolution_agent/collectors.py b/examples/sdk_evolution_agent/collectors.py index 8dc135c..4dbce5d 100644 --- a/examples/sdk_evolution_agent/collectors.py +++ b/examples/sdk_evolution_agent/collectors.py @@ -10,6 +10,7 @@ import subprocess import urllib.request from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass from pathlib import Path from typing import Any @@ -23,6 +24,22 @@ FRESHNESS_CUTOFF_ENV_VARS = ("UV_EXCLUDE_NEWER",) +_REFRESH_TRANSITION_RE = re.compile( + r"^[ \t]*Update[ \t]+(?P[A-Za-z0-9_.-]+)[ \t]+" + r"v(?P\S+)[ \t]+->[ \t]+v(?P\S+)[ \t]*$", + re.MULTILINE, +) + + +@dataclass(frozen=True, order=True) +class ResolverTransition: + """One exact package transition selected by the uv refresh preview.""" + + package: str + from_version: str + to_version: str + + PACKAGE_SOURCE_HINTS: dict[str, tuple[SourceRef, ...]] = { "claude-agent-sdk": ( SourceRef( @@ -268,6 +285,33 @@ def run_refresh_preview( ) +def parse_refresh_transitions(evidence: Mapping[str, Any]) -> tuple[ResolverTransition, ...]: + """Parse exact resolver transitions from refresh-preview stdout and stderr.""" + + preview = evidence.get("refresh_preview") + if not isinstance(preview, Mapping): + return () + text = f"{preview.get('stdout') or ''}\n{preview.get('stderr') or ''}" + transitions = { + ResolverTransition( + package=match.group("package"), + from_version=match.group("from_version"), + to_version=match.group("to_version"), + ) + for match in _REFRESH_TRANSITION_RE.finditer(text) + } + return tuple(sorted(transitions)) + + +def refresh_update_versions(evidence: Mapping[str, Any]) -> dict[str, str]: + """Return resolver-selected target versions keyed by exact package name.""" + + return { + transition.package: transition.to_version + for transition in parse_refresh_transitions(evidence) + } + + def run_lock_update( root: Path, packages: Sequence[str], diff --git a/examples/sdk_evolution_agent/current_state.py b/examples/sdk_evolution_agent/current_state.py index 9f37bae..7c91ae1 100644 --- a/examples/sdk_evolution_agent/current_state.py +++ b/examples/sdk_evolution_agent/current_state.py @@ -48,6 +48,7 @@ def _artifact_refs(report_root: Path, *, workspace: Path) -> dict[str, dict[str, "api_diffs.json", "behavior_probes.json", "behavior_diffs.json", + "behavior_summary.json", "direction_analysis.json", "architecture_decision.json", "implementation_summary.json", diff --git a/examples/sdk_evolution_agent/report.py b/examples/sdk_evolution_agent/report.py index ec26afa..9318295 100644 --- a/examples/sdk_evolution_agent/report.py +++ b/examples/sdk_evolution_agent/report.py @@ -6,6 +6,10 @@ from pathlib import Path from typing import Any +from examples.sdk_evolution_agent.behavior import ( + assess_behavior_payload, + behavior_expectations_from_evidence, +) from examples.sdk_evolution_agent.models import RunContext, to_jsonable @@ -44,6 +48,13 @@ def write_run_report( write_json(context.report_root / "api_diffs.json", api_diffs) write_json(context.report_root / "behavior_probes.json", behavior.get("results", [])) write_json(context.report_root / "behavior_diffs.json", behavior.get("diffs", [])) + write_json( + context.report_root / "behavior_summary.json", + assess_behavior_payload( + behavior, + expectations=behavior_expectations_from_evidence(evidence), + ), + ) write_json(context.report_root / "direction_analysis.json", direction) write_json(context.report_root / "architecture_decision.json", architecture) write_json(context.report_root / "implementation_summary.json", implementation) @@ -61,6 +72,7 @@ def write_run_report( render_markdown_report( config=config, evidence=evidence, + snapshots=snapshots, api_diffs=api_diffs, release_notes=release_notes, behavior=behavior, @@ -79,6 +91,7 @@ def render_markdown_report( *, config: dict[str, Any], evidence: dict[str, Any], + snapshots: list[dict[str, Any]], api_diffs: list[dict[str, Any]], release_notes: list[dict[str, Any]], behavior: dict[str, Any], @@ -115,8 +128,31 @@ def render_markdown_report( for item in release_notes if isinstance(item, dict) and item.get("to_version") ] - behavior_summary = behavior.get("summary") if isinstance(behavior, dict) else {} - behavior_diffs = behavior.get("diffs", []) if isinstance(behavior, dict) else [] + behavior_summary = assess_behavior_payload( + behavior, + expectations=behavior_expectations_from_evidence(evidence), + ) + behavior_diffs = behavior.get("diffs", []) + behavior_reasons = behavior_summary.get("reasons", []) + snapshot_errors = [ + snapshot + for snapshot in snapshots + if isinstance(snapshot, dict) and snapshot.get("import_error") + ] + snapshot_error_lines = [ + "- {package}@{version} ({source}): {error}".format( + package=snapshot.get("package"), + version=snapshot.get("version"), + source=snapshot.get("source"), + error=_one_line(snapshot.get("import_error")), + ) + for snapshot in snapshot_errors + ] + behavior_reason_lines = [ + f"- {_one_line(reason)}" + for reason in behavior_reasons + if isinstance(reason, str) and reason + ] promotion = current_state.get("promotion", {}) if isinstance(current_state, dict) else {} return "\n".join( [ @@ -132,6 +168,13 @@ def render_markdown_report( "", *(package_lines or ["- No package evidence collected."]), "", + "## API Snapshots", + "", + f"- Status: `{'incomplete' if snapshot_errors else 'pass'}`", + f"- Snapshot count: `{len(snapshots)}`", + f"- Import or execution errors: `{len(snapshot_errors)}`", + *(snapshot_error_lines or ["- No snapshot errors recorded."]), + "", "## API Diffs", "", f"- Diff count: `{len(api_diffs)}`", @@ -145,7 +188,13 @@ def render_markdown_report( f"- Status: `{behavior_summary.get('status')}`", f"- Changed contracts: `{behavior_summary.get('changed_count')}`", f"- Breaking contracts: `{behavior_summary.get('breaking_count')}`", + f"- Contract failures: `{behavior_summary.get('contract_failure_count')}`", + f"- Probe errors: `{behavior_summary.get('probe_error_count')}`", + f"- Skipped probes: `{behavior_summary.get('skipped_count')}`", + f"- Missing comparisons: `{behavior_summary.get('missing_comparison_count')}`", + f"- Malformed evidence: `{behavior_summary.get('malformed_count')}`", f"- Diff count: `{len(behavior_diffs)}`", + *(behavior_reason_lines or ["- No behavior evidence issues recorded."]), "", "## Direction Of Travel", "", @@ -190,3 +239,7 @@ def render_markdown_report( "", ] ) + + +def _one_line(value: object, *, limit: int = 560) -> str: + return " ".join(str(value).split())[:limit] diff --git a/examples/sdk_evolution_agent/stages.py b/examples/sdk_evolution_agent/stages.py index 15c9c53..703b20e 100644 --- a/examples/sdk_evolution_agent/stages.py +++ b/examples/sdk_evolution_agent/stages.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import re from collections.abc import Mapping, Sequence from pathlib import Path from typing import Any @@ -30,6 +29,11 @@ from agent_runtime_kit.events import safe_emit, task_completed_event, task_started_event from agent_runtime_kit.registry import create_default_registry from examples.sdk_evolution_agent.auth import prepare_isolated_codex_home +from examples.sdk_evolution_agent.behavior import ( + assess_behavior_payload, + behavior_expectations_from_evidence, +) +from examples.sdk_evolution_agent.collectors import parse_refresh_transitions from examples.sdk_evolution_agent.models import ( RUNTIME_CONTRACT_SYMBOLS, ApiDiff, @@ -242,7 +246,7 @@ async def run_analysis_pipeline( architecture = with_recursive_impact(architecture, api_diffs) architecture = with_candidate_api_diff_guard(architecture, evidence, api_diffs) architecture = with_release_note_guard(architecture, release_notes) - architecture = with_behavior_probe_guard(architecture, behavior) + architecture = with_behavior_probe_guard(architecture, evidence, behavior) architecture = with_manual_design_gate(architecture) architecture = _compact_stage_output(architecture) review = await run_stage( @@ -380,14 +384,34 @@ def with_candidate_api_diff_guard( ) -> dict[str, Any]: """Block SDK update implementation when candidate API evidence is missing.""" - update_packages = _refresh_update_packages(evidence) - if not update_packages: + transitions = parse_refresh_transitions(evidence) + if not transitions: return dict(architecture) - diff_packages = { - diff.package if isinstance(diff, ApiDiff) else str(diff.get("package") or "") + observed = { + ( + diff.package, + str(diff.from_version or ""), + str(diff.to_version or ""), + ) + if isinstance(diff, ApiDiff) + else ( + str(diff.get("package") or ""), + str(diff.get("from_version") or ""), + str(diff.get("to_version") or ""), + ) for diff in api_diffs + if isinstance(diff, ApiDiff | Mapping) } - missing = tuple(sorted(package for package in update_packages if package not in diff_packages)) + missing = tuple( + transition + for transition in transitions + if ( + transition.package, + transition.from_version, + transition.to_version, + ) + not in observed + ) if not missing: return dict(architecture) @@ -402,14 +426,21 @@ def with_candidate_api_diff_guard( "SDK update candidates require candidate-version API snapshot diffs " "before implementation can be considered safe." ), - "evidence": [f"missing api_diffs for {package}" for package in missing], + "evidence": [ + f"missing api_diffs for {transition.package} " + f"{transition.from_version} -> {transition.to_version}" + for transition in missing + ], } ) result["findings"] = findings uncertainty = list(result.get("uncertainty") or []) uncertainty.append( - "Candidate API diffs were not available for update candidate(s): " - + ", ".join(missing) + "Exact candidate API transitions were not available for: " + + ", ".join( + f"{transition.package} {transition.from_version} -> {transition.to_version}" + for transition in missing + ) ) result["uncertainty"] = uncertainty plan = list(result.get("self_adaptation_plan") or []) @@ -454,37 +485,76 @@ def with_release_note_guard( def with_behavior_probe_guard( architecture: Mapping[str, Any], + evidence: Mapping[str, Any], behavior: Mapping[str, Any], ) -> dict[str, Any]: - """Block implementation when candidate behavior probes fail.""" + """Block implementation unless raw behavior evidence is complete and consistent.""" - diffs = behavior.get("diffs") - if not isinstance(diffs, list): - return dict(architecture) - breaking = [ - diff - for diff in diffs - if isinstance(diff, Mapping) and str(diff.get("severity")) == "breaking" - ] - if not breaking: + validation_issues: list[str] = [] + computed = assess_behavior_payload( + behavior, + expectations=behavior_expectations_from_evidence(evidence), + ) + supplied = behavior.get("summary") + required_summary_keys = ( + "status", + "breaking_count", + "changed_count", + "unchanged_count", + "contract_failure_count", + "probe_error_count", + "skipped_count", + "missing_comparison_count", + "malformed_count", + "reasons", + ) + count_keys = required_summary_keys[1:-1] + if not isinstance(supplied, Mapping): + validation_issues.append("behavior summary must be an object") + else: + status = supplied.get("status") + if status not in {"pass", "changed", "incomplete", "fail"}: + validation_issues.append(f"unknown behavior summary status {status!r}") + for key in count_keys: + value = supplied.get(key) + if type(value) is not int or value < 0: + validation_issues.append(f"behavior summary {key} must be a non-negative integer") + reasons = supplied.get("reasons") + if not isinstance(reasons, list) or not all(isinstance(reason, str) for reason in reasons): + validation_issues.append("behavior summary reasons must be an array of strings") + for key in required_summary_keys: + if key not in supplied: + validation_issues.append(f"behavior summary is missing {key}") + elif supplied.get(key) != computed.get(key): + validation_issues.append( + f"behavior summary {key} contradicts raw evidence: " + f"supplied={supplied.get(key)!r} computed={computed.get(key)!r}" + ) + + status = str(computed.get("status") or "incomplete") + if not validation_issues and status in {"pass", "changed"}: return dict(architecture) + result = dict(architecture) result["safe_to_implement"] = False result["manual_design_required"] = True findings = list(result.get("findings") or []) + evidence_items = validation_issues + [ + str(reason) for reason in computed.get("reasons", []) if reason + ] findings.append( { "classification": "manual-design-required", - "summary": "Candidate SDK behavior probes detected breaking adapter-contract drift.", - "evidence": [ - f"{diff.get('package')}:{diff.get('probe')} {diff.get('summary')}" - for diff in breaking - ], + "summary": ("SDK behavior evidence is incomplete or failed deterministic validation."), + "evidence": evidence_items or [f"computed behavior status is {status}"], } ) result["findings"] = findings uncertainty = list(result.get("uncertainty") or []) - uncertainty.append("Breaking behavior probes require manual adapter design review.") + uncertainty.append( + "Behavior evidence must be complete, internally consistent, and non-breaking " + "before implementation." + ) result["uncertainty"] = uncertainty return result @@ -498,16 +568,6 @@ def with_manual_design_gate(architecture: Mapping[str, Any]) -> dict[str, Any]: return result -def _refresh_update_packages(evidence: Mapping[str, Any]) -> tuple[str, ...]: - preview = evidence.get("refresh_preview") - if not isinstance(preview, Mapping): - return () - text = f"{preview.get('stdout') or ''}\n{preview.get('stderr') or ''}" - return tuple( - sorted(set(re.findall(r"Update\s+([A-Za-z0-9_.-]+)\s+v\S+\s+->\s+v\S+", text))) - ) - - def _compact_stage_output(value: Mapping[str, Any]) -> dict[str, Any]: return {key: _compact_stage_value(item) for key, item in value.items()} @@ -554,8 +614,9 @@ def _stage_system_prompt(stage: str, schema: JsonSchema) -> str: "Do not mark manual_design_required, unsafe, or review rejection solely " "because public top-level symbols were added or removed when behavior " "probes pass before and after and there is no adapter-source evidence " - "that the removed symbols are used. Breaking behavior_diffs, missing " - "candidate API diffs, unavailable required release-note evidence, " + "that the removed symbols are used. Failed, incomplete, malformed, or " + "internally inconsistent behavior evidence; missing exact candidate API " + "transitions; unavailable required release-note evidence; " "reviewer-identified unsupported vendor behavior, or recursive " "runtime-contract impact remain hard blockers. Release-note status found " "is direct release-note evidence. Status no-matching-version is source " diff --git a/tests/test_sdk_evolution_agent.py b/tests/test_sdk_evolution_agent.py index b3dfec2..6250f59 100644 --- a/tests/test_sdk_evolution_agent.py +++ b/tests/test_sdk_evolution_agent.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import os import stat import subprocess @@ -29,26 +30,39 @@ from examples.sdk_evolution_agent import auth as auth_module from examples.sdk_evolution_agent.auth import CodexAuthResult, ensure_codex_sdk_auth from examples.sdk_evolution_agent.behavior import ( + assess_behavior_payload, collect_behavior_evidence, diff_behavior_results, probe_candidate_in_venv, + summarize_behavior, ) from examples.sdk_evolution_agent.cli import RunOptions, _collect_snapshots, parse_args, run_agent from examples.sdk_evolution_agent.collectors import ( + ResolverTransition, build_refresh_preview_command, collect_evidence, cutoff_free_env, + parse_refresh_transitions, run_lock_update, run_refresh_preview, ) from examples.sdk_evolution_agent.current_state import build_current_state -from examples.sdk_evolution_agent.models import ApiSnapshot, CommandResult, RunContext, SourceRef +from examples.sdk_evolution_agent.models import ( + ApiSnapshot, + BehaviorDiff, + BehaviorProbeResult, + CommandResult, + RunContext, + SourceRef, + to_jsonable, +) from examples.sdk_evolution_agent.pr import build_draft_pr_body from examples.sdk_evolution_agent.release_notes import ( _fetch_source_text, _format_github_discussions_index, collect_release_notes, ) +from examples.sdk_evolution_agent.report import render_markdown_report, write_run_report from examples.sdk_evolution_agent.schemas import ( DIRECTION_ANALYSIS_SCHEMA, SchemaValidationError, @@ -129,6 +143,47 @@ def runner( assert result.removed_env == ("UV_EXCLUDE_NEWER",) +def test_refresh_transition_parser_reads_exact_stdout_and_stderr_updates() -> None: + transitions = parse_refresh_transitions( + { + "refresh_preview": { + "stdout": ( + "Update claude-agent-sdk v0.2.96 -> v0.2.106\n" + "Update openai-codex v0.1.0b3 -> v0.1.0rc1\n" + ), + "stderr": ( + "Update google-antigravity v0.1.2 -> v0.1.4\n" + "Update claude-agent-sdk v0.2.96 -> v0.2.106\n" + ), + } + } + ) + + assert transitions == ( + ResolverTransition("claude-agent-sdk", "0.2.96", "0.2.106"), + ResolverTransition("google-antigravity", "0.1.2", "0.1.4"), + ResolverTransition("openai-codex", "0.1.0b3", "0.1.0rc1"), + ) + + +def test_refresh_transition_parser_rejects_partial_or_decorated_lines() -> None: + transitions = parse_refresh_transitions( + { + "refresh_preview": { + "stdout": ( + "NotUpdate claude-agent-sdk v1 -> v2\n" + "Update claude-agent-sdk v1 -> v2 trailing\n" + "prefix Update claude-agent-sdk v1 -> v2\n" + "Update claude-agent-sdk-extra v1 -> v2\n" + ), + "stderr": "", + } + } + ) + + assert transitions == (ResolverTransition("claude-agent-sdk-extra", "1", "2"),) + + def test_lock_update_uses_targeted_packages_and_clean_env( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -325,9 +380,7 @@ def test_fetch_source_text_uses_plain_fetch_without_token( url="https://github.com/o/r/discussions/categories/announcements", ) - text = _fetch_source_text( - source, fetcher=lambda url: "html fallback", use_github_graphql=True - ) + text = _fetch_source_text(source, fetcher=lambda url: "html fallback", use_github_graphql=True) assert text == "html fallback" @@ -419,7 +472,7 @@ def test_behavior_diffs_track_candidate_contract_changes() -> None: ], {}, ) - assert behavior["summary"]["status"] == "pass" + assert behavior["summary"]["status"] == "incomplete" diffs = diff_behavior_results( [ @@ -495,6 +548,14 @@ def isolated(package: str, version: str, *, scope: str = "candidate"): ("claude-agent-sdk", "0.2.106", "candidate"), ] assert behavior["diffs"][0].severity == "changed" + assert behavior["expected_packages"] == ["claude-agent-sdk"] + assert behavior["expected_transitions"] == [ + { + "package": "claude-agent-sdk", + "from_version": "0.2.96", + "to_version": "0.2.106", + } + ] def test_behavior_evidence_uses_locked_baseline_when_sdk_not_installed( @@ -607,6 +668,58 @@ def isolated(package: str, version: str, *, scope: str = "candidate"): assert calls == [("claude-agent-sdk", "0.2.106")] +def test_behavior_no_update_is_incomplete_when_ambient_version_drifted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "examples.sdk_evolution_agent.behavior.probe_current_package", + lambda package, *, version=None: ( + _probe(package, version, "current-environment", "pass", {"missing": []}), + ), + ) + + behavior = collect_behavior_evidence( + [ + { + "name": "claude-agent-sdk", + "locked_version": "1.0.0", + "installed_version": "2.0.0", + } + ], + {}, + ) + + assert behavior["expected_baselines"] == {"claude-agent-sdk": "1.0.0"} + assert behavior["summary"]["status"] == "incomplete" + assert behavior["summary"]["missing_comparison_count"] == 1 + assert "observed: 2.0.0" in " ".join(behavior["summary"]["reasons"]) + + +def test_behavior_no_update_passes_when_ambient_matches_locked_baseline( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "examples.sdk_evolution_agent.behavior.probe_current_package", + lambda package, *, version=None: ( + _probe(package, version, "current-environment", "pass", {"missing": []}), + ), + ) + + behavior = collect_behavior_evidence( + [ + { + "name": "claude-agent-sdk", + "locked_version": "1.0.0", + "installed_version": "1.0.0", + } + ], + {}, + ) + + assert behavior["summary"]["status"] == "pass" + assert behavior["summary"]["missing_comparison_count"] == 0 + + def test_behavior_evidence_reuses_matching_ambient_baseline( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -697,14 +810,22 @@ def test_candidate_behavior_probe_scrubs_subprocess_env( def fake_run(args: Any, **kwargs: Any) -> Any: captured_envs.append(kwargs.get("env")) - return types.SimpleNamespace(stdout="[]", returncode=0) + return types.SimpleNamespace( + stdout=( + '[{"package":"claude-agent-sdk","version":"9.9.9",' + '"scope":"candidate","probe":"adapter-contract","status":"pass",' + '"summary":"ok","details":{}}]' + ), + returncode=0, + ) monkeypatch.setenv("ARK_FAKE_SECRET", "not-for-candidates") monkeypatch.setattr("examples.sdk_evolution_agent.behavior.subprocess.run", fake_run) results = probe_candidate_in_venv("claude-agent-sdk", "9.9.9") - assert results == () + assert len(results) == 1 + assert results[0].status == "pass" # venv create, pip install, probe script: every subprocess that touches the # freshly downloaded candidate runs with the scrubbed environment. assert len(captured_envs) == 3 @@ -714,7 +835,390 @@ def fake_run(args: Any, **kwargs: Any) -> Any: assert env.get("HOME") != os.environ.get("HOME") -def test_behavior_probe_guard_blocks_breaking_candidate_diff() -> None: +@pytest.mark.parametrize( + ("package", "failure_call", "error", "failure_step", "probe"), + [ + ( + "claude-agent-sdk", + 1, + subprocess.CalledProcessError( + 1, + ("python", "-m", "venv"), + stderr="venv failed " + ("x" * 1_000), + ), + "virtual-environment-creation", + "adapter-contract", + ), + ( + "openai-codex-cli-bin", + 2, + OSError("installer unavailable " + ("x" * 1_000)), + "package-installation", + "binary-distribution", + ), + ( + "google-antigravity", + 3, + subprocess.TimeoutExpired(("python", "-c", "probe"), 7), + "probe-execution", + "adapter-contract", + ), + ], +) +def test_candidate_behavior_probe_contains_subprocess_failures( + monkeypatch: pytest.MonkeyPatch, + package: str, + failure_call: int, + error: BaseException, + failure_step: str, + probe: str, +) -> None: + calls = 0 + + def fake_run(args: Any, **kwargs: Any) -> Any: + del args, kwargs + nonlocal calls + calls += 1 + if calls == failure_call: + raise error + return types.SimpleNamespace(stdout="", stderr="", returncode=0) + + monkeypatch.setattr("examples.sdk_evolution_agent.behavior.subprocess.run", fake_run) + + results = probe_candidate_in_venv(package, "9.9.9", timeout=7) + + assert len(results) == 1 + result = results[0] + assert result.package == package + assert result.version == "9.9.9" + assert result.scope == "candidate" + assert result.probe == probe + assert result.status == "fail" + assert result.details["failure_step"] == failure_step + assert result.details["error"] == result.summary + assert len(result.summary) <= 560 + + +@pytest.mark.parametrize( + "output", + [ + "not-json", + "[]", + ( + '[{"package":"wrong-sdk","version":"9.9.9","scope":"candidate",' + '"probe":"adapter-contract","status":"pass","summary":"ok","details":{}}]' + ), + ( + '[{"package":"claude-agent-sdk","version":"9.9.9","scope":"candidate",' + '"probe":"adapter-contract","status":"pass","summary":null,"details":{}}]' + ), + ( + '[{"package":"claude-agent-sdk","version":"9.9.9","scope":"candidate",' + '"probe":"adapter-contract","status":"unknown","summary":"ok","details":{}}]' + ), + ( + '[{"package":"claude-agent-sdk","version":"9.9.9","scope":"candidate",' + '"probe":"adapter-contract","status":"fail","summary":"missing",' + '"details":{"missing":7}}]' + ), + ], +) +def test_candidate_behavior_probe_contains_malformed_output( + monkeypatch: pytest.MonkeyPatch, + output: str, +) -> None: + def fake_run(args: Any, **kwargs: Any) -> Any: + del args, kwargs + return types.SimpleNamespace(stdout=output, stderr="", returncode=0) + + monkeypatch.setattr("examples.sdk_evolution_agent.behavior.subprocess.run", fake_run) + + (result,) = probe_candidate_in_venv("claude-agent-sdk", "9.9.9") + + assert result.package == "claude-agent-sdk" + assert result.version == "9.9.9" + assert result.scope == "candidate" + assert result.probe == "adapter-contract" + assert result.status == "fail" + assert result.details["failure_step"] == "probe-output-validation" + assert "malformed probe output" in result.summary + assert len(result.summary) <= 560 + + +def test_behavior_summary_accepts_valid_no_update_and_unchanged_evidence() -> None: + baseline = _probe( + "claude-agent-sdk", + "1.0.0", + "current-environment", + "pass", + {"missing": []}, + ) + no_update = summarize_behavior( + [baseline], + [], + expected_packages=["claude-agent-sdk"], + expected_transitions=[], + expected_baselines={"claude-agent-sdk": "1.0.0"}, + ) + assert no_update["status"] == "pass" + assert no_update["missing_comparison_count"] == 0 + + candidate = _probe( + "claude-agent-sdk", + "2.0.0", + "candidate", + "pass", + {"missing": []}, + ) + diffs = diff_behavior_results([baseline, candidate]) + unchanged = summarize_behavior( + [baseline, candidate], + diffs, + expected_packages=["claude-agent-sdk"], + expected_transitions=[ResolverTransition("claude-agent-sdk", "1.0.0", "2.0.0")], + expected_baselines={"claude-agent-sdk": "1.0.0"}, + ) + assert unchanged["status"] == "pass" + assert unchanged["unchanged_count"] == 1 + + +def test_behavior_summary_reports_changed_only_for_complete_evidence() -> None: + baseline = _probe( + "claude-agent-sdk", + "1.0.0", + "current-baseline", + "pass", + {"missing": [], "required_fields": ["a"]}, + ) + candidate = _probe( + "claude-agent-sdk", + "2.0.0", + "candidate", + "pass", + {"missing": [], "required_fields": ["a", "b"]}, + ) + diffs = diff_behavior_results([baseline, candidate]) + + summary = summarize_behavior( + [baseline, candidate], + diffs, + expected_packages=["claude-agent-sdk"], + expected_transitions=[ResolverTransition("claude-agent-sdk", "1.0.0", "2.0.0")], + expected_baselines={"claude-agent-sdk": "1.0.0"}, + ) + + assert summary["status"] == "changed" + assert summary["changed_count"] == 1 + + +def test_behavior_summary_marks_errors_skips_missing_and_malformed_as_incomplete() -> None: + transition = ResolverTransition("claude-agent-sdk", "1.0.0", "2.0.0") + baseline = _probe("claude-agent-sdk", "1.0.0", "current-baseline", "pass", {"missing": []}) + error = _probe( + "claude-agent-sdk", + "2.0.0", + "candidate", + "fail", + {"error": "probe crashed", "failure_step": "probe-execution"}, + ) + skipped = _probe("claude-agent-sdk", "2.0.0", "candidate", "skip", {"reason": "opt-in"}) + wrong_version = _probe("claude-agent-sdk", "3.0.0", "candidate", "pass", {"missing": []}) + + probe_error = summarize_behavior( + [baseline, error], + [], + expected_packages=["claude-agent-sdk"], + expected_transitions=[transition], + expected_baselines={"claude-agent-sdk": "1.0.0"}, + ) + probe_skip = summarize_behavior( + [baseline, skipped], + [], + expected_packages=["claude-agent-sdk"], + expected_transitions=[transition], + expected_baselines={"claude-agent-sdk": "1.0.0"}, + ) + missing = summarize_behavior( + [baseline, wrong_version], + diff_behavior_results([baseline, wrong_version]), + expected_packages=["claude-agent-sdk"], + expected_transitions=[transition], + expected_baselines={"claude-agent-sdk": "1.0.0"}, + ) + malformed = summarize_behavior( + [{"package": "claude-agent-sdk", "summary": None}], + [], + expected_packages=["claude-agent-sdk"], + expected_transitions=[], + expected_baselines={"claude-agent-sdk": "1.0.0"}, + ) + + assert probe_error["status"] == "incomplete" + assert probe_error["probe_error_count"] == 1 + assert probe_skip["status"] == "incomplete" + assert probe_skip["skipped_count"] == 1 + assert missing["status"] == "incomplete" + assert missing["missing_comparison_count"] == 1 + assert malformed["status"] == "incomplete" + assert malformed["malformed_count"] == 1 + + +def test_behavior_summary_contains_malformed_nested_contract_details() -> None: + transition = ResolverTransition("claude-agent-sdk", "1.0.0", "2.0.0") + baseline = _probe( + "claude-agent-sdk", "1.0.0", "current-baseline", "pass", {"missing": []} + ) + malformed_candidate = BehaviorProbeResult( + package="claude-agent-sdk", + version="2.0.0", + scope="candidate", + probe="adapter-contract", + status="fail", + summary="missing fields", + details={"missing": 7}, + ) + supplied_diffs = diff_behavior_results([baseline, malformed_candidate]) + + summary = summarize_behavior( + [baseline, malformed_candidate], + supplied_diffs, + expected_packages=["claude-agent-sdk"], + expected_transitions=[transition], + expected_baselines={"claude-agent-sdk": "1.0.0"}, + ) + + assert summary["status"] == "incomplete" + assert summary["malformed_count"] == 2 + assert "details.missing" in " ".join(summary["reasons"]) + + +def test_behavior_summary_treats_missing_fields_plus_error_as_contract_failure() -> None: + transition = ResolverTransition("claude-agent-sdk", "1.0.0", "2.0.0") + baseline = _probe( + "claude-agent-sdk", "1.0.0", "current-baseline", "pass", {"missing": []} + ) + candidate = _probe( + "claude-agent-sdk", + "2.0.0", + "candidate", + "fail", + {"missing": ["required_field"], "error": "secondary diagnostic"}, + ) + diffs = diff_behavior_results([baseline, candidate]) + + summary = summarize_behavior( + [baseline, candidate], + diffs, + expected_packages=["claude-agent-sdk"], + expected_transitions=[transition], + expected_baselines={"claude-agent-sdk": "1.0.0"}, + ) + + assert diffs[0].severity == "breaking" + assert summary["status"] == "fail" + assert summary["contract_failure_count"] == 1 + assert summary["probe_error_count"] == 0 + + +def test_behavior_summary_requires_complete_probe_sets_for_transition() -> None: + transition = ResolverTransition("claude-agent-sdk", "1.0.0", "2.0.0") + results = [ + _probe( + "claude-agent-sdk", + "1.0.0", + "current-baseline", + "pass", + {"missing": []}, + ), + BehaviorProbeResult( + package="claude-agent-sdk", + version="1.0.0", + scope="current-baseline", + probe="package-import", + status="pass", + summary="imported", + details={}, + ), + _probe( + "claude-agent-sdk", + "2.0.0", + "candidate", + "pass", + {"missing": []}, + ), + ] + diffs = diff_behavior_results(results) + + summary = summarize_behavior( + results, + diffs, + expected_packages=["claude-agent-sdk"], + expected_transitions=[transition], + expected_baselines={"claude-agent-sdk": "1.0.0"}, + ) + + assert {diff.probe for diff in diffs} == {"adapter-contract"} + assert summary["status"] == "incomplete" + assert summary["missing_comparison_count"] == 1 + + +def test_behavior_summary_status_precedence_is_fail_then_incomplete_then_changed() -> None: + baseline = _probe("claude-agent-sdk", "1.0.0", "current-baseline", "pass", {"missing": []}) + contract_failure = _probe( + "claude-agent-sdk", + "2.0.0", + "candidate", + "fail", + {"missing": ["required_field"]}, + ) + extra_skip = _probe("fake-sdk", "1.0.0", "candidate", "skip", {"reason": "missing"}) + failed = summarize_behavior( + [baseline, contract_failure, extra_skip], + diff_behavior_results([baseline, contract_failure, extra_skip]), + expected_packages=["claude-agent-sdk"], + expected_transitions=[ResolverTransition("claude-agent-sdk", "1.0.0", "2.0.0")], + expected_baselines={"claude-agent-sdk": "1.0.0"}, + ) + assert failed["status"] == "fail" + assert failed["contract_failure_count"] == 1 + assert failed["skipped_count"] == 1 + + changed_candidate = _probe( + "claude-agent-sdk", + "2.0.0", + "candidate", + "pass", + {"missing": [], "required_fields": ["new"]}, + ) + changed_diffs = diff_behavior_results([baseline, changed_candidate]) + incomplete = summarize_behavior( + [baseline, changed_candidate, extra_skip], + changed_diffs, + expected_packages=["claude-agent-sdk"], + expected_transitions=[ResolverTransition("claude-agent-sdk", "1.0.0", "2.0.0")], + expected_baselines={"claude-agent-sdk": "1.0.0"}, + ) + assert incomplete["status"] == "incomplete" + assert incomplete["changed_count"] == 1 + + +def test_behavior_probe_guard_blocks_complete_breaking_candidate_payload() -> None: + baseline = _probe( + "google-antigravity", "1.0.0", "current-baseline", "pass", {"missing": []} + ) + candidate = _probe( + "google-antigravity", + "2.0.0", + "candidate", + "fail", + {"missing": ["required_field"]}, + ) + transition = ResolverTransition("google-antigravity", "1.0.0", "2.0.0") + behavior = _behavior_payload( + [baseline, candidate], + expected_packages=["google-antigravity"], + expected_transitions=[transition], + ) guarded = with_behavior_probe_guard( { "findings": [], @@ -722,20 +1226,155 @@ def test_behavior_probe_guard_blocks_breaking_candidate_diff() -> None: "manual_design_required": False, "uncertainty": [], }, + _sdk_evidence( + package="google-antigravity", + locked_version="1.0.0", + installed_version="1.0.0", + candidate_version="2.0.0", + ), + behavior, + ) + + assert guarded["safe_to_implement"] is False + assert guarded["manual_design_required"] is True + assert behavior["summary"]["status"] == "fail" + assert "breaking" in " ".join(guarded["findings"][-1]["evidence"]) + + +def test_behavior_probe_guard_allows_valid_pass_and_changed_evidence() -> None: + architecture = { + "findings": [], + "safe_to_implement": True, + "manual_design_required": False, + "uncertainty": [], + } + baseline = _probe("claude-agent-sdk", "1.0.0", "current-baseline", "pass", {"missing": []}) + pass_payload = _behavior_payload( + [baseline], + expected_packages=["claude-agent-sdk"], + ) + changed_candidate = _probe( + "claude-agent-sdk", + "2.0.0", + "candidate", + "pass", + {"missing": [], "required_fields": ["new"]}, + ) + transition = ResolverTransition("claude-agent-sdk", "1.0.0", "2.0.0") + changed_payload = _behavior_payload( + [baseline, changed_candidate], + expected_packages=["claude-agent-sdk"], + expected_transitions=[transition], + ) + + assert ( + with_behavior_probe_guard(architecture, _sdk_evidence(), pass_payload)[ + "safe_to_implement" + ] + is True + ) + assert ( + with_behavior_probe_guard( + architecture, + _sdk_evidence(candidate_version="2.0.0"), + changed_payload, + )["safe_to_implement"] + is True + ) + + +def test_behavior_probe_guard_blocks_self_declared_empty_expectations() -> None: + behavior = _behavior_payload([], expected_packages=[]) + assert behavior["summary"]["status"] == "pass" + + guarded = with_behavior_probe_guard( { - "diffs": [ - { - "package": "google-antigravity", - "probe": "adapter-contract", - "severity": "breaking", - "summary": "Candidate probe changed from pass to fail.", - } - ] + "findings": [], + "safe_to_implement": True, + "manual_design_required": False, + "uncertainty": [], }, + _sdk_evidence(), + behavior, ) assert guarded["safe_to_implement"] is False assert guarded["manual_design_required"] is True + assert "contradicts deterministic evidence" in " ".join( + guarded["findings"][-1]["evidence"] + ) + + +def test_behavior_probe_guard_blocks_failed_incomplete_and_invalid_evidence() -> None: + architecture = { + "findings": [], + "safe_to_implement": True, + "manual_design_required": False, + "uncertainty": [], + } + baseline = _probe("claude-agent-sdk", "1.0.0", "current-baseline", "pass", {"missing": []}) + transition = ResolverTransition("claude-agent-sdk", "1.0.0", "2.0.0") + failed_payload = _behavior_payload( + [ + baseline, + _probe( + "claude-agent-sdk", + "2.0.0", + "candidate", + "fail", + {"missing": ["required_field"]}, + ), + ], + expected_packages=["claude-agent-sdk"], + expected_transitions=[transition], + ) + incomplete_payload = _behavior_payload( + [ + baseline, + _probe( + "claude-agent-sdk", + "2.0.0", + "candidate", + "skip", + {"reason": "opt-in"}, + ), + ], + expected_packages=["claude-agent-sdk"], + expected_transitions=[transition], + ) + missing_summary = _behavior_payload( + [baseline], + expected_packages=["claude-agent-sdk"], + ) + missing_summary.pop("summary") + unknown_status = _behavior_payload( + [baseline], + expected_packages=["claude-agent-sdk"], + ) + unknown_status["summary"]["status"] = "mystery" + contradictory = _behavior_payload( + [baseline], + expected_packages=["claude-agent-sdk"], + ) + contradictory["results"] = failed_payload["results"] + contradictory["expected_transitions"] = failed_payload["expected_transitions"] + malformed = _behavior_payload( + [baseline], + expected_packages=["claude-agent-sdk"], + ) + malformed["results"] = [{"package": "claude-agent-sdk", "summary": None}] + + for evidence, payload in ( + (_sdk_evidence(candidate_version="2.0.0"), failed_payload), + (_sdk_evidence(candidate_version="2.0.0"), incomplete_payload), + (_sdk_evidence(), missing_summary), + (_sdk_evidence(), unknown_status), + (_sdk_evidence(), contradictory), + (_sdk_evidence(), malformed), + ): + guarded = with_behavior_probe_guard(architecture, evidence, payload) + assert guarded["safe_to_implement"] is False + assert guarded["manual_design_required"] is True def test_current_state_artifact_paths_are_repo_relative(tmp_path: Path) -> None: @@ -750,6 +1389,7 @@ def test_current_state_artifact_paths_are_repo_relative(tmp_path: Path) -> None: report_root = tmp_path / "reports" / "sdk-evolution" / "run-1" report_root.mkdir(parents=True) (report_root / "evidence.json").write_text("{}", encoding="utf-8") + (report_root / "behavior_summary.json").write_text('{"status":"pass"}', encoding="utf-8") snapshots = report_root / "api_snapshots" snapshots.mkdir() (snapshots / "01-claude-agent-sdk.json").write_text("{}", encoding="utf-8") @@ -772,11 +1412,129 @@ def test_current_state_artifact_paths_are_repo_relative(tmp_path: Path) -> None: paths = [artifact["path"] for artifact in state["artifacts"].values()] assert "reports/sdk-evolution/run-1/evidence.json" in paths + assert "reports/sdk-evolution/run-1/behavior_summary.json" in paths assert "reports/sdk-evolution/run-1/api_snapshots/01-claude-agent-sdk.json" in paths + assert state["artifacts"]["behavior_summary.json"]["sha256"] assert all(not path.startswith("/") for path in paths) assert all("/private/tmp" not in path and "/tmp/" not in path for path in paths) +def test_report_exposes_snapshot_and_behavior_evidence_failures() -> None: + behavior = _behavior_payload( + [ + BehaviorProbeResult( + package="claude-agent-sdk", + version="2.0.0", + scope="candidate", + probe="adapter-contract", + status="fail", + summary="probe crashed", + details={"error": "probe crashed", "failure_step": "probe-execution"}, + ) + ], + expected_packages=["claude-agent-sdk"], + expected_transitions=[ResolverTransition("claude-agent-sdk", "1.0.0", "2.0.0")], + ) + + report = render_markdown_report( + config={"runtime": "fake", "implementation_enabled": False, "draft_pr": False}, + evidence=_sdk_evidence(candidate_version="2.0.0"), + snapshots=[ + { + "package": "claude-agent-sdk", + "version": "2.0.0", + "source": "isolated-venv", + "import_error": "candidate import failed\nwith details", + } + ], + api_diffs=[], + release_notes=[], + behavior=behavior, + current_state={"promotion": {"status": "skipped", "promoted": False}}, + direction={}, + architecture={ + "manual_design_required": True, + "recursive_self_adaptation_impact": False, + "safe_to_implement": False, + }, + implementation={}, + review={}, + ) + + assert "## API Snapshots" in report + assert "- Status: `incomplete`" in report + assert "- Snapshot count: `1`" in report + assert "- Import or execution errors: `1`" in report + assert "candidate import failed with details" in report + assert "## Behavior Probes" in report + assert "- Probe errors: `1`" in report + assert "- Missing comparisons: `1`" in report + assert "probe crashed" in report + + +def test_report_persists_and_renders_recomputed_behavior_summary(tmp_path: Path) -> None: + baseline = _probe( + "claude-agent-sdk", "1.0.0", "current-baseline", "pass", {"missing": []} + ) + transition = ResolverTransition("claude-agent-sdk", "1.0.0", "2.0.0") + behavior = _behavior_payload( + [ + baseline, + _probe( + "claude-agent-sdk", + "2.0.0", + "candidate", + "fail", + {"missing": ["required_field"]}, + ), + ], + expected_packages=["claude-agent-sdk"], + expected_transitions=[transition], + ) + behavior["summary"] = _behavior_payload( + [baseline], expected_packages=["claude-agent-sdk"] + )["summary"] + assert behavior["summary"]["status"] == "pass" + + report_root = tmp_path / "reports" / "run-1" + context = RunContext( + run_id="run-1", + workspace=tmp_path, + report_root=report_root, + runtime="fake", + event_log_path=report_root / "events.jsonl", + implementation_enabled=False, + draft_pr=False, + ) + report_path = write_run_report( + context, + config={"runtime": "fake", "implementation_enabled": False, "draft_pr": False}, + evidence=_sdk_evidence(candidate_version="2.0.0"), + snapshots=[], + api_diffs=[], + release_notes=[], + behavior=behavior, + current_state={"promotion": {"status": "skipped", "promoted": False}}, + direction={}, + architecture={ + "manual_design_required": True, + "recursive_self_adaptation_impact": False, + "safe_to_implement": False, + }, + implementation={}, + review={}, + ) + + persisted = json.loads( + (report_root / "behavior_summary.json").read_text(encoding="utf-8") + ) + report = report_path.read_text(encoding="utf-8") + assert persisted["status"] == "fail" + assert persisted["contract_failure_count"] == 1 + assert "- Status: `fail`" in report + assert "failed the required contract" in report + + def test_snapshot_and_diff_public_api(monkeypatch: pytest.MonkeyPatch) -> None: module = types.ModuleType("fake_sdk") @@ -1227,7 +1985,52 @@ def test_candidate_api_diff_guard_blocks_missing_update_diff() -> None: assert guarded["safe_to_implement"] is False assert guarded["manual_design_required"] is True - assert "missing api_diffs for google-antigravity" in guarded["findings"][-1]["evidence"] + assert ( + guarded["findings"][-1]["evidence"][0] + == "missing api_diffs for google-antigravity 0.1.2 -> 0.1.4" + ) + + +@pytest.mark.parametrize( + "api_diff", + [ + { + "package": "google-antigravity", + "from_version": "0.1.1", + "to_version": "0.1.4", + }, + { + "package": "google-antigravity", + "from_version": "0.1.2", + "to_version": "0.1.5", + }, + { + "package": "google-antigravity-extra", + "from_version": "0.1.2", + "to_version": "0.1.4", + }, + ], +) +def test_candidate_api_diff_guard_requires_exact_transition(api_diff: dict[str, str]) -> None: + guarded = with_candidate_api_diff_guard( + { + "findings": [], + "safe_to_implement": True, + "manual_design_required": False, + "uncertainty": [], + "self_adaptation_plan": [], + }, + { + "refresh_preview": { + "stdout": "", + "stderr": "Update google-antigravity v0.1.2 -> v0.1.4\n", + } + }, + [api_diff], + ) + + assert guarded["safe_to_implement"] is False + assert guarded["manual_design_required"] is True def test_candidate_api_diff_guard_accepts_empty_update_diff() -> None: @@ -1500,6 +2303,7 @@ async def test_run_agent_report_only_generates_artifacts( assert (report_path.parent / "api_diffs.json").exists() assert (report_path.parent / "behavior_probes.json").exists() assert (report_path.parent / "behavior_diffs.json").exists() + assert (report_path.parent / "behavior_summary.json").exists() assert (report_path.parent / "current_state.json").exists() assert (report_path.parent / "direction_analysis.json").exists() assert (report_path.parent / "architecture_decision.json").exists() @@ -1540,11 +2344,21 @@ def forbidden_candidate(package: str, version: str) -> ApiSnapshot: ) monkeypatch.setattr( "examples.sdk_evolution_agent.cli.collect_behavior_evidence", - lambda packages, updates, *, inspect_candidates=False: { - "results": [], - "diffs": [], - "summary": {"status": "pass"}, - }, + lambda packages, updates, *, inspect_candidates=False, expected_transitions=None: ( + _behavior_payload( + [ + _probe( + "claude-agent-sdk", + None, + "current-environment", + "pass", + {}, + ) + ], + expected_packages=["claude-agent-sdk"], + expected_transitions=expected_transitions, + ) + ), ) # Default run (no inspect_candidates) must never pip-install/import upstream code. @@ -1667,11 +2481,28 @@ async def test_run_agent_autonomous_pr_path( ) monkeypatch.setattr( "examples.sdk_evolution_agent.cli.collect_behavior_evidence", - lambda packages, updates, *, inspect_candidates=False: { - "results": [], - "diffs": [], - "summary": {"status": "pass"}, - }, + lambda packages, updates, *, inspect_candidates=False, expected_transitions=None: ( + _behavior_payload( + [ + _probe( + "claude-agent-sdk", + "0.2.1", + "current-environment", + "pass", + {"missing": []}, + ), + _probe( + "claude-agent-sdk", + "0.3.0", + "candidate", + "pass", + {"missing": []}, + ), + ], + expected_packages=["claude-agent-sdk"], + expected_transitions=expected_transitions, + ) + ), ) commands: list[tuple[str, ...]] = [] @@ -2062,13 +2893,11 @@ async def run(self, task: AgentTask) -> AgentResult: def _probe( package: str, - version: str, + version: str | None, scope: str, status: str, details: dict[str, Any], -): - from examples.sdk_evolution_agent.models import BehaviorProbeResult - +) -> BehaviorProbeResult: return BehaviorProbeResult( package=package, version=version, @@ -2080,6 +2909,76 @@ def _probe( ) +def _behavior_payload( + results: list[BehaviorProbeResult], + *, + diffs: list[BehaviorDiff] | None = None, + expected_packages: list[str] | None = None, + expected_transitions: list[ResolverTransition] | tuple[ResolverTransition, ...] | None = None, + expected_baselines: dict[str, str | None] | None = None, +) -> dict[str, Any]: + observed_diffs = diffs if diffs is not None else list(diff_behavior_results(results)) + packages = expected_packages or [] + transitions = list(expected_transitions or []) + baselines = dict(expected_baselines or {}) + for package in packages: + transition = next( + (item for item in transitions if item.package == package), + None, + ) + if transition is not None: + baselines.setdefault(package, transition.from_version) + continue + observation = next( + ( + result + for result in results + if result.package == package + and result.scope in {"current-baseline", "current-environment"} + ), + None, + ) + baselines.setdefault(package, observation.version if observation is not None else None) + payload: dict[str, Any] = to_jsonable( + { + "results": results, + "diffs": observed_diffs, + "expected_packages": packages, + "expected_transitions": transitions, + "expected_baselines": baselines, + } + ) + payload["summary"] = assess_behavior_payload(payload) + return payload + + +def _sdk_evidence( + *, + package: str = "claude-agent-sdk", + locked_version: str | None = "1.0.0", + installed_version: str | None = "1.0.0", + candidate_version: str | None = None, +) -> dict[str, Any]: + evidence: dict[str, Any] = { + "packages": [ + { + "name": package, + "locked_version": locked_version, + "installed_version": installed_version, + } + ] + } + if candidate_version is not None: + evidence["refresh_preview"] = { + "stdout": "", + "stderr": ( + f"Update {package} v{locked_version or installed_version} " + f"-> v{candidate_version}\n" + ), + } + return evidence + + def _fake_pypi(package: str) -> dict[str, Any]: assert package == "claude-agent-sdk" return {