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.
- {muse_code_sdk-1.4.4/src/muse_code_sdk.egg-info → muse_code_sdk-1.4.7}/PKG-INFO +1 -1
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/pyproject.toml +1 -1
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/__init__.py +4 -2
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/connection/spawn.py +11 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/errors.py +33 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/facade/client.py +93 -7
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/facade/turn_submit.py +8 -1
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/fold/session_fold.py +17 -2
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7/src/muse_code_sdk.egg-info}/PKG-INFO +1 -1
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code_sdk.egg-info/SOURCES.txt +2 -0
- muse_code_sdk-1.4.7/tests/test_facade_required_selection.py +305 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_facade_turn.py +32 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_muse_serve_child.py +40 -0
- muse_code_sdk-1.4.7/tests/test_permission_state_wire_types.py +174 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_session_fold.py +4 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/LICENSE +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/README.md +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/setup.cfg +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/connection/__init__.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/connection/connection.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/connection/discovery.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/connection/validation.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/facade/__init__.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/facade/approval.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/facade/discarded.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/facade/gap_fill.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/facade/host_death.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/facade/host_start.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/facade/session.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/facade/turn_handle.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/fold/__init__.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/fold/item_store.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/fold/state_store.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/pending/__init__.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/pending/pending_command_set.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/py.typed +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/replay.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code/sync_facade.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code_sdk.egg-info/dependency_links.txt +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code_sdk.egg-info/requires.txt +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/src/muse_code_sdk.egg-info/top_level.txt +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_asyncio_cancellation.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_compatibility_row.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_connection.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_connection_error_arms.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_docs_generation.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_external_audience.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_facade_approval.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_facade_gap_fill.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_facade_open_same_chunk.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_facade_persistent_approval.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_host_discovery.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_item_store.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_manifests.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_pending_command_corpus.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_pending_command_set.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_pending_command_wire_binding.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_public_mirror_text.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_publish_shape.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_source_closure.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_spawn_handshake.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_spawn_host_lost.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_state_store.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_sync_facade.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_tolerance_corpus.py +0 -0
- {muse_code_sdk-1.4.4 → muse_code_sdk-1.4.7}/tests/test_transcript_replay.py +0 -0
- {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.
|
|
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.
|
|
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:
|
|
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.
|
|
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
|
-
|
|
132
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|