muse-code-sdk 1.4.9__tar.gz → 1.4.10__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. {muse_code_sdk-1.4.9/src/muse_code_sdk.egg-info → muse_code_sdk-1.4.10}/PKG-INFO +1 -1
  2. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/pyproject.toml +1 -1
  3. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/src/muse_code/__init__.py +1 -1
  4. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/src/muse_code/fold/session_fold.py +21 -8
  5. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10/src/muse_code_sdk.egg-info}/PKG-INFO +1 -1
  6. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/tests/test_external_audience.py +188 -0
  7. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/tests/test_public_mirror_text.py +37 -0
  8. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/tests/test_session_fold.py +85 -10
  9. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/LICENSE +0 -0
  10. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/README.md +0 -0
  11. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/setup.cfg +0 -0
  12. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/src/muse_code/connection/__init__.py +0 -0
  13. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/src/muse_code/connection/connection.py +0 -0
  14. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/src/muse_code/connection/discovery.py +0 -0
  15. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/src/muse_code/connection/spawn.py +0 -0
  16. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/src/muse_code/connection/validation.py +0 -0
  17. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/src/muse_code/errors.py +0 -0
  18. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/src/muse_code/facade/__init__.py +0 -0
  19. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/src/muse_code/facade/approval.py +0 -0
  20. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/src/muse_code/facade/client.py +0 -0
  21. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/src/muse_code/facade/discarded.py +0 -0
  22. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/src/muse_code/facade/gap_fill.py +0 -0
  23. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/src/muse_code/facade/host_death.py +0 -0
  24. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/src/muse_code/facade/host_start.py +0 -0
  25. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/src/muse_code/facade/session.py +0 -0
  26. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/src/muse_code/facade/turn_handle.py +0 -0
  27. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/src/muse_code/facade/turn_submit.py +0 -0
  28. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/src/muse_code/fold/__init__.py +0 -0
  29. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/src/muse_code/fold/item_store.py +0 -0
  30. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/src/muse_code/fold/state_store.py +0 -0
  31. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/src/muse_code/pending/__init__.py +0 -0
  32. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/src/muse_code/pending/pending_command_set.py +0 -0
  33. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/src/muse_code/py.typed +0 -0
  34. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/src/muse_code/replay.py +0 -0
  35. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/src/muse_code/sync_facade.py +0 -0
  36. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/src/muse_code_sdk.egg-info/SOURCES.txt +0 -0
  37. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/src/muse_code_sdk.egg-info/dependency_links.txt +0 -0
  38. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/src/muse_code_sdk.egg-info/requires.txt +0 -0
  39. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/src/muse_code_sdk.egg-info/top_level.txt +0 -0
  40. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/tests/test_asyncio_cancellation.py +0 -0
  41. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/tests/test_compatibility_row.py +0 -0
  42. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/tests/test_connection.py +0 -0
  43. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/tests/test_connection_error_arms.py +0 -0
  44. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/tests/test_docs_generation.py +0 -0
  45. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/tests/test_facade_approval.py +0 -0
  46. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/tests/test_facade_gap_fill.py +0 -0
  47. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/tests/test_facade_open_same_chunk.py +0 -0
  48. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/tests/test_facade_persistent_approval.py +0 -0
  49. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/tests/test_facade_required_selection.py +0 -0
  50. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/tests/test_facade_turn.py +0 -0
  51. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/tests/test_host_discovery.py +0 -0
  52. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/tests/test_item_store.py +0 -0
  53. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/tests/test_manifests.py +0 -0
  54. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/tests/test_muse_serve_child.py +0 -0
  55. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/tests/test_pending_command_corpus.py +0 -0
  56. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/tests/test_pending_command_set.py +0 -0
  57. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/tests/test_pending_command_wire_binding.py +0 -0
  58. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/tests/test_permission_state_wire_types.py +0 -0
  59. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/tests/test_publish_shape.py +0 -0
  60. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/tests/test_source_closure.py +0 -0
  61. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/tests/test_spawn_handshake.py +0 -0
  62. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/tests/test_spawn_host_lost.py +0 -0
  63. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/tests/test_state_store.py +0 -0
  64. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/tests/test_sync_facade.py +0 -0
  65. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/tests/test_tolerance_corpus.py +0 -0
  66. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/tests/test_transcript_replay.py +0 -0
  67. {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.10}/tests/test_typing_gates.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: muse-code-sdk
3
- Version: 1.4.9
3
+ Version: 1.4.10
4
4
  Summary: Muse Code SDK for MSP: asyncio-first facade over the generated wire types (muse-code-msp), with a thin sync wrapper.
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/meta-models/muse-code-sdk
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
 
6
6
  [project]
7
7
  name = "muse-code-sdk"
8
- version = "1.4.9"
8
+ version = "1.4.10"
9
9
  description = "Muse Code SDK for MSP: asyncio-first facade over the generated wire types (muse-code-msp), with a thin sync wrapper."
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.10"
@@ -92,7 +92,7 @@ Python SDK reds this lane instead of drifting silently.
92
92
  # The lockstep rule: the release train bumps pyproject.toml, the
93
93
  # distribution-metadata authority; this constant is aligned at publication
94
94
  # time.
95
- __version__: Final[str] = "1.4.9"
95
+ __version__: Final[str] = "1.4.10"
96
96
 
97
97
  # The facade is imported last so the constants above are already bound: the
98
98
  # spawn path reads ``EXPECTED_SCHEMA_FINGERPRINT`` (lazily, at initialize
@@ -323,9 +323,12 @@ class IgnoredMissingParams:
323
323
  class IgnoredStaleFrame:
324
324
  """A recognized frame the fold deliberately dropped, state unchanged.
325
325
 
326
- A ``turn/started`` or ``turn/completed`` for a turn that already left
327
- ``running``, an ``approval/updated`` for an approval it never saw
328
- requested (or whose entry a resolution already deleted), or a redelivered
326
+ A ``turn/started``, ``turn/retryScheduled``, ``turn/modelRetryScheduled``
327
+ or ``turn/foregroundCompleted`` for a turn that already left ``running``;
328
+ a ``turn/completed`` for a ``retracted`` or ``unqueued`` turn; a declined
329
+ ``turn/retracted`` for a turn the fold has not seen; an
330
+ ``approval/updated`` for an approval it never saw requested (or whose
331
+ entry a resolution already deleted); or a redelivered
329
332
  ``approval/requested`` / ``userInput/requested`` for an approval/prompt
330
333
  that already has its durable terminal. Truthful by contract: a "pending"
331
334
  outcome here would make a renderer show an entry the fold does not hold.
@@ -714,15 +717,25 @@ class SessionFold:
714
717
 
715
718
  def _turn_retracted(self, params: Params) -> FoldOutcome:
716
719
  turn_id = str(params["turnId"])
720
+ # A declined retract means the submission stood, so the fold keeps
721
+ # the turn's prior state: a running turn stays running and its
722
+ # turn/completed still settles it, and a settled turn stays settled.
723
+ # The fold records the standing submission's commandId on a turn it
724
+ # holds. A declined retract carries no lifecycle fact, so for a turn
725
+ # the fold has not seen it creates no entry. Only an explicit
726
+ # "declined" counts; an absent or unrecognized outcome reads as
727
+ # accepted.
728
+ if params.get("outcome") == "declined":
729
+ held = self._turns.get(turn_id)
730
+ if held is None:
731
+ return IgnoredStaleFrame("turn/retracted", turn_id)
732
+ held.command_id = params.get("commandId")
733
+ return TurnFold(turn_id, held.state)
717
734
  turn = self._turn_for(turn_id)
718
735
  turn.command_id = params.get("commandId")
719
736
  # A retract is NOT a TurnTerminal; leaving `terminal` unset is the
720
737
  # whole point. On accepted, the retracted user message re-arrives via
721
- # `item/updated` with `retracted: true`, which the item half folds
722
- # (a declined outcome also lands here for now: the entry reads
723
- # retracted and the later turn/completed arrives as a stale frame,
724
- # so its terminal is dropped too - a known gap in this fold,
725
- # behavior unchanged).
738
+ # `item/updated` with `retracted: true`, which the item half folds.
726
739
  turn.state = "retracted"
727
740
  self._clear_active(turn_id)
728
741
  return TurnFold(turn_id, turn.state)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: muse-code-sdk
3
- Version: 1.4.9
3
+ Version: 1.4.10
4
4
  Summary: Muse Code SDK for MSP: asyncio-first facade over the generated wire types (muse-code-msp), with a thin sync wrapper.
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/meta-models/muse-code-sdk
@@ -21,6 +21,7 @@ so this file's own text stays clean under the tree tier.
21
21
  from __future__ import annotations
22
22
 
23
23
  import importlib.util
24
+ import io
24
25
  import re
25
26
  import subprocess
26
27
  import sys
@@ -674,3 +675,190 @@ def test_the_npm_tarball_mode_refuses_a_tarball_missing_its_readme(tmp_path: Pat
674
675
  result = _run_checker("--npm-tarball", str(tarball))
675
676
  assert result.returncode != 0
676
677
  assert "no package/README.md" in result.stderr
678
+
679
+
680
+
681
+ # A Rust source member's name, built at run time so this mirrored file carries
682
+ # no repository source-path token of its own.
683
+ LIB_RS = "src/lib" + ".r" + "s"
684
+
685
+ def _seeded_crate(
686
+ tmp_path: Path,
687
+ *,
688
+ readme_line: str = "Drive the host from a Tokio runtime.",
689
+ description: str = "A clean summary.",
690
+ files: dict[str, str] | None = None,
691
+ ) -> Path:
692
+ # A .crate is a gzip tarball under one <name>-<version>/ root.
693
+ entries = {
694
+ "README.md": f"# clean\n\n{readme_line}\n",
695
+ "Cargo.toml": (
696
+ '[package]\nname = "muse-code-sdk"\nversion = "1.0.0"\n'
697
+ f'description = "{description}"\n'
698
+ ),
699
+ LIB_RS: "//! A clean crate.\n",
700
+ }
701
+ entries.update(files or {})
702
+ crate = tmp_path / "muse-code-sdk-1.0.0.crate"
703
+ with tarfile.open(crate, "w:gz") as archive:
704
+ for name, text in entries.items():
705
+ data = text.encode("utf-8")
706
+ info = tarfile.TarInfo(f"muse-code-sdk-1.0.0/{name}")
707
+ info.size = len(data)
708
+ archive.addfile(info, io.BytesIO(data))
709
+ return crate
710
+
711
+
712
+ def _vcs_record(path_in_vcs: str) -> str:
713
+ return (
714
+ '{"git": {"sha1": "' + "a" * 40 + '", "dirty": false}, '
715
+ f'"path_in_vcs": "{path_in_vcs}"}}\n'
716
+ )
717
+
718
+
719
+ def test_a_clean_crate_passes(tmp_path: Path) -> None:
720
+ result = _run_checker("--crate", str(_seeded_crate(tmp_path)))
721
+ assert result.returncode == 0, result.stderr
722
+
723
+
724
+ @pytest.mark.parametrize(
725
+ ("site", "overrides"),
726
+ [
727
+ # A metadata-only class: the tree tier allows a closure path, the README
728
+ # (registry metadata) does not.
729
+ ("README.md", {"readme_line": LEAKS["client-path"]}),
730
+ ("Cargo.toml [package] description", {"description": LEAKS["client-path"]}),
731
+ (
732
+ LIB_RS + ": adr-citation",
733
+ {"files": {LIB_RS: "//! A clean crate.\n// Checked per " + LEAKS["adr-citation"] + ".\n"}},
734
+ ),
735
+ ],
736
+ ids=["crate-readme", "crate-description", "crate-source"],
737
+ )
738
+ def test_each_crate_site_is_refused(tmp_path: Path, site: str, overrides: dict) -> None:
739
+ result = _run_checker("--crate", str(_seeded_crate(tmp_path, **overrides)))
740
+ assert result.returncode != 0, f"a leak in the {site} site passed the crate gate"
741
+ assert site in result.stderr, f"the refusal must name the {site} site:\n{result.stderr}"
742
+
743
+
744
+ def test_the_crate_mode_passes_the_mirrors_commit_record_and_refuses_the_producers(
745
+ tmp_path: Path,
746
+ ) -> None:
747
+ # Cargo writes .cargo_vcs_info.json into every crate packaged from git: a
748
+ # commit SHA (no class here) and the crate's path in the repository. The
749
+ # tree tier allows the mirror's rust/clients/sdk-rs; the producing
750
+ # checkout's path is a dev-loop path, so a crate packaged there refuses.
751
+ mirror = _seeded_crate(
752
+ tmp_path, files={".cargo_vcs_info.json": _vcs_record("rust/clients/sdk-rs")}
753
+ )
754
+ result = _run_checker("--crate", str(mirror))
755
+ assert result.returncode == 0, result.stderr
756
+ producer_dir = tmp_path / "producer"
757
+ producer_dir.mkdir()
758
+ producer = _seeded_crate(
759
+ producer_dir,
760
+ files={".cargo_vcs_info.json": _vcs_record("projects/" + "tb" + "h/clients/sdk-rs")},
761
+ )
762
+ result = _run_checker("--crate", str(producer))
763
+ assert result.returncode != 0
764
+ assert ".cargo_vcs_info.json: dev-loop-path" in result.stderr, result.stderr
765
+
766
+
767
+ def test_the_crate_mode_refuses_a_crate_missing_its_readme(tmp_path: Path) -> None:
768
+ crate = tmp_path / "muse-code-sdk-1.0.0.crate"
769
+ data = b'[package]\nname = "muse-code-sdk"\nversion = "1.0.0"\n'
770
+ with tarfile.open(crate, "w:gz") as archive:
771
+ info = tarfile.TarInfo("muse-code-sdk-1.0.0/Cargo.toml")
772
+ info.size = len(data)
773
+ archive.addfile(info, io.BytesIO(data))
774
+ result = _run_checker("--crate", str(crate))
775
+ assert result.returncode != 0
776
+ assert "no muse-code-sdk-1.0.0/README.md" in result.stderr
777
+
778
+
779
+ def test_the_crate_mode_holds_module_docs_to_the_full_table(tmp_path: Path) -> None:
780
+ # The crate's documentation pages show each module's inner docs, so a
781
+ # metadata-only class refuses there; the same text in an ordinary comment
782
+ # is tree-tier and passes.
783
+ in_docs = _seeded_crate(tmp_path, files={LIB_RS: "//! See " + LEAKS["client-path"] + ".\n"})
784
+ result = _run_checker("--crate", str(in_docs))
785
+ assert result.returncode != 0
786
+ assert LIB_RS + " module docs: client-path" in result.stderr, result.stderr
787
+ comment_dir = tmp_path / "comment"
788
+ comment_dir.mkdir()
789
+ in_comment = _seeded_crate(
790
+ comment_dir, files={LIB_RS: "//! A clean crate.\n// See " + LEAKS["client-path"] + ".\n"}
791
+ )
792
+ result = _run_checker("--crate", str(in_comment))
793
+ assert result.returncode == 0, result.stderr
794
+
795
+
796
+ def test_the_crate_mode_holds_example_headers_to_the_full_table(tmp_path: Path) -> None:
797
+ # Examples ship in the crate too, so their headers get the same table.
798
+ example = "examples/hello" + ".r" + "s"
799
+ crate = _seeded_crate(tmp_path, files={example: "//! See " + LEAKS["client-path"] + ".\nfn main() {}\n"})
800
+ result = _run_checker("--crate", str(crate))
801
+ assert result.returncode != 0
802
+ assert example + " module docs: client-path" in result.stderr, result.stderr
803
+
804
+
805
+ def _crate_with(tmp_path: Path, members: dict[str, bytes]) -> Path:
806
+ crate = tmp_path / "muse-code-sdk-1.0.0.crate"
807
+ with tarfile.open(crate, "w:gz") as archive:
808
+ for name, data in members.items():
809
+ info = tarfile.TarInfo(name)
810
+ info.size = len(data)
811
+ archive.addfile(info, io.BytesIO(data))
812
+ return crate
813
+
814
+
815
+ _CLEAN_MANIFEST = b'[package]\nname = "muse-code-sdk"\nversion = "1.0.0"\n'
816
+
817
+
818
+ @pytest.mark.parametrize(
819
+ ("members", "message"),
820
+ [
821
+ (
822
+ {"a-1.0.0/README.md": b"# a\n", "b-1.0.0/README.md": b"# b\n"},
823
+ "has no single package root",
824
+ ),
825
+ (
826
+ {
827
+ "muse-code-sdk-1.0.0/README.md": b"# clean\n",
828
+ "muse-code-sdk-1.0.0/Cargo.toml": _CLEAN_MANIFEST,
829
+ "muse-code-sdk-1.0.0/src/data.bin": b"\xff\xfe",
830
+ },
831
+ "not UTF-8 text",
832
+ ),
833
+ (
834
+ {
835
+ "muse-code-sdk-1.0.0/README.md": b"# clean\n",
836
+ "muse-code-sdk-1.0.0/Cargo.toml": b"[package\nname = 1\n",
837
+ },
838
+ "not parseable TOML",
839
+ ),
840
+ ],
841
+ ids=["two-roots", "binary-member", "bad-manifest"],
842
+ )
843
+ def test_the_crate_mode_refuses_a_malformed_crate(
844
+ tmp_path: Path, members: dict[str, bytes], message: str
845
+ ) -> None:
846
+ result = _run_checker("--crate", str(_crate_with(tmp_path, members)))
847
+ assert result.returncode != 0
848
+ assert message in result.stderr, result.stderr
849
+
850
+
851
+ def test_the_crate_mode_refuses_a_file_that_is_no_crate_archive(tmp_path: Path) -> None:
852
+ not_a_crate = tmp_path / "muse-code-sdk-1.0.0.crate"
853
+ not_a_crate.write_text("not a tarball\n")
854
+ result = _run_checker("--crate", str(not_a_crate))
855
+ assert result.returncode != 0
856
+ assert "is not a readable crate archive" in result.stderr, result.stderr
857
+
858
+
859
+ def test_the_crate_mode_refuses_a_crate_that_is_missing(tmp_path: Path) -> None:
860
+ # The publish script relies on this: it has no check of its own for the
861
+ # file `cargo package` writes.
862
+ result = _run_checker("--crate", str(tmp_path / "muse-code-sdk-1.0.0.crate"))
863
+ assert result.returncode != 0
864
+ assert "is not a readable crate archive" in result.stderr, result.stderr
@@ -433,6 +433,33 @@ def test_digest_tree_matches_across_layouts(tmp_path: Path) -> None:
433
433
  assert digest_of(producer) == digest_of(mirror)
434
434
 
435
435
 
436
+ def test_digest_tree_matches_across_layouts_for_rust(tmp_path: Path) -> None:
437
+ # "Rust closure" entries map under rust/ the same way: the producer's
438
+ # verbatim crate and the mirror's rust/ copy digest identically.
439
+ producer = tmp_path / "producer"
440
+ mirror = tmp_path / "mirror"
441
+ notes = {
442
+ "clients/crate": "Rust closure. Marked for the mapping test.",
443
+ "scripts/publish.sh": "Rust closure. Marked for the mapping test.",
444
+ }
445
+ write_mirror_tree(
446
+ producer,
447
+ {"clients/crate/a.txt": "one\n", "scripts/publish.sh": "two\n"},
448
+ entries=["clients/crate", "scripts/publish.sh"],
449
+ notes=notes,
450
+ )
451
+ write_mirror_tree(
452
+ mirror,
453
+ {
454
+ "rust/clients/crate/a.txt": "one\n",
455
+ "rust/scripts/publish.sh": "two\n",
456
+ },
457
+ entries=["clients/crate", "scripts/publish.sh"],
458
+ notes=notes,
459
+ )
460
+ assert digest_of(producer) == digest_of(mirror)
461
+
462
+
436
463
  def test_digest_tree_refuses_symlink_inside_directory(tmp_path: Path) -> None:
437
464
  # A symlink anywhere under a walked entry refuses instead of
438
465
  # resolving: link following could escape the closure.
@@ -573,6 +600,16 @@ def test_closure_python_marked_set_is_frozen() -> None:
573
600
  }
574
601
 
575
602
 
603
+ def test_closure_rust_marked_set_is_frozen() -> None:
604
+ # LOAD-BEARING FREEZE, like the Python one: "Rust closure" notes map
605
+ # entries under rust/. None exist until the Rust SDK joins the mirror
606
+ # closure; that change updates this pin deliberately.
607
+ manifest = json.loads(CLOSURE_MANIFEST.read_text(encoding="utf-8"))
608
+ notes = manifest["path_notes"]
609
+ marked = {path for path, note in notes.items() if note.startswith("Rust closure")}
610
+ assert marked == set()
611
+
612
+
576
613
  def test_scan_text_refuses_undecodable_file(tmp_path: Path) -> None:
577
614
  binary = tmp_path / "bytes.bin"
578
615
  binary.write_bytes(b"\xff\xfe\x00bad")
@@ -69,17 +69,19 @@ def turn_completed(
69
69
  return {"method": "turn/completed", "params": params}
70
70
 
71
71
 
72
- def turn_retracted(turn_id: str, command_id: str, view_cursor: str) -> dict[str, Any]:
73
- return {
74
- "method": "turn/retracted",
75
- "params": {
76
- "commandId": command_id,
77
- "sessionId": SESSION,
78
- "sourceRange": SOURCE,
79
- "turnId": turn_id,
80
- "viewCursor": view_cursor,
81
- },
72
+ def turn_retracted(
73
+ turn_id: str, command_id: str, view_cursor: str, outcome: str | None = None
74
+ ) -> dict[str, Any]:
75
+ params: dict[str, Any] = {
76
+ "commandId": command_id,
77
+ "sessionId": SESSION,
78
+ "sourceRange": SOURCE,
79
+ "turnId": turn_id,
80
+ "viewCursor": view_cursor,
82
81
  }
82
+ if outcome is not None:
83
+ params["outcome"] = outcome
84
+ return {"method": "turn/retracted", "params": params}
83
85
 
84
86
 
85
87
  def turn_unqueued(turn_id: str, command_id: str, view_cursor: str) -> dict[str, Any]:
@@ -443,6 +445,79 @@ def test_a_reclaimed_turn_stays_unqueued_no_turn_completed_ever_settles_it() ->
443
445
  assert held.terminal is None, "a reclaim is not a terminal"
444
446
 
445
447
 
448
+ # A declined retract means the submission stood: the fold keeps a turn's prior
449
+ # state, so turn/completed settles a running turn in either order. On a turn
450
+ # the fold holds it records the retract's commandId (the standing
451
+ # submission's).
452
+ def test_a_declined_retract_after_turn_completed_leaves_the_turn_settled_and_records_its_command_id() -> None:
453
+ fold = SessionFold()
454
+ # First seen through its completion, so only the retract names the command.
455
+ fold.apply(turn_completed("turn-1", "completed", "v:s:1"))
456
+ outcome = fold.apply(turn_retracted("turn-1", "cmd-c", "v:s:2", "declined"))
457
+
458
+ assert outcome == TurnFold("turn-1", "settled")
459
+ turn = fold.turn("turn-1")
460
+ assert turn is not None
461
+ assert turn.state == "settled", "the submission stood"
462
+ assert turn.terminal == "completed"
463
+ assert turn.command_id == "cmd-c"
464
+
465
+
466
+ def test_a_declined_retract_while_the_turn_runs_leaves_it_running_and_turn_completed_settles_it() -> None:
467
+ fold = SessionFold()
468
+ fold.apply(turn_started("turn-1", "cmd-1", "v:s:1"))
469
+ outcome = fold.apply(turn_retracted("turn-1", "cmd-1", "v:s:2", "declined"))
470
+
471
+ assert outcome == TurnFold("turn-1", "running")
472
+ turn = fold.turn("turn-1")
473
+ assert turn is not None and turn.state == "running", "the submission stood"
474
+ assert fold.active_turn_id == "turn-1", "the turn is still the active one"
475
+
476
+ completed = fold.apply(turn_completed("turn-1", "completed", "v:s:3"))
477
+ assert completed == TurnFold("turn-1", "settled")
478
+ turn = fold.turn("turn-1")
479
+ assert turn is not None and turn.terminal == "completed", "the terminal is not dropped"
480
+ assert fold.active_turn_id is None
481
+
482
+
483
+ def test_a_declined_retract_leaves_a_reclaimed_turn_unqueued_and_records_its_command_id() -> None:
484
+ # The protocol never pairs these frames. The test pins the commandId rule
485
+ # for a held turn in any state, with a distinct id so the record shows.
486
+ fold = SessionFold()
487
+ fold.apply(turn_unqueued("turn-2", "cmd-2", "v:s:1"))
488
+ fold.apply(turn_retracted("turn-2", "cmd-c", "v:s:2", "declined"))
489
+
490
+ turn = fold.turn("turn-2")
491
+ assert turn is not None
492
+ assert turn.state == "unqueued"
493
+ assert turn.command_id == "cmd-c"
494
+
495
+
496
+ def test_a_declined_retract_for_a_turn_the_fold_has_not_seen_creates_no_entry() -> None:
497
+ fold = SessionFold()
498
+ # A client that joined after this turn's completion: the declined retract
499
+ # carries no lifecycle fact, so the fold must not invent a running turn.
500
+ outcome = fold.apply(turn_retracted("turn-9", "cmd-9", "v:s:1", "declined"))
501
+
502
+ assert outcome == IgnoredStaleFrame("turn/retracted", "turn-9")
503
+ assert fold.turn("turn-9") is None
504
+ assert fold.active_turn_id is None
505
+
506
+ fold.apply(turn_completed("turn-9", "completed", "v:s:2"))
507
+ turn = fold.turn("turn-9")
508
+ assert turn is not None and turn.state == "settled", "a later completion still settles it"
509
+
510
+
511
+ def test_an_unrecognized_retract_outcome_reads_as_accepted() -> None:
512
+ fold = SessionFold()
513
+ fold.apply(turn_started("turn-1", "cmd-1", "v:s:1"))
514
+ fold.apply(turn_retracted("turn-1", "cmd-1", "v:s:2", "future-outcome"))
515
+
516
+ turn = fold.turn("turn-1")
517
+ assert turn is not None and turn.state == "retracted"
518
+ assert fold.active_turn_id is None
519
+
520
+
446
521
  def test_turn_retracted_carries_the_command_id_when_the_retract_is_the_first_frame() -> None:
447
522
  fold = SessionFold()
448
523
  # A client joining mid-session can see `turn/retracted` as its first
File without changes
File without changes
File without changes