diff --git a/docs/validation/mcp-shell-combined-cli-20261004.tar.gz b/docs/validation/mcp-shell-combined-cli-20261004.tar.gz new file mode 100644 index 0000000000..ecbf3b8740 Binary files /dev/null and b/docs/validation/mcp-shell-combined-cli-20261004.tar.gz differ diff --git a/docs/validation/mcp-shell-derived-diagnostic-20261004.rst b/docs/validation/mcp-shell-derived-diagnostic-20261004.rst new file mode 100644 index 0000000000..68ddd18ce2 --- /dev/null +++ b/docs/validation/mcp-shell-derived-diagnostic-20261004.rst @@ -0,0 +1,235 @@ +Closed-journal MCP shell exit1 investigation +========================================== + +Singer owns OpenHCS response/JSON diagnostic receiving; python-introspect's +declaration decoder is a shared dependency seam being coordinated with Dewey. +Root394/541 source/units and merged567 native semantics remain independent. + +Pinned currentmain2df0918353b47fa07d19fe4b6cc8b52b5e4c1695. Existing checkout +was reused on a new branch after567 closed; six foreign dirty gitlinks and all +untracked originals remain untouched. Open PRs394/404/472/494/518/522/541/468 +have no active dev-client shell/result implementation claim. Current394 file +census has no dev_client, dto/functions or serialization/json crossing. + +Original engineering567/public94-attempt01/mcp.stdout records eight public +operation receipts with empty visible errors; original shell ends terminal1. +No new client/native process or request was used to determine the cause. + +Actual original qualified target8513 decoder applied offline to all eight closed +receipts: seven decode without error; registration-status becomes +McpDevPayloadFailure with mcp_payload_invalid/ValueError: +CustomFunctionRegistrationObservation received undeclared field(s): outcome. + +Original python-introspect.dataclass_from_mapping only admits init=True fields. +Observation.outcome is declared init=False and derived from publication/saved +proofs in __post_init__; original to_jsonable emits all dataclass fields. +The same nominal declaration therefore serializes a field its decoder rejects. +McpDevToolResult.has_errors correctly sees the rejection; persistent shell +aggregates that returncode. The custom McpDevPayloadFailure JSON projector emits +only receipt, deleting rejection diagnostics from visible JSON. This explains +the exit1 without alleging native registration/status/close failure. + +Original read-only diagnostic script/logs remain under engineering567/ +public94-attempt01/DIAGNOSE-CLOSED-RESULTS01.py/.stdout/.stderr: terminal0, +4.12s,236672KiB peakRSS,Swap0; no sockets or source/image execution. +Actual original dev_client_core.py SHA256 +2aaaffec501e901352192ed35016caa6ce00122b429e41b3f7c8683eb3fd1aed; +target01/python_introspect/dataclass_projection.py SHA256 +82662ff8eaa4f2aeb3b33edd61abae57020a7f139c3aa396d82dcecf8b801160. + +Required relation and owner destination +--------------------------------------- + +One declaration must drive encode/decode of derived fields. Constructor inputs +remain constructor-owned; supplied derived wire values must be checked against +the actual constructed result, not dropped, assigned or allowed to override it. +Unknown fields and mismatched derived proofs must fail closed. Reuse the original +python-introspect decoder, not a consumer-specific outcome pop/type switch or +alternative codec. BOUND-1/2/8 apply at this declaration boundary. + +The existing McpDevToolResult/McpDevPayloadFailure owns failed-decode diagnostics +and original receipt. JSON, formatted output, returncode and persistent aggregate +must project that same owned failure, not silently serialize success while +retaining hidden exit1. No error suppression/--allow-error-payloads workaround. + +Acceptance after coherent implementation: original closed status receipt decodes +to the declared observation with exact matching proofs, shell returns0 for those +unchanged successful receipts; genuinely malformed/mismatched/new declaration +cases retain original receipt and visible typed diagnostics with nonzero exit. +Use complete relevant AST/dependency/consumer evidence before production edits; +focused original user entrypoint verification last, no mutation replay/new client. + +Working diagnostic checkpoint +----------------------------- + +Deleted the five-line McpDevPayloadFailure serializer that returned only the +native receipt. The original canonical dataclass serializer now exposes the +same declared receipt/errors record used by returncode and formatted output. +The existing failure declaration rejects an empty diagnostic cause. The original +McpDevToolResult decoder restores this common rejection record through the same +dataclass_from_mapping codec, so a JSON batch roundtrip keeps the original cause +once rather than hiding it or wrapping a second rejection. No raw-shape parser, +business-leaf switch, codec, status flag or registry was added. + +Source qualification used the existing viewer-retirement-519/run_source_controls.py +bootstrap: OpenHCS source activation first, then read-only paired startup and Qt +backing before their consumers. SHELL-DIAGNOSTIC-CONTROLS02 passed20, terminal0, +6.91s wall,270032KiB peakRSS,Swap0. It exercised strict producer decode, independent +capability/MRO behavior, visible JSON cause plus unchanged receipt, batch roundtrip, +and original transport failure ownership. No server/native operation was launched. +The previous CONTROLS01 collection failure from unmatched foreign Qt backing is +preserved, not rewritten. The two plugin-free pytest configuration warnings are +retained in the original output. + +Complete source evidence reused the original audit.Package/Repository tooling: +704 OpenHCS modules parsed with zero omissions;40 relevant full module ASTs, +plus12 original qualified python-introspect module ASTs with zero omissions. +SHELL-FAMILY01.jsonl/.stderr retain declarations, imports, writes and consumers. +Actual census terminal0,13.06s,538200KiB peakRSS,Swap0; this is source evidence, +not a complete NRA R1 ownership proof and not a <=512MiB claim. The transport +derived-field census finds Observation.outcome as the sole init=False field in +agent/MCP DTOs; internal cache/lock/thread init=False fields are not alternate +registration status authorities. + +Remaining: the shared python-introspect declaration decoder still rejects the +original successful derived outcome. Dewey was asked for the existing dependency +owner implementation/release; no foreign dependency checkout or gitlink was +edited. This diagnostic checkpoint makes that original cause visible, but does +not yet claim successful observation CLI roundtrip or corrected shell terminal0. +The original native positive and original shell terminal1 remain separate facts. + +Consumer closure and shared-code disposition +------------------------------------------- + +The related sampling, pipeline/artifact-plan and UI-action JSON assertions now +retain the entire original rejected receipt AND assert its typed diagnostic +projection, instead of asserting the removed diagnostic-erasing shape. No +success assertion or nonzero exit assertion was weakened. The real +McpDevClient.execute pipeline command check retains its original controlled +wire peer and production command/framing/rendering/returncode path. + +CONSUMERS03 retained its original terminal1:62 passed/11 failed. Five failures +identified additional old failure-shape assertions, now migrated. Four were the +existing sibling test-module import under importlib mode; the original test +directory is explicitly on PYTHONPATH for CONSUMERS04. Two were an existing +UI-test global serializer monkeypatch captured by subsequently imported knowledge +renderer aliases; the independent UI and pipeline/sampling qualifications are +kept distinct rather than changing production serialization to tolerate it. +CONSUMERS03's12 UI controls passed. CONSUMERS04's pipeline/sampling61 controls +passed, terminal0, including the affected real execute boundary. Original raw +failure and successful output/timing remain byte-exact in the archive. + +Original unmodified debt_census.py against main2df0918 and production8fbba870f, +exact sole changed production file openhcs/mcp/dev_client_core.py: all measured +debt deltas zero, code lines+3, zero parse omissions, terminal0,0.56s wall, +25016KiB peakRSS,Swap0. R0 is a syntax ratchet, not live/whole NRA proof. + +Dewey explicitly confirmed no released dependency checkout: the foreign +python-introspect source has active transitive borrowers even though its child +tree is clean. He forwarded the exact original-decoder requirement to Parent +integration. Singer has not edited that checkout or its gitlink. The remaining +shared hunk is dataclass_from_mapping's declared-field algorithm: admit all +declared names, construct only init fields, decode supplied derived values with +the same annotation mechanism, compare with actual post-construction owner +values, and reject contradictions. project_dataclass remains a constructor-field +projection that intentionally recomputes target-derived facts; it is not a wire +decoder and must not be turned into a second status authority. + +No installed CLI terminal0 claim yet: the original native reply needs that codec +integration before its successful derived outcome can roundtrip. The diagnostic +source checkpoint works independently and does not claim recovery/replay of any +UNKNOWN operation. Planck's disjoint Shapes geometry ownership was confirmed; +522 selection/lifecycle and541 Root units ownership remain untouched. + +Reviewed dependency and actual CLI receiving +------------------------------------------- + +The preceding dependency-release blocker is historical. Parent owns the distinct +python-introspect checkout and PR10. Source reviewed at ee0b65f and final +ac7b2388ed800649a8d0157f00056471a466dd20: dataclasses.fields owns membership; +get_type_hints(include_extras=True) and the existing recursive annotation decoder +own input conversion; only init fields reach the constructor. Supplied non-init +values are compared with validated actual constructed values, never assigned. +Omitted derived fields are legal; contradictory values and unknown fields fail +closed. No outcome special case, mirrored field roster or second codec exists. +Equality retains the field owner's existing Python equality contract; this is +not an array-specific comparison extension. No concrete defect found in review. + +Read-only codec source selected after original OpenHCS source activation, before +any consumers, through the existing source bootstrap. Actual imported codec: +/home/ts/wt/python-introspect-derived-decode-20261004/src/python_introspect/ +dataclass_projection.py, SHA256 +d2be277051b070d73503025a3addcde8016787e18a369c45b60be0c0edb65fea. +The receiving process imported version0.1.15 at fad3cbb. Exact source diff to +ac7b238's0.1.16 changes only __version__ in src/python_introspect/__init__.py; +the tested decoder and all other source algorithms are byte-identical. No +foreign gitlink, installed dependency or existing scientific target was changed. + +SHELL-COMBINED-CLI05 retained terminal1:17 source controls passed, including the +new independent derived declaration using inherited cooperative describe hooks, +omitted derived fields and contradiction rejection without generic consumers +being edited. Four receiving cases failed before wire dispatch because the new +fixture mistakenly supplied --no-resident-server instead of the original +--no-resident option. This was a known harness error, not native UNKNOWN. +Original log retained:7.24s wall,261568KiB peakRSS,Swap0. The corrected fixture +also consumes the real pretty-printed JSON documents through stdlib JSONDecoder. + +Only the four affected receiving cases ran in SHELL-COMBINED-CLI06. All passed: +normal dev_client.main -> persistent shell -> original McpDevClient.start/execute +and command/framing/DTO decoding -> canonical JSON -> shell aggregate returncode. +Only the transport context/peer is controlled, reusing the existing pipeline +ControlledWireSession. Process launches are forbidden by the fixture. Exact +original command arguments/tool order and complete saved replies are retained. +No old process was contacted, no source was evaluated, no native operation or +registration was replayed, and no new MCP/native/client subprocess was started. + +The eight original successful replies yield shell terminal0. The original +registration observation is the declared DTO with registered outcome, zero +errors, one published source and its exact persisted proof; canonical projection +equals the entire original status receipt. The three copied malformed replies +(contradictory outcome, boolean outcome, undeclared field) each yield terminal1, +visible mcp_payload_invalid/ValueError cause and the full original receipt with +the single explicit test mutation. Native receipt errors remain empty; local +decode errors remain a separate fact. Re-decoding canonical batches retains the +same cause once and nonzero status. + +Actual controls06 terminal0,4PASS,5.73s wall,254896KiB peakRSS,Swap0. Full canonical +CLI outputs and per-case statuses are in the raw stdout, not just test assertions. +No successful17-control repeat or7GiB installed cold import occurred. Before this +source batch, original resource check read9.3GiB available, critical warnings +for existing swap/disk, full-memory PSI avg10=.24 then .17. This receipt reports +actual measured resource cost, not a new hard cap or scientific admission rule. + +The real closed native TCP journey remains the accepted merged567 evidence; +this acceptance establishes its corrected source CLI decoding/rendering/exit +path with a controlled closed-reply peer. It does not claim a fresh installed +0.1.16 client/native journey or recovery of an uncertain mutation. Public +dependency0.1.16 publication and future bundle projection remain Dewey's release +ownership. Existing OpenHCS dependency floor is still >=0.1.14; original metadata +owner must carry the corrected minimum for ordinary future installation. + +Ordinary installation requirement +-------------------------------- + +Parent explicitly released the narrow pyproject.toml metadata hunk into579 +after python-introspect0.1.16 publication. The canonical production requirement +is now python-introspect>=0.1.16,<0.2, excluding earlier decoders that reject +derived fields and preserving the breaking-series bound. This supersedes the +historical floor dependency above. Dewey/518 received the exact requirement +coordination so later metadata integration must retain0.1.16 rather than0.1.15. +No gitlink, ObjectState requirement, vendoring or installed package changed. + +Dewey's existing PUBLISHED-PYPI-PROOF.rst records workflow37174368240SUCCESS at +merge430fbbeeb6c7e0a9274d3bf9b0998a10cae5a505 and exact public wheel/sdist source +identity. Publication is not installed receiving. The four-case CLI acceptance +and production R0 evidence above are unchanged; no full tests/build/import or +scientific target mutation was repeated for this one-line metadata correction. + +SHELL-METADATA-FLOOR07 uses the original read_project/read_release_candidate and +CandidateRequirementCompatibility owner, not another requirements parser: +0.1.14/0.1.15 excluded,0.1.16/0.1.17 admitted,0.2.0 excluded; actual candidate +floor/compatibility proofs true. Terminal0,0.19s wall,26444KiB peakRSS,Swap0. +Original raw command/output remains in engineering567/public94-attempt01. +Root's sole518 metadata ownership was rechecked via the current PR/comments; +the precise one-line hunk and future430 source-pin boundary were addressed +directly on518. His broader branch and release family remain unedited. diff --git a/docs/validation/mcp-shell-derived-diagnostic-controls-20261004.tar.gz b/docs/validation/mcp-shell-derived-diagnostic-controls-20261004.tar.gz new file mode 100644 index 0000000000..87e80ca013 Binary files /dev/null and b/docs/validation/mcp-shell-derived-diagnostic-controls-20261004.tar.gz differ diff --git a/openhcs/mcp/dev_client_core.py b/openhcs/mcp/dev_client_core.py index 2dc3ae1d8b..cdebef180c 100644 --- a/openhcs/mcp/dev_client_core.py +++ b/openhcs/mcp/dev_client_core.py @@ -407,10 +407,9 @@ class McpDevPayloadFailure: receipt: JsonValue errors: tuple[AgentError, ...] - -@to_jsonable.register(McpDevPayloadFailure) -def _jsonable_payload_failure(value: McpDevPayloadFailure) -> JsonValue: - return value.receipt + def __post_init__(self) -> None: + if not self.errors: + raise ValueError("A rejected MCP payload requires its diagnostic cause.") @dataclass(frozen=True, slots=True) @@ -451,7 +450,13 @@ def _decode_payload(payload, contracts): rejections.append( AgentError.from_exception("mcp_payload_invalid", error) ) - return McpDevPayloadFailure(payload, (*_agent_errors(payload), *rejections)) + # The local rejection is a declared transport record too. Its canonical + # dataclass projection retains both cause and original native receipt. + # Recover that record through the same codec, not another error parser. + try: + return dataclass_from_mapping(McpDevPayloadFailure, payload) + except (TypeError, ValueError): + return McpDevPayloadFailure(payload, (*_agent_errors(payload), *rejections)) def first_decoded_payload(self): """A missing or rejected payload is not a successful empty record.""" diff --git a/pyproject.toml b/pyproject.toml index ff8fd15e1f..94c4cf162b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,7 +102,7 @@ dependencies = [ "zmqruntime>=0.3.0,<0.4", "pycodify>=0.1.3,<0.2", "objectstate>=1.1.8,<2", - "python-introspect>=0.1.14,<0.2", + "python-introspect>=0.1.16,<0.2", "metaclass-registry>=0.2.2,<0.3", "arraybridge>=0.3.6,<0.4", "polystore>=0.3.0,<0.4", diff --git a/tests/unit/agent/test_mcp_dev_client_declared_decoder.py b/tests/unit/agent/test_mcp_dev_client_declared_decoder.py index 29eeebe3dc..814a97e9e0 100644 --- a/tests/unit/agent/test_mcp_dev_client_declared_decoder.py +++ b/tests/unit/agent/test_mcp_dev_client_declared_decoder.py @@ -1,7 +1,8 @@ """Original successful native receipt, replayed offline: NEVER start a process.""" -from dataclasses import dataclass +from dataclasses import dataclass, field import json from pathlib import Path +import sys import pytest @@ -9,7 +10,7 @@ from openhcs.agent.dto.common import AgentError, AgentResultEnvelope, SCHEMA_VERSION from openhcs.agent.dto.execution import RuntimeBootstrapState, RuntimeBootstrapHandle from openhcs.mcp.dev_client_core import ( - McpDevToolBatchResponse, McpDevToolResult, McpDevPayloadFailure, + McpDevToolBatchResponse, McpDevToolResult, McpDevPayloadFailure, McpDevServerSpec, state_surface_document, state_surface_payload, ui_bridge_operation_result, workflow_result_payload, ) @@ -70,6 +71,43 @@ def test_renderless_new_declaration_decodes_and_executes_cooperative_mro(): assert result.decoded_for_rendering().first_decoded_payload() is value +@dataclass(frozen=True, kw_only=True) +class DerivedRenderlessResult(RenderlessResult): + description: str = field(init=False) + + def __post_init__(self): + object.__setattr__(self, "description", self.describe()) + + +class DerivedRenderlessCapability(RenderlessCapability): + name = "openhcs_decoder_derived_case578" + cli_command = "decoder-derived-case578" + output_contract = DerivedRenderlessResult + + +def test_independent_derived_declaration_executes_existing_cooperative_hooks(): + original = DerivedRenderlessResult(schema_version=SCHEMA_VERSION, fact=DeclaredFact(value="owned")) + wire = to_jsonable(original) + result = McpDevToolResult(DerivedRenderlessCapability.name, False, (wire,)).decoded_for_rendering() + value = result.first_decoded_payload() + assert value == original and value.description == "independent:owned" + assert value.describe() == "independent:owned" + assert not result.has_errors() + del wire["description"] + omitted = McpDevToolResult(DerivedRenderlessCapability.name, False, (wire,)).decoded_for_rendering() + assert omitted.first_decoded_payload() == original + + +def test_independent_derived_claim_cannot_override_cooperative_owner(): + original = DerivedRenderlessResult(schema_version=SCHEMA_VERSION, fact=DeclaredFact(value="owned")) + wire = {**to_jsonable(original), "description": "override"} + result = McpDevToolResult(DerivedRenderlessCapability.name, False, (wire,)).decoded_for_rendering() + assert result.has_errors() and result.first_decoded_payload() is None + assert isinstance(result.payloads[0], McpDevPayloadFailure) + assert result.payloads[0].receipt == wire + assert "disagrees" in result.diagnostic_errors()[0].message + + def test_renderless_malformed_record_is_rejected_with_original_receipt(): raw = {"schema_version": SCHEMA_VERSION, "fact": {}} result = McpDevToolResult(RenderlessCapability.name, False, (raw,)).decoded_for_rendering() @@ -79,6 +117,28 @@ def test_renderless_malformed_record_is_rejected_with_original_receipt(): assert result.decoded_for_rendering().payloads[0] is result.payloads[0] +def test_json_rejection_preserves_cause_receipt_and_original_batch_roundtrip(): + raw = {"schema_version": SCHEMA_VERSION, "fact": {}} + result = McpDevToolResult(RenderlessCapability.name, False, (raw,)).decoded_for_rendering() + batch = McpDevToolBatchResponse.from_results(McpDevServerSpec(sys.executable), (result,)) + wire = to_jsonable(batch) + rejection = wire["results"][0]["payloads"][0] + assert rejection["receipt"] == raw + assert rejection["errors"] == to_jsonable(result.diagnostic_errors()) + assert rejection["errors"][0]["code"] == "mcp_payload_invalid" + restored = McpDevToolBatchResponse.for_rendering(wire) + assert restored.has_errors() + assert restored.diagnostic_errors() == batch.diagnostic_errors() + assert len(restored.diagnostic_errors()) == 1 + assert restored.results[0].payloads[0].receipt == raw + assert isinstance(restored.results[0].payloads[0], McpDevPayloadFailure) + + +def test_rejection_declaration_cannot_claim_failure_without_a_cause(): + with pytest.raises(ValueError, match="diagnostic cause"): + McpDevPayloadFailure(receipt={}, errors=()) + + @pytest.mark.parametrize("raw", ( {"schema_version": SCHEMA_VERSION, "fact": {"value": "owned"}, "unknown": True}, {"schema_version": SCHEMA_VERSION, "fact": {"value": "owned", "unknown": True}}, diff --git a/tests/unit/agent/test_mcp_dev_client_pipeline_results.py b/tests/unit/agent/test_mcp_dev_client_pipeline_results.py index 19d34b9c38..6a12dcf66c 100644 --- a/tests/unit/agent/test_mcp_dev_client_pipeline_results.py +++ b/tests/unit/agent/test_mcp_dev_client_pipeline_results.py @@ -439,7 +439,9 @@ def test_malformed_contract_is_explicit_and_keeps_entire_receipt(broken): failed = response.results[0].payloads[0] assert isinstance(failed, McpDevPayloadFailure) assert failed.receipt == broken - assert to_jsonable(failed) == broken + rejection = to_jsonable(failed) + assert rejection["receipt"] == broken + assert rejection["errors"] == to_jsonable(failed.errors) assert response.has_errors() rendered = PipelineArtifactPlanRenderer.render(response) assert "mcp_payload_invalid" in rendered and "unavailable" in rendered @@ -793,7 +795,13 @@ def forbidden_raw_failure_scan(value): finally: client.close() assert execution.returncode == int(malformed) - assert execution.payload["results"][0]["payloads"][0] == receipt + payload = execution.payload["results"][0]["payloads"][0] + if malformed: + assert payload["receipt"] == receipt + assert len(payload["errors"]) == 1 + assert payload["errors"][0]["code"] == "mcp_payload_invalid" + else: + assert payload == receipt assert ( "mcp_payload_invalid" if malformed else "Owned step" ) in execution.rendered_output diff --git a/tests/unit/agent/test_mcp_dev_client_sampling_results.py b/tests/unit/agent/test_mcp_dev_client_sampling_results.py index b76cdd2550..b9c853ce8c 100644 --- a/tests/unit/agent/test_mcp_dev_client_sampling_results.py +++ b/tests/unit/agent/test_mcp_dev_client_sampling_results.py @@ -190,7 +190,9 @@ def test_malformed_receipt_never_becomes_successful_empty_sample(receipt): response = McpDevToolBatchResponse.from_results(McpDevServerSpec(sys.executable), (result,)) assert isinstance(result.payloads[0], McpDevPayloadFailure) assert result.payloads[0].receipt == receipt - assert to_jsonable(response)["results"][0]["payloads"][0] == receipt + rejection = to_jsonable(response)["results"][0]["payloads"][0] + assert rejection["receipt"] == receipt + assert rejection["errors"] == to_jsonable(result.diagnostic_errors()) text = PlateImageSampleRenderer.render(response) assert "mcp_payload_invalid" in text assert "Image:" not in text diff --git a/tests/unit/agent/test_mcp_dev_client_ui_action_results.py b/tests/unit/agent/test_mcp_dev_client_ui_action_results.py index ae9c1b3980..1c6066c40a 100644 --- a/tests/unit/agent/test_mcp_dev_client_ui_action_results.py +++ b/tests/unit/agent/test_mcp_dev_client_ui_action_results.py @@ -157,7 +157,9 @@ async def controlled_wire(args): assert dev_client.main(["selected-workflow", "run_plate"]) == 1 assert "mcp_payload_invalid" in capsys.readouterr().out assert dev_client.main(["selected-workflow", "run_plate", "--json"]) == 1 - assert json.loads(capsys.readouterr().out)["results"][0]["payloads"][0] == malformed + rejection = json.loads(capsys.readouterr().out)["results"][0]["payloads"][0] + assert rejection["receipt"] == malformed + assert rejection["errors"] == to_jsonable(result.diagnostic_errors()) def test_poll_presentation_rejects_row_missing_declared_state_field(): diff --git a/validation/registration-observation-567/test_closed_cli_roundtrip.py b/validation/registration-observation-567/test_closed_cli_roundtrip.py new file mode 100644 index 0000000000..3b5532695b --- /dev/null +++ b/validation/registration-observation-567/test_closed_cli_roundtrip.py @@ -0,0 +1,107 @@ +"""Receive closed engineering replies offline through the original CLI. + +Only the wire peer is controlled; never reconnect to or replay a native mutation. +""" +from contextlib import asynccontextmanager +from io import StringIO +import json +from pathlib import Path +import shlex + +import pytest + +from test_mcp_dev_client_pipeline_results import ControlledWireSession +from openhcs.agent.dto.functions import CustomFunctionRegistrationObservation +from openhcs.mcp import dev_client +from openhcs.mcp.dev_client_core import McpDevPayloadFailure, McpDevToolBatchResponse +from openhcs.serialization.json import to_jsonable + + +ROOT = Path('/home/ts/wt/openhcs-issue-batch-20260929/engineering567/public94-attempt01') + + +@pytest.mark.parametrize('mutation,message', ( + (None, None), + ({'outcome': 'not_observed'}, 'disagrees with its constructed value'), + ({'outcome': True}, 'must be one of'), + ({'extra': 'not declared'}, 'undeclared field'), +)) +def test_saved_native_success_and_declared_contradictions_through_normal_shell( + monkeypatch, capsys, mutation, message, +): + receipts = json.loads((ROOT / 'PUBLIC-REPLIES-FINAL01.json').read_text()) + original_lines = (ROOT / 'ADMIN_REGISTRATION56794/author-workspace/output/runtime/mcp.stdin').read_text().splitlines() + commands = tuple(line for line in original_lines if line.startswith('call ')) + assert len(commands) == len(receipts) == 8 + assert tuple(shlex.split(line)[1] for line in commands) == tuple(row['tool'] for row in receipts) + status_index = next(index for index, row in enumerate(receipts) + if row['tool'] == 'openhcs_get_custom_function_registration_status') + original_status = json.loads(json.dumps(receipts[status_index]['payloads'][0])) + if mutation is not None: + commands = (commands[status_index],) + receipts = [receipts[status_index]] + receipts[0]['payloads'][0].update(mutation) + peer = ControlledWireSession(tuple(row['payloads'][0] for row in receipts)) + + @asynccontextmanager + async def closed_wire_peer(*args, **kwargs): + yield peer + + def forbidden_launch(*args, **kwargs): + raise AssertionError('Closed-reply acceptance must never launch a process') + + import asyncio + import subprocess + monkeypatch.setattr(asyncio, 'create_subprocess_exec', forbidden_launch) + monkeypatch.setattr(asyncio, 'create_subprocess_shell', forbidden_launch) + monkeypatch.setattr(subprocess, 'Popen', forbidden_launch) + monkeypatch.setattr(dev_client, 'open_mcp_dev_session', closed_wire_peer) + monkeypatch.setattr('sys.stdin', StringIO('\n'.join((*commands, 'exit', '')))) + # main -> persistent shell -> original client lifecycle/execute -> actual + # command/session/framing/DTO codec -> canonical JSON -> shell returncode. + terminal = dev_client.main(['shell', '--no-resident', '--no-prompt']) + output = capsys.readouterr() + assert not output.err + batches = [] + remaining = output.out.lstrip() + decoder = json.JSONDecoder() + while remaining: + batch, end = decoder.raw_decode(remaining) + batches.append(batch) + remaining = remaining[end:].lstrip() + assert len(batches) == len(commands) + assert tuple(call[0] for call in peer.calls) == tuple(row['tool'] for row in receipts) + for command, call in zip(commands, peer.calls): + arguments = shlex.split(command) + assert call[1] == json.loads(arguments[arguments.index('--arguments') + 1]) + if mutation is None: + assert terminal == 0 + for batch in batches: + restored = McpDevToolBatchResponse.for_rendering(batch) + assert not restored.has_errors() + assert not restored.diagnostic_errors() + restored_status = McpDevToolBatchResponse.for_rendering(batches[status_index]) + value = restored_status.results[0].first_decoded_payload() + assert isinstance(value, CustomFunctionRegistrationObservation) + assert to_jsonable(value) == original_status + assert value.outcome.value == 'registered' + assert len(value.published_sources) == 1 + assert value.persisted_source is not None + else: + assert terminal == 1 + rejection = batches[0]['results'][0]['payloads'][0] + assert rejection['receipt'] == {**original_status, **mutation} + assert rejection['receipt']['errors'] == [] + assert len(rejection['errors']) == 1 + cause = rejection['errors'][0] + assert cause['code'] == 'mcp_payload_invalid' + assert cause['exception_type'] == 'ValueError' + assert message in cause['message'] + restored = McpDevToolBatchResponse.for_rendering(batches[0]) + assert restored.has_errors() + assert isinstance(restored.results[0].payloads[0], McpDevPayloadFailure) + assert to_jsonable(restored.diagnostic_errors()) == rejection['errors'] + # Preserve full canonical user output and actual exit, not only assertions. + print(json.dumps({'case': mutation, 'terminal': terminal, + 'controlled_wire_calls': len(peer.calls)})) + print(output.out, end='')