muse-code-sdk 1.4.4__tar.gz → 1.4.7__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.4/src/muse_code_sdk.egg-info → muse_code_sdk-1.4.7}/PKG-INFO +1 -1
  2. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/pyproject.toml +1 -1
  3. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/__init__.py +4 -2
  4. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/connection/spawn.py +11 -0
  5. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/errors.py +33 -0
  6. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/facade/client.py +93 -7
  7. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/facade/turn_submit.py +8 -1
  8. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/fold/session_fold.py +17 -2
  9. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7/src/muse_code_sdk.egg-info}/PKG-INFO +1 -1
  10. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code_sdk.egg-info/SOURCES.txt +2 -0
  11. muse_code_sdk-1.4.7/tests/test_facade_required_selection.py +305 -0
  12. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_facade_turn.py +32 -0
  13. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_muse_serve_child.py +40 -0
  14. muse_code_sdk-1.4.7/tests/test_permission_state_wire_types.py +174 -0
  15. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_session_fold.py +4 -0
  16. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/LICENSE +0 -0
  17. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/README.md +0 -0
  18. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/setup.cfg +0 -0
  19. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/connection/__init__.py +0 -0
  20. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/connection/connection.py +0 -0
  21. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/connection/discovery.py +0 -0
  22. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/connection/validation.py +0 -0
  23. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/facade/__init__.py +0 -0
  24. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/facade/approval.py +0 -0
  25. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/facade/discarded.py +0 -0
  26. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/facade/gap_fill.py +0 -0
  27. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/facade/host_death.py +0 -0
  28. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/facade/host_start.py +0 -0
  29. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/facade/session.py +0 -0
  30. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/facade/turn_handle.py +0 -0
  31. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/fold/__init__.py +0 -0
  32. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/fold/item_store.py +0 -0
  33. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/fold/state_store.py +0 -0
  34. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/pending/__init__.py +0 -0
  35. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/pending/pending_command_set.py +0 -0
  36. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/py.typed +0 -0
  37. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/replay.py +0 -0
  38. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/sync_facade.py +0 -0
  39. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code_sdk.egg-info/dependency_links.txt +0 -0
  40. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code_sdk.egg-info/requires.txt +0 -0
  41. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code_sdk.egg-info/top_level.txt +0 -0
  42. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_asyncio_cancellation.py +0 -0
  43. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_compatibility_row.py +0 -0
  44. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_connection.py +0 -0
  45. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_connection_error_arms.py +0 -0
  46. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_docs_generation.py +0 -0
  47. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_external_audience.py +0 -0
  48. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_facade_approval.py +0 -0
  49. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_facade_gap_fill.py +0 -0
  50. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_facade_open_same_chunk.py +0 -0
  51. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_facade_persistent_approval.py +0 -0
  52. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_host_discovery.py +0 -0
  53. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_item_store.py +0 -0
  54. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_manifests.py +0 -0
  55. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_pending_command_corpus.py +0 -0
  56. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_pending_command_set.py +0 -0
  57. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_pending_command_wire_binding.py +0 -0
  58. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_public_mirror_text.py +0 -0
  59. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_publish_shape.py +0 -0
  60. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_source_closure.py +0 -0
  61. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_spawn_handshake.py +0 -0
  62. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_spawn_host_lost.py +0 -0
  63. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_state_store.py +0 -0
  64. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_sync_facade.py +0 -0
  65. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_tolerance_corpus.py +0 -0
  66. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_transcript_replay.py +0 -0
  67. {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/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.4
3
+ Version: 1.4.7
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.4"
8
+ version = "1.4.7"
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"
@@ -24,6 +24,7 @@ from .errors import (
24
24
  MuseForeignSessionError,
25
25
  MuseGapFillError,
26
26
  MuseSessionDiscardedError,
27
+ PermissionProfileNotApplied,
27
28
  )
28
29
  from .fold import ItemStore, SessionFold, SessionStateStore
29
30
  from .pending import PendingCommandSet
@@ -51,6 +52,7 @@ __all__ = [
51
52
  "MuseSessionDiscardedError",
52
53
  "PendingCommandSet",
53
54
  "PendingCommandView",
55
+ "PermissionProfileNotApplied",
54
56
  "ResumeSessionOptions",
55
57
  "SendUserTurnOptions",
56
58
  "Session",
@@ -78,7 +80,7 @@ __all__ = [
78
80
  ]
79
81
 
80
82
  EXPECTED_SCHEMA_FINGERPRINT: Final[str] = (
81
- "sha256:7c94f153c41659cb3f1bd3c3e04438be254644cb2a97d65d48edc7449b74858a"
83
+ "sha256:6de32db08bbbe0deb478d2bfcaba001e9fb490986bc80db3aceb4d996abf7df9"
82
84
  )
83
85
  """The stable schema-bundle fingerprint this SDK was built against.
84
86
 
@@ -90,7 +92,7 @@ Python SDK reds this lane instead of drifting silently.
90
92
  # The lockstep rule: the release train bumps pyproject.toml, the
91
93
  # distribution-metadata authority; this constant is aligned at publication
92
94
  # time.
93
- __version__: Final[str] = "1.4.4"
95
+ __version__: Final[str] = "1.4.7"
94
96
 
95
97
  # The facade is imported last so the constants above are already bound: the
96
98
  # spawn path reads ``EXPECTED_SCHEMA_FINGERPRINT`` (lazily, at initialize
@@ -39,6 +39,13 @@ from .validation import parse_initialize_result, parse_spawn_options
39
39
  if TYPE_CHECKING:
40
40
  from muse_code_msp import InitializeParams, InitializeResult, RequestId
41
41
 
42
+ _CREATE_NO_WINDOW = 0x0800_0000
43
+ """Win32 ``CREATE_NO_WINDOW`` process-creation flag (``subprocess`` defines it
44
+ only on Windows builds). A spawned ``muse serve`` is never meant to be seen,
45
+ so on Windows the SDK starts it without a console window: a windowless host
46
+ (a GUI app, a service) would otherwise get a stray console on every spawn."""
47
+
48
+
42
49
  DEFAULT_STDERR_MAX_BYTES = 8 * 1024
43
50
  DEFAULT_STDERR_MAX_LINES = 100
44
51
  DEFAULT_SHUTDOWN_TIMEOUT_MS = 30_000
@@ -817,6 +824,9 @@ class MuseServeChild:
817
824
  env=env,
818
825
  shutdown_timeout_ms=shutdown_timeout_ms,
819
826
  )
827
+ # POSIX: the child leads its own process group so the close ladder can
828
+ # signal the group. Windows: no process group, and no console window
829
+ # (CPython accepts ``creationflags=0`` on POSIX).
820
830
  owns_process_group = sys.platform != "win32"
821
831
  child = await asyncio.create_subprocess_exec(
822
832
  options.command,
@@ -827,6 +837,7 @@ class MuseServeChild:
827
837
  cwd=options.cwd,
828
838
  env=options.env,
829
839
  start_new_session=owns_process_group,
840
+ creationflags=0 if owns_process_group else _CREATE_NO_WINDOW,
830
841
  )
831
842
  return MuseServeChild(
832
843
  child,
@@ -82,6 +82,39 @@ class MuseGapFillError(Exception):
82
82
  self.__cause__ = cause
83
83
 
84
84
 
85
+ class PermissionProfileNotApplied(Exception):
86
+ """A required session-start permission-profile selection the client could
87
+ not prove applied: the start result carried no ``permissionState``, and
88
+ the ``session/read`` read-back served a different profile — or none at
89
+ all. Never a silent default: the session stays open and the caller
90
+ decides (set/retry or close) via ``session_id``, with no auto-close and
91
+ no auto-set.
92
+
93
+ Attributes:
94
+ requested: The profile id the caller required.
95
+ served: The committed profile id the read-back served, or None when
96
+ unknown.
97
+ session_id: The open session, so the caller can set, retry, or close it.
98
+ """
99
+
100
+ def __init__(self, requested: str, served: str | None, session_id: str) -> None:
101
+ """Builds the refusal.
102
+
103
+ Args:
104
+ requested: The profile id the caller required.
105
+ served: The committed profile id served, or None when unknown.
106
+ session_id: The open session's id.
107
+ """
108
+ served_text = "unknown" if served is None else repr(served)
109
+ super().__init__(
110
+ f"required permissionProfile {requested!r} was not applied:"
111
+ f" the committed selection is {served_text} (session {session_id})"
112
+ )
113
+ self.requested: str = requested
114
+ self.served: str | None = served
115
+ self.session_id: str = session_id
116
+
117
+
85
118
  ValidationSeam = Literal["initializeResult", "spawnOptions"]
86
119
  """Which validating pydantic seam refused its input.
87
120
 
@@ -53,7 +53,7 @@ from ..connection.spawn import (
53
53
  SpawnedMspConnection,
54
54
  spawn_msp_connection,
55
55
  )
56
- from ..errors import MuseSessionDiscardedError
56
+ from ..errors import MuseSessionDiscardedError, PermissionProfileNotApplied
57
57
  from .discarded import DiscardedSessions
58
58
  from .host_start import MuseHostStartError, keeps_its_own_error
59
59
  from .host_death import (
@@ -93,6 +93,18 @@ class StartSessionOptions:
93
93
  ``workspace_root`` is also given the two must agree.
94
94
  model_id: The model to serve the session with; omitted = the
95
95
  provider's default.
96
+ permission_profile: The permission profile to start the session
97
+ under (wire ``permissionProfile``): a profile id the host offers;
98
+ omitted = the host's configured default. An id the host does not
99
+ offer refuses the start before the session exists.
100
+ require_permission_profile: Require the ``permission_profile``
101
+ selection: when true, the client proves the
102
+ committed selection equals the requested id — from the start
103
+ result's ``session.permissionState``, else a ``session/read``
104
+ read-back — and raises ``PermissionProfileNotApplied`` instead
105
+ of proceeding on the host default. Client-local: never sent on
106
+ the wire. Setting it without ``permission_profile`` is a usage
107
+ error.
96
108
  """
97
109
 
98
110
  approval_mode: str | None = None
@@ -101,6 +113,8 @@ class StartSessionOptions:
101
113
  workspace_root: str | None = None
102
114
  workspace_roots: List[str] | None = None
103
115
  model_id: str | None = None
116
+ permission_profile: str | None = None
117
+ require_permission_profile: bool = False
104
118
 
105
119
 
106
120
  @dataclass
@@ -127,12 +141,18 @@ class ResumeSessionOptions:
127
141
  # Drift pins — the Python twin of the TS `AssertNever` on START_FORWARDED /
128
142
  # RESUME_FORWARDED (client.ts). A schema regen that adds a member to the
129
143
  # generated params but not to the options dataclass reds THIS import (and thus
130
- # every test), so a regenerated member cannot silently fail to reach the wire
131
- #. The excluded members are the ones the facade owns rather than
132
- # forwards: `commandId` (the connection is the single minter) and `config`
133
- # (temporarily withheld until capability-checked forwarding lands).
144
+ # every test), so a regenerated member cannot silently fail to reach the wire.
145
+ # The excluded members are the ones the facade owns rather than forwards:
146
+ # `commandId` (the connection is the single minter) and `config`
147
+ # (temporarily withheld until capability-checked forwarding lands). The owned
148
+ # members are options-only posture the wire never carries (client-local
149
+ # `requirePermissionProfile`, the required-selection check): subtracted from
150
+ # the options side, so a regen that adds a same-named wire member reds here.
134
151
  _START_EXCLUDED = {"commandId", "config"}
135
- assert {_camel(f.name) for f in fields(StartSessionOptions)} | _START_EXCLUDED == set(
152
+ _START_OWNED = {"requirePermissionProfile"}
153
+ assert (
154
+ {_camel(f.name) for f in fields(StartSessionOptions)} - _START_OWNED
155
+ ) | _START_EXCLUDED == set(
136
156
  SessionStartParams.__annotations__
137
157
  ), "StartSessionOptions drifted from SessionStartParams — forward the new member"
138
158
 
@@ -142,6 +162,21 @@ assert {_camel(f.name) for f in fields(ResumeSessionOptions)} | _RESUME_EXCLUDED
142
162
  ), "ResumeSessionOptions drifted from SessionResumeParams — forward the new member"
143
163
 
144
164
 
165
+ def _committed_profile_id(snapshot: Any) -> str | None:
166
+ """The committed profile id a session snapshot claims, or None when the
167
+ snapshot is silent (the stable bundle strips the member; absent and null
168
+ both mean unknown, never applied). Structural on purpose: an unknown wire
169
+ shape reads as unknown rather than raising.
170
+ """
171
+ if not isinstance(snapshot, Mapping):
172
+ return None
173
+ state = snapshot.get("permissionState")
174
+ if not isinstance(state, Mapping):
175
+ return None
176
+ profile_id = state.get("profileId")
177
+ return profile_id if isinstance(profile_id, str) else None
178
+
179
+
145
180
  @dataclass
146
181
  class MuseClientOptions:
147
182
  """Options for building a ``MuseClient`` around a connection you own.
@@ -377,10 +412,61 @@ class MuseClient(Generic[_I]):
377
412
  params["workspaceRoots"] = options.workspace_roots
378
413
  if options.model_id is not None:
379
414
  params["modelId"] = options.model_id
415
+ # The initial permission-profile selection, forwarded verbatim like the
416
+ # other start members (None stays omitted).
417
+ if options.permission_profile is not None:
418
+ params["permissionProfile"] = options.permission_profile
419
+ # Required selection: a caller that requires its selection proves it,
420
+ # never assuming the host default. The flag without the member is a
421
+ # usage error, raised pre-wire.
422
+ required = options.require_permission_profile is True
423
+ if required and options.permission_profile is None:
424
+ raise ValueError(
425
+ "require_permission_profile needs permission_profile:"
426
+ " require the selection you pass"
427
+ )
380
428
  # Keyed by the id the SERVER named, never the one the caller asked for:
381
429
  # ``sessionId`` is a REQUEST on start, and a session keyed by a hopeful
382
430
  # id would reject every one of its own events as foreign.
383
- return await self._open("session/start", params, _StartOpening)
431
+ session = await self._open("session/start", params, _StartOpening)
432
+ if not required:
433
+ return session
434
+ assert options.permission_profile is not None # the usage guard above
435
+ return await self._prove_selection(session, options.permission_profile)
436
+
437
+ async def _prove_selection(
438
+ self, session: Session[_I], requested: str
439
+ ) -> Session[_I]:
440
+ """Prove a required start selection: the start result's own
441
+ ``session.permissionState`` first (absent = unknown,
442
+ never applied); else a point-in-time ``session/read``, which never
443
+ subscribes. Only ``profileId == requested`` proceeds; anything else
444
+ raises ``PermissionProfileNotApplied`` BEFORE any turn is sent. The
445
+ refused session is dropped from the client (no orphan keeps folding
446
+ for an id the caller can never reach — the SDK has no set/close-by-id
447
+ verb); the error's ``session_id`` is the caller's handle to resume via
448
+ ``session_id`` or start again. A failed read-back propagates: the
449
+ client still never proceeds on the default.
450
+ """
451
+ opening = session.opening
452
+ started = (
453
+ _committed_profile_id(opening.result["session"])
454
+ if isinstance(opening, _StartOpening)
455
+ else None
456
+ )
457
+ if started is not None:
458
+ if started == requested:
459
+ return session
460
+ self._sessions.discard(session)
461
+ raise PermissionProfileNotApplied(requested, started, session.session_id)
462
+ read = await self._connection.command(
463
+ "session/read", {"sessionId": session.session_id}
464
+ )
465
+ served = _committed_profile_id(read.get("session"))
466
+ if served == requested:
467
+ return session
468
+ self._sessions.discard(session)
469
+ raise PermissionProfileNotApplied(requested, served, session.session_id)
384
470
 
385
471
  async def resume_session(self, options: ResumeSessionOptions) -> Session[_I]:
386
472
  """Load an existing session and subscribe this connection to its view.
@@ -20,6 +20,7 @@ from muse_code_msp import (
20
20
  TurnInputPart,
21
21
  TurnStartParams,
22
22
  TurnStartResult,
23
+ TurnUserShell,
23
24
  )
24
25
 
25
26
  from ..connection.connection import Connection, MspError, Params
@@ -54,6 +55,8 @@ class SendUserTurnOptions(Generic[_I]):
54
55
  workspace_roots: A sticky replacement of the session's runtime
55
56
  workspace-root set for this turn and subsequent turns (wire
56
57
  ``workspaceRoots``).
58
+ user_shell: The queued ``!`` shell action this turn carries (wire
59
+ ``userShell``).
57
60
  composer_input: What to hand back to the composer if this submit is
58
61
  ever retired — the protocol restores the INPUT, not the wire parts.
59
62
  Optional: a consumer rendering nothing optimistically has nothing
@@ -67,6 +70,8 @@ class SendUserTurnOptions(Generic[_I]):
67
70
  # No token_budget: the member is experimental-gated (v1 membership
68
71
  # unruled), and this SDK serves the stable surface only.
69
72
  workspace_roots: List[str] | None = None
73
+ # v1 stable member, forwarded like any other.
74
+ user_shell: TurnUserShell | None = None
70
75
  composer_input: _I | None = None
71
76
 
72
77
 
@@ -88,7 +93,7 @@ def _camel(field_name: str) -> str:
88
93
  # own field, never a wire member, so it is dropped
89
94
  # before the compare; `commandId`/`sessionId` are excluded because the
90
95
  # connection mints the first and the session owns the second.
91
- _TURN_WIRE_FIELDS = {"input", "display_text", "if_busy", "reasoning_effort", "workspace_roots"}
96
+ _TURN_WIRE_FIELDS = {"input", "display_text", "if_busy", "reasoning_effort", "workspace_roots", "user_shell"}
92
97
  _TURN_EXCLUDED = {"commandId", "sessionId"}
93
98
  assert {
94
99
  _camel(f.name) for f in fields(SendUserTurnOptions) if f.name in _TURN_WIRE_FIELDS
@@ -292,4 +297,6 @@ class TurnSubmitter(Generic[_I]):
292
297
  # No tokenBudget forward: experimental-gated, stable SDK omits it.
293
298
  if options.workspace_roots is not None:
294
299
  params["workspaceRoots"] = options.workspace_roots
300
+ if options.user_shell is not None:
301
+ params["userShell"] = options.user_shell
295
302
  return params
@@ -102,6 +102,17 @@ LIVE_HOST_STATE_PROJECTIONS: frozenset[str] = frozenset(
102
102
  # connection) — no ``viewCursor``, no ``sourceRange``; a row-table
103
103
  # consumer applies it out of band and it folds no transcript state.
104
104
  "session/listChanged",
105
+ # The plugin change hints are live host-state projections like
106
+ # ``skill/changed`` — re-issue hints, never folded items: an
107
+ # install landing (``plugin/changed``) or a marketplace add,
108
+ # update, or remove (``plugin/marketplaceChanged``) tells the
109
+ # client to re-issue ``plugin/list``, and neither carries
110
+ # foldable view state.
111
+ "plugin/changed",
112
+ "plugin/marketplaceChanged",
113
+ # A session's computer-use setup state moved: a host-state push
114
+ # with no view cursor, for a Settings consumer.
115
+ "computerUseSetup/changed",
105
116
  )
106
117
  )
107
118
  """Notifications that are live host-state projections, not view events:
@@ -675,8 +686,12 @@ class SessionFold:
675
686
  turn = self._turn_for(turn_id)
676
687
  turn.command_id = params.get("commandId")
677
688
  # A retract is NOT a TurnTerminal; leaving `terminal` unset is the
678
- # whole point. The retracted user message re-arrives via
679
- # `item/updated` with `retracted: true`, which the item half folds.
689
+ # whole point. On accepted, the retracted user message re-arrives via
690
+ # `item/updated` with `retracted: true`, which the item half folds
691
+ # (a declined outcome also lands here for now: the entry reads
692
+ # retracted and the later turn/completed arrives as a stale frame,
693
+ # so its terminal is dropped too - a known gap in this fold,
694
+ # behavior unchanged).
680
695
  turn.state = "retracted"
681
696
  self._clear_active(turn_id)
682
697
  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.4
3
+ Version: 1.4.7
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
@@ -42,6 +42,7 @@ tests/test_facade_approval.py
42
42
  tests/test_facade_gap_fill.py
43
43
  tests/test_facade_open_same_chunk.py
44
44
  tests/test_facade_persistent_approval.py
45
+ tests/test_facade_required_selection.py
45
46
  tests/test_facade_turn.py
46
47
  tests/test_host_discovery.py
47
48
  tests/test_item_store.py
@@ -50,6 +51,7 @@ tests/test_muse_serve_child.py
50
51
  tests/test_pending_command_corpus.py
51
52
  tests/test_pending_command_set.py
52
53
  tests/test_pending_command_wire_binding.py
54
+ tests/test_permission_state_wire_types.py
53
55
  tests/test_public_mirror_text.py
54
56
  tests/test_publish_shape.py
55
57
  tests/test_session_fold.py
@@ -0,0 +1,305 @@
1
+ """The required-selection arm of the Python SDK facade: a caller that sets
2
+ ``require_permission_profile`` alongside ``permission_profile`` requires its
3
+ selection, and the client proves it instead of proceeding on the host default.
4
+
5
+ A caller that sets ``require_permission_profile`` alongside
6
+ ``permission_profile`` requires its selection: the client proves the
7
+ committed selection equals the requested id -- from the start result's own
8
+ ``session.permissionState``, else a ``session/read`` read-back -- and raises
9
+ ``PermissionProfileNotApplied`` instead of proceeding on the host default.
10
+ The refused session is dropped (no orphan); the caller resumes via the
11
+ error's ``session_id`` or starts again: no auto-close, no auto-set, never a
12
+ silent default.
13
+
14
+ The blocking twin (``SyncMuseClient.start_session``) delegates verbatim to
15
+ the async verb, so these async arms cover it; no sync-surface test needed.
16
+
17
+ Causal, never time-based: every wait is ``wait_for_writes`` / ``pump``.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ from typing import Any
24
+
25
+ import pytest
26
+
27
+ from muse_code import (
28
+ MuseClient,
29
+ MuseClientOptions,
30
+ PermissionProfileNotApplied,
31
+ ResumeSessionOptions,
32
+ StartSessionOptions,
33
+ read_session_durability,
34
+ )
35
+ from muse_code.connection import Connection
36
+
37
+ from helpers_connection import (
38
+ FakeDuplex,
39
+ answer,
40
+ pump,
41
+ sent_frame,
42
+ sent_params,
43
+ wait_for_writes,
44
+ )
45
+
46
+ Params = dict[str, Any]
47
+
48
+ REQUIRED = ":unrestricted"
49
+ OTHER = ":ask-me"
50
+
51
+
52
+ def _client() -> tuple[MuseClient[Any], FakeDuplex]:
53
+ transport = FakeDuplex()
54
+ client: MuseClient[Any] = MuseClient(
55
+ Connection(transport),
56
+ MuseClientOptions(
57
+ durability=read_session_durability({"sessionDurability": "durable"})
58
+ ),
59
+ )
60
+ return client, transport
61
+
62
+
63
+ def _start_result(session_id: str, permission_state: Params | None = None) -> Params:
64
+ session: Params = {"sessionId": session_id, "status": "idle"}
65
+ if permission_state is not None:
66
+ session["permissionState"] = permission_state
67
+ return {"session": session, "viewCursor": "v:1"}
68
+
69
+
70
+ def _read_result(session_id: str, permission_state: Params | None = None) -> Params:
71
+ session: Params = {"sessionId": session_id, "status": "idle"}
72
+ if permission_state is not None:
73
+ session["permissionState"] = permission_state
74
+ return {
75
+ "history": {"mode": "none", "items": []},
76
+ "pendingRequests": [],
77
+ "session": session,
78
+ "viewCursor": "v:2",
79
+ }
80
+
81
+
82
+ def _triple(profile_id: str | None) -> Params:
83
+ return {
84
+ "profileId": profile_id,
85
+ "reviewerAuthority": "autoReview",
86
+ "reviewerAttached": True,
87
+ }
88
+
89
+
90
+ @pytest.mark.asyncio
91
+ async def test_required_start_whose_read_back_serves_a_different_profile_refuses() -> None:
92
+ client, transport = _client()
93
+ pending = asyncio.ensure_future(
94
+ client.start_session(
95
+ StartSessionOptions(
96
+ permission_profile=REQUIRED, require_permission_profile=True
97
+ )
98
+ )
99
+ )
100
+ await wait_for_writes(transport, 1)
101
+ assert sent_frame(transport, 0)["method"] == "session/start"
102
+ assert sent_params(transport, 0)["permissionProfile"] == REQUIRED
103
+ assert "requirePermissionProfile" not in sent_params(transport, 0), (
104
+ "the require flag is client-local, never on the wire"
105
+ )
106
+ answer(transport, 0, _start_result("s-1"))
107
+
108
+ await wait_for_writes(transport, 2)
109
+ assert sent_frame(transport, 1)["method"] == "session/read"
110
+ assert sent_params(transport, 1)["sessionId"] == "s-1"
111
+ answer(transport, 1, _read_result("s-1", _triple(OTHER)))
112
+
113
+ with pytest.raises(PermissionProfileNotApplied) as refused:
114
+ await pending
115
+ assert refused.value.requested == REQUIRED
116
+ assert refused.value.served == OTHER
117
+ assert refused.value.session_id == "s-1"
118
+ await pump()
119
+ assert len(transport.writes) == 2, "no turn is sent after the refusal"
120
+ await client.close()
121
+
122
+
123
+ @pytest.mark.asyncio
124
+ async def test_required_start_whose_read_back_omits_permission_state_refuses_unknown() -> None:
125
+ client, transport = _client()
126
+ pending = asyncio.ensure_future(
127
+ client.start_session(
128
+ StartSessionOptions(
129
+ permission_profile=REQUIRED, require_permission_profile=True
130
+ )
131
+ )
132
+ )
133
+ await wait_for_writes(transport, 1)
134
+ answer(transport, 0, _start_result("s-1"))
135
+ await wait_for_writes(transport, 2)
136
+ answer(transport, 1, _read_result("s-1"))
137
+
138
+ with pytest.raises(PermissionProfileNotApplied) as refused:
139
+ await pending
140
+ assert refused.value.requested == REQUIRED
141
+ assert refused.value.served is None
142
+ assert refused.value.session_id == "s-1"
143
+ await pump()
144
+ assert len(transport.writes) == 2
145
+ await client.close()
146
+
147
+
148
+ @pytest.mark.asyncio
149
+ async def test_required_start_whose_read_back_serves_null_profile_refuses_unknown() -> None:
150
+ client, transport = _client()
151
+ pending = asyncio.ensure_future(
152
+ client.start_session(
153
+ StartSessionOptions(
154
+ permission_profile=REQUIRED, require_permission_profile=True
155
+ )
156
+ )
157
+ )
158
+ await wait_for_writes(transport, 1)
159
+ answer(transport, 0, _start_result("s-1"))
160
+ await wait_for_writes(transport, 2)
161
+ answer(transport, 1, _read_result("s-1", _triple(None)))
162
+
163
+ with pytest.raises(PermissionProfileNotApplied) as refused:
164
+ await pending
165
+ assert refused.value.requested == REQUIRED
166
+ assert refused.value.served is None
167
+ await pump()
168
+ assert len(transport.writes) == 2
169
+ await client.close()
170
+
171
+
172
+ @pytest.mark.asyncio
173
+ async def test_require_flag_without_member_is_a_usage_error_before_anything_is_sent() -> None:
174
+ client, transport = _client()
175
+ pending = asyncio.ensure_future(
176
+ client.start_session(StartSessionOptions(require_permission_profile=True))
177
+ )
178
+ await pump()
179
+ if transport.writes:
180
+ # Pre-fix behavior: it sent -- answer so nothing dangles, then fail loudly.
181
+ answer(transport, 0, _start_result("s-1"))
182
+ await pending
183
+ await client.close()
184
+ pytest.fail("usage error must fire before anything is sent")
185
+ with pytest.raises(ValueError):
186
+ await pending
187
+ await client.close()
188
+
189
+
190
+ @pytest.mark.asyncio
191
+ async def test_start_without_the_flag_sends_one_frame_and_no_read() -> None:
192
+ client, transport = _client()
193
+ pending = asyncio.ensure_future(
194
+ client.start_session(StartSessionOptions(permission_profile=REQUIRED))
195
+ )
196
+ await wait_for_writes(transport, 1)
197
+ answer(transport, 0, _start_result("s-1"))
198
+ session = await pending
199
+ assert session.session_id == "s-1"
200
+ await pump()
201
+ assert len(transport.writes) == 1
202
+ await client.close()
203
+
204
+
205
+ @pytest.mark.asyncio
206
+ async def test_start_result_already_carrying_the_required_profile_proceeds_with_no_read() -> None:
207
+ client, transport = _client()
208
+ pending = asyncio.ensure_future(
209
+ client.start_session(
210
+ StartSessionOptions(
211
+ permission_profile=REQUIRED, require_permission_profile=True
212
+ )
213
+ )
214
+ )
215
+ await wait_for_writes(transport, 1)
216
+ answer(transport, 0, _start_result("s-1", _triple(REQUIRED)))
217
+ session = await pending
218
+ assert session.session_id == "s-1"
219
+ await pump()
220
+ assert len(transport.writes) == 1
221
+ await client.close()
222
+
223
+
224
+ @pytest.mark.asyncio
225
+ async def test_start_result_carrying_a_different_profile_refuses_with_no_read() -> None:
226
+ client, transport = _client()
227
+ pending = asyncio.ensure_future(
228
+ client.start_session(
229
+ StartSessionOptions(
230
+ permission_profile=REQUIRED, require_permission_profile=True
231
+ )
232
+ )
233
+ )
234
+ await wait_for_writes(transport, 1)
235
+ answer(transport, 0, _start_result("s-1", _triple(OTHER)))
236
+
237
+ with pytest.raises(PermissionProfileNotApplied) as refused:
238
+ await pending
239
+ assert refused.value.requested == REQUIRED
240
+ assert refused.value.served == OTHER
241
+ await pump()
242
+ assert len(transport.writes) == 1
243
+ await client.close()
244
+
245
+
246
+ @pytest.mark.asyncio
247
+ async def test_refused_session_is_dropped_from_the_client_registry() -> None:
248
+ # The no-orphan pin: the registry has no public accessor (neither SDK
249
+ # exposes its sessions), so the single-underscore set is the only
250
+ # observable for "no unreachable Session keeps folding".
251
+ client, transport = _client()
252
+ pending = asyncio.ensure_future(
253
+ client.start_session(
254
+ StartSessionOptions(
255
+ permission_profile=REQUIRED, require_permission_profile=True
256
+ )
257
+ )
258
+ )
259
+ await wait_for_writes(transport, 1)
260
+ answer(transport, 0, _start_result("s-1", _triple(OTHER)))
261
+
262
+ with pytest.raises(PermissionProfileNotApplied):
263
+ await pending
264
+ await pump()
265
+ assert {s.session_id for s in client._sessions} == set()
266
+ await client.close()
267
+
268
+
269
+ @pytest.mark.asyncio
270
+ async def test_refused_id_stays_resumable_after_the_drop() -> None:
271
+ # Constitution XIII paired evidence (review 5476188067 follow-up-1): the
272
+ # refusal drops the orphan while the client stays usable on the same id.
273
+ client, transport = _client()
274
+ pending = asyncio.ensure_future(
275
+ client.start_session(
276
+ StartSessionOptions(
277
+ permission_profile=REQUIRED, require_permission_profile=True
278
+ )
279
+ )
280
+ )
281
+ await wait_for_writes(transport, 1)
282
+ answer(transport, 0, _start_result("s-1", _triple(OTHER)))
283
+ with pytest.raises(PermissionProfileNotApplied):
284
+ await pending
285
+ await pump()
286
+
287
+ resumed = asyncio.ensure_future(
288
+ client.resume_session(ResumeSessionOptions(session_id="s-1"))
289
+ )
290
+ await wait_for_writes(transport, 2)
291
+ assert sent_frame(transport, 1)["method"] == "session/resume"
292
+ assert sent_params(transport, 1)["sessionId"] == "s-1"
293
+ answer(
294
+ transport,
295
+ 1,
296
+ {
297
+ "session": {"sessionId": "s-1", "status": "idle"},
298
+ "history": {"mode": "none", "items": []},
299
+ "pendingRequests": [],
300
+ "viewCursor": "v:9",
301
+ },
302
+ )
303
+ session = await resumed
304
+ assert session.session_id == "s-1"
305
+ await client.close()
@@ -874,6 +874,9 @@ async def test_start_session_sends_the_verb_and_keys_by_the_servers_id() -> None
874
874
  sent = await _read_last_frame(transport, 1)
875
875
  assert sent["method"] == "session/start"
876
876
  assert sent["params"]["workspaceRoot"] == "/repo"
877
+ # The initial permission-profile selection is OMITTED when the option is
878
+ # left None — never sent as null, like every other optional start member.
879
+ assert "permissionProfile" not in sent["params"]
877
880
  # session/start result may omit the redundant commandId echo.
878
881
  transport.chunks.push(
879
882
  frame(
@@ -889,6 +892,35 @@ async def test_start_session_sends_the_verb_and_keys_by_the_servers_id() -> None
889
892
  await client.close()
890
893
 
891
894
 
895
+ @pytest.mark.asyncio
896
+ async def test_start_session_forwards_the_permission_profile_member() -> None:
897
+ """``permission_profile`` rides the wire as ``permissionProfile`` verbatim,
898
+ like the other start members (the TypeScript SDK pins the same forward)."""
899
+ import asyncio
900
+
901
+ client, transport = _client()
902
+ pending = asyncio.ensure_future(
903
+ client.start_session(
904
+ StartSessionOptions(workspace_root="/repo", permission_profile=":read-only")
905
+ )
906
+ )
907
+ sent = await _read_last_frame(transport, 1)
908
+ assert sent["method"] == "session/start"
909
+ assert sent["params"]["permissionProfile"] == ":read-only"
910
+ transport.chunks.push(
911
+ frame(
912
+ {
913
+ "jsonrpc": "2.0",
914
+ "id": sent["id"],
915
+ "result": {"session": {"sessionId": SESSION}, "viewCursor": "v:0"},
916
+ }
917
+ )
918
+ )
919
+ session_obj = await pending
920
+ assert session_obj.session_id == SESSION
921
+ await client.close()
922
+
923
+
892
924
  @pytest.mark.asyncio
893
925
  async def test_send_user_turn_then_routed_events_reach_the_turn_handle() -> None:
894
926
  import asyncio
@@ -1118,3 +1118,43 @@ async def test_a_bare_incoming_attribute_probe_does_not_arm_the_close_penalty()
1118
1118
  )
1119
1119
  finally:
1120
1120
  reap(pid)
1121
+
1122
+
1123
+ @pytest.mark.asyncio
1124
+ @pytest.mark.parametrize(
1125
+ ("platform", "start_new_session", "creationflags"),
1126
+ [
1127
+ pytest.param("win32", False, 0x0800_0000, id="windows-no-console"),
1128
+ pytest.param("linux", True, 0, id="linux-process-group"),
1129
+ pytest.param("darwin", True, 0, id="darwin-process-group"),
1130
+ ],
1131
+ )
1132
+ async def test_spawn_passes_the_platform_spawn_keywords(
1133
+ monkeypatch: pytest.MonkeyPatch,
1134
+ platform: str,
1135
+ start_new_session: bool,
1136
+ creationflags: int,
1137
+ ) -> None:
1138
+ # A spawned ``muse serve`` is never meant to be seen: on Windows it starts
1139
+ # with CREATE_NO_WINDOW and no process group; on POSIX it leads its own
1140
+ # process group and sets no Windows-only flag bit (creationflags=0).
1141
+ # Wiring pin: the stub
1142
+ # records the exact keywords the call site passes and stops before any
1143
+ # process exists.
1144
+ from muse_code.connection import spawn as spawn_module
1145
+
1146
+ class _Stop(Exception):
1147
+ pass
1148
+
1149
+ seen: dict[str, object] = {}
1150
+
1151
+ async def _record(*args: object, **kwargs: object) -> object:
1152
+ seen.update(kwargs)
1153
+ raise _Stop
1154
+
1155
+ monkeypatch.setattr(spawn_module.asyncio, "create_subprocess_exec", _record)
1156
+ monkeypatch.setattr(spawn_module.sys, "platform", platform)
1157
+ with pytest.raises(_Stop):
1158
+ await MuseServeChild.spawn(muse_bin=sys.executable, args=["-c", "pass"])
1159
+ assert seen["start_new_session"] is start_new_session
1160
+ assert seen["creationflags"] == creationflags
@@ -0,0 +1,174 @@
1
+ """The Python SDK's client arms for the served Auto-review active-state contract.
2
+
3
+ The wire types under ``muse_code_msp`` are GENERATED from the protocol schema
4
+ by the producing repository's code generator, so this suite tests them and
5
+ never edits them: a wire-type change is a server/schema change, outside the
6
+ client arms.
7
+
8
+ The stable SDK negotiates no ``experimentalApi``: on its connection the
9
+ ``permissionState`` member is STRIPPED and the facade renders no Auto-review
10
+ state. Its arms are therefore the compatibility arms — the stripped member is
11
+ unknown evidence, the experimental member arriving anyway (an embedder that
12
+ opted in) is tolerated losslessly, and a mode-only change folds the mode
13
+ while creating no Auto-review claim. First-run receipts: every arm passes on
14
+ the pre-change facade — pins, recorded as such on the owning change.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import typing
20
+ from typing import Any
21
+
22
+ import muse_code_msp as stable
23
+ from muse_code import Session, read_session_durability
24
+ from muse_code.fold.session_fold import IgnoredUnrecognizedMethod, SessionStateFold
25
+ from muse_code_msp import experimental
26
+
27
+ SESSION = "s-1"
28
+ SOURCE_RANGE: dict[str, Any] = {
29
+ "first": {"recordIndex": 0, "streamOffset": 0},
30
+ "last": {"recordIndex": 0, "streamOffset": 0},
31
+ "stream": {"kind": "run", "runId": "r-1"},
32
+ }
33
+ TRIPLE: experimental.EffectivePermissionState = {
34
+ "profileId": ":auto-review",
35
+ "reviewerAttached": True,
36
+ "reviewerAuthority": "autoReview",
37
+ }
38
+
39
+
40
+ def _keys(typed_dict: Any) -> set[str]:
41
+ return set(typed_dict.__required_keys__) | set(typed_dict.__optional_keys__)
42
+
43
+
44
+ def _fold_only() -> Session[str]:
45
+ return Session(SESSION, read_session_durability({"sessionDurability": "durable"}))
46
+
47
+
48
+ def _claim_surface(value: object) -> list[str]:
49
+ """Every public member name that reads like an Auto-review / permission-state claim.
50
+
51
+ ``dir`` lists instance attributes as well as the class surface, so a
52
+ public field planted on the instance trips it too (the TS twin had to
53
+ widen its scan for that).
54
+ """
55
+ words = ("permission", "reviewer", "auto_review", "autoreview")
56
+ return sorted(name for name in dir(value) if any(word in name.lower() for word in words))
57
+
58
+
59
+ # ---- the experimental bundle types the renders-active triple -----------------
60
+
61
+
62
+ def test_the_experimental_bundle_types_the_renders_active_triple() -> None:
63
+ # Membership, not a freeze: the arm needs these three to be required; a
64
+ # later required member on this experimental type must not red it.
65
+ assert {"profileId", "reviewerAttached", "reviewerAuthority"} <= set(
66
+ experimental.EffectivePermissionState.__required_keys__
67
+ ), "a snapshot cannot answer the predicate with a member missing"
68
+ assert "autoReview" in experimental.REVIEWER_AUTHORITY_KNOWN_VALUES
69
+ # Membership here too: a client that renders nothing off the mode must not
70
+ # red on a fifth mode word; classifying one is the renderer's concern.
71
+ assert {"allowAll", "promptUnmatched", "onRequest", "denyUnmatched"} <= set(
72
+ typing.get_args(experimental.ApprovalMode)
73
+ )
74
+ # Both halves of the triple ride the same served snapshot, additive-optional.
75
+ assert "permissionState" in experimental.Session.__optional_keys__
76
+ assert "approvalMode" in experimental.Session.__optional_keys__
77
+ assert "permissionState" in experimental.SessionStatusChangedParams.__optional_keys__
78
+ assert "permissionState" in experimental.SessionSetPermissionProfileResult.__required_keys__
79
+ assert "session/permissionProfileChanged" in experimental.NOTIFICATIONS
80
+
81
+
82
+ def test_a_stable_connection_sees_the_member_stripped() -> None:
83
+ assert "permissionState" not in _keys(stable.Session)
84
+ assert "approvalMode" in _keys(stable.Session), "the mode half stays stable"
85
+ assert "permissionState" not in _keys(stable.SessionStatusChangedParams)
86
+ assert "session/permissionProfileChanged" not in stable.NOTIFICATIONS
87
+ assert not hasattr(stable, "EffectivePermissionState")
88
+
89
+
90
+ # ---- the facade tolerates the experimental member and claims nothing ----------
91
+
92
+
93
+ def test_status_changed_carrying_the_triple_is_tolerated_and_folds_no_state() -> None:
94
+ session = _fold_only()
95
+ outcome = session.apply(
96
+ {
97
+ "method": "session/statusChanged",
98
+ "params": {
99
+ "sessionId": SESSION,
100
+ "status": "idle",
101
+ "viewCursor": None,
102
+ "permissionState": dict(TRIPLE),
103
+ },
104
+ }
105
+ )
106
+ assert isinstance(outcome.fold, IgnoredUnrecognizedMethod)
107
+ assert outcome.fold.method == "session/statusChanged"
108
+ assert session.fold.session_state.families() == []
109
+
110
+
111
+ def test_permission_profile_changed_is_tolerated_as_a_newer_hosts_method() -> None:
112
+ session = _fold_only()
113
+ outcome = session.apply(
114
+ {
115
+ "method": "session/permissionProfileChanged",
116
+ "params": {
117
+ "clientName": "test",
118
+ "commandId": "cmd-9",
119
+ "profileId": ":auto-review",
120
+ "sessionId": SESSION,
121
+ "source": "client",
122
+ "sourceRange": SOURCE_RANGE,
123
+ "viewCursor": "v:2",
124
+ "permissionState": dict(TRIPLE),
125
+ },
126
+ }
127
+ )
128
+ assert isinstance(outcome.fold, IgnoredUnrecognizedMethod)
129
+ assert session.fold.session_state.families() == []
130
+
131
+
132
+ def test_a_mode_only_change_folds_the_mode_and_the_facade_claims_no_auto_review() -> None:
133
+ session = _fold_only()
134
+ modes = ("onRequest", "promptUnmatched", "allowAll", "denyUnmatched")
135
+ for index, mode in enumerate(modes, start=1):
136
+ # A distinct cursor per event: an equal cursor is a replay the store refuses.
137
+ params = {
138
+ "clientName": "test",
139
+ "commandId": "cmd-1",
140
+ "mode": mode,
141
+ "sessionId": SESSION,
142
+ "source": "approvalReconfigure",
143
+ "sourceRange": SOURCE_RANGE,
144
+ "viewCursor": f"v:{index}",
145
+ }
146
+ outcome = session.apply({"method": "session/approvalModeChanged", "params": params})
147
+ assert isinstance(outcome.fold, SessionStateFold), f"{mode} folds as session state"
148
+ assert session.fold.session_state.get("session/approvalModeChanged") == params
149
+ assert session.fold.session_state.families() == ["session/approvalModeChanged"]
150
+ # A later triple on the live projection changes nothing the fold holds.
151
+ session.apply(
152
+ {
153
+ "method": "session/statusChanged",
154
+ "params": {
155
+ "sessionId": SESSION,
156
+ "status": "idle",
157
+ "viewCursor": None,
158
+ "permissionState": dict(TRIPLE),
159
+ },
160
+ }
161
+ )
162
+ assert session.fold.session_state.families() == ["session/approvalModeChanged"]
163
+ # Contract tripwire: a client may report active Auto-review only when the
164
+ # served mode, reviewer authority, and attachment agree in one snapshot.
165
+ # The stable facade exposes NO Auto-review / permission state accessor, so
166
+ # nothing can report "active" off the mode or a label alone; a future
167
+ # accessor must flip this pin AND satisfy the whole triple.
168
+ assert _claim_surface(session) == []
169
+
170
+
171
+ def test_the_claim_surface_tripwire_sees_a_planted_instance_attribute() -> None:
172
+ session = _fold_only()
173
+ setattr(session, "permission_state", {})
174
+ assert _claim_surface(session) == ["permission_state"]
@@ -1013,10 +1013,14 @@ def test_every_generated_view_notification_is_routed_never_the_default() -> None
1013
1013
  # like the handshake's `initialized`; the client handles it out of band.
1014
1014
  # `userInput/engaged` is client-to-server, like the handshake's
1015
1015
  # `initialized` — the fold never sees it.
1016
+ # `recap/completed` is a command-plane settlement to the requesting
1017
+ # connection only — no `viewCursor`, no `sourceRange`, nothing durable
1018
+ # to fold.
1016
1019
  non_fold_notifications = {
1017
1020
  "initialized",
1018
1021
  "session/viewHealthChanged",
1019
1022
  "userInput/engaged",
1023
+ "recap/completed",
1020
1024
  }
1021
1025
  # Session lifecycle broadcasts (a session was loaded or unloaded on the
1022
1026
  # host) are control-plane notifications, not view events — they carry no
File without changes
File without changes
File without changes