muse-code-sdk 1.4.10__tar.gz → 1.4.12__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.10/src/muse_code_sdk.egg-info → muse_code_sdk-1.4.12}/PKG-INFO +11 -4
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/README.md +9 -3
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/pyproject.toml +9 -8
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/__init__.py +2 -2
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/facade/approval.py +79 -18
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/facade/client.py +92 -5
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/facade/gap_fill.py +6 -1
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/facade/session.py +128 -5
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/fold/__init__.py +2 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/fold/session_fold.py +53 -8
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/sync_facade.py +15 -1
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12/src/muse_code_sdk.egg-info}/PKG-INFO +11 -4
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code_sdk.egg-info/SOURCES.txt +3 -0
- muse_code_sdk-1.4.12/src/muse_code_sdk.egg-info/requires.txt +2 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_facade_approval.py +231 -7
- muse_code_sdk-1.4.12/tests/test_facade_approval_resend.py +498 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_facade_gap_fill.py +59 -1
- muse_code_sdk-1.4.12/tests/test_facade_user_input_resend.py +384 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_manifests.py +7 -3
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_public_mirror_text.py +10 -3
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_publish_shape.py +84 -14
- muse_code_sdk-1.4.12/tests/test_recovery_observation.py +305 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_session_fold.py +147 -1
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_sync_facade.py +29 -0
- muse_code_sdk-1.4.10/src/muse_code_sdk.egg-info/requires.txt +0 -1
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/LICENSE +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/setup.cfg +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/connection/__init__.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/connection/connection.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/connection/discovery.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/connection/spawn.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/connection/validation.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/errors.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/facade/__init__.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/facade/discarded.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/facade/host_death.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/facade/host_start.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/facade/turn_handle.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/facade/turn_submit.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/fold/item_store.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/fold/state_store.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/pending/__init__.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/pending/pending_command_set.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/py.typed +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/replay.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code_sdk.egg-info/dependency_links.txt +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code_sdk.egg-info/top_level.txt +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_asyncio_cancellation.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_compatibility_row.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_connection.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_connection_error_arms.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_docs_generation.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_external_audience.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_facade_open_same_chunk.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_facade_persistent_approval.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_facade_required_selection.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_facade_turn.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_host_discovery.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_item_store.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_muse_serve_child.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_pending_command_corpus.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_pending_command_set.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_pending_command_wire_binding.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_permission_state_wire_types.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_source_closure.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_spawn_handshake.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_spawn_host_lost.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_state_store.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_tolerance_corpus.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_transcript_replay.py +0 -0
- {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/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.12
|
|
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
|
|
@@ -15,6 +15,7 @@ Requires-Python: >=3.10
|
|
|
15
15
|
Description-Content-Type: text/markdown
|
|
16
16
|
License-File: LICENSE
|
|
17
17
|
Requires-Dist: pydantic==2.13.5
|
|
18
|
+
Requires-Dist: muse-code-msp==1.4.12
|
|
18
19
|
Dynamic: license-file
|
|
19
20
|
|
|
20
21
|
# `muse-code-sdk`
|
|
@@ -27,7 +28,12 @@ wrapper for scripts and notebooks.
|
|
|
27
28
|
one you already run.
|
|
28
29
|
- Stream a turn's items and text deltas as async iterators; await the turn's
|
|
29
30
|
server-authored outcome.
|
|
30
|
-
- Answer tool-approval requests with a handler you register
|
|
31
|
+
- Answer tool-approval requests with a handler you register, or pass it as
|
|
32
|
+
`ResumeSessionOptions(on_approval=...)`, with `on_approval_error=...` for
|
|
33
|
+
its failures, so approvals still pending at resume reach it.
|
|
34
|
+
- Answer the agent's questions: list them with
|
|
35
|
+
`session.fold.pending_user_inputs()` and answer with
|
|
36
|
+
`session.answer_user_input`.
|
|
31
37
|
- A client-side fold keeps a live, typed view of the session — items, session
|
|
32
38
|
state, pending commands — with gap recovery after reconnects, and safe
|
|
33
39
|
same-command retry that can never double-submit.
|
|
@@ -37,13 +43,14 @@ Status: developer preview. Experimental protocol surfaces may change.
|
|
|
37
43
|
|
|
38
44
|
The generated MSP wire types live in the companion package
|
|
39
45
|
[`muse-code-msp`](https://pypi.org/project/muse-code-msp/) and are never
|
|
40
|
-
restated here
|
|
46
|
+
restated here; it is installed with this package, pinned to the same version.
|
|
47
|
+
One further runtime dependency: `pydantic`, exact-pinned.
|
|
41
48
|
Python 3.10+. Fully typed (`py.typed`).
|
|
42
49
|
|
|
43
50
|
## Install
|
|
44
51
|
|
|
45
52
|
```sh
|
|
46
|
-
pip install muse-code-sdk
|
|
53
|
+
pip install muse-code-sdk
|
|
47
54
|
```
|
|
48
55
|
|
|
49
56
|
You also need the Muse Code CLI: a `muse` binary on `PATH`, or an explicit
|
|
@@ -8,7 +8,12 @@ wrapper for scripts and notebooks.
|
|
|
8
8
|
one you already run.
|
|
9
9
|
- Stream a turn's items and text deltas as async iterators; await the turn's
|
|
10
10
|
server-authored outcome.
|
|
11
|
-
- Answer tool-approval requests with a handler you register
|
|
11
|
+
- Answer tool-approval requests with a handler you register, or pass it as
|
|
12
|
+
`ResumeSessionOptions(on_approval=...)`, with `on_approval_error=...` for
|
|
13
|
+
its failures, so approvals still pending at resume reach it.
|
|
14
|
+
- Answer the agent's questions: list them with
|
|
15
|
+
`session.fold.pending_user_inputs()` and answer with
|
|
16
|
+
`session.answer_user_input`.
|
|
12
17
|
- A client-side fold keeps a live, typed view of the session — items, session
|
|
13
18
|
state, pending commands — with gap recovery after reconnects, and safe
|
|
14
19
|
same-command retry that can never double-submit.
|
|
@@ -18,13 +23,14 @@ Status: developer preview. Experimental protocol surfaces may change.
|
|
|
18
23
|
|
|
19
24
|
The generated MSP wire types live in the companion package
|
|
20
25
|
[`muse-code-msp`](https://pypi.org/project/muse-code-msp/) and are never
|
|
21
|
-
restated here
|
|
26
|
+
restated here; it is installed with this package, pinned to the same version.
|
|
27
|
+
One further runtime dependency: `pydantic`, exact-pinned.
|
|
22
28
|
Python 3.10+. Fully typed (`py.typed`).
|
|
23
29
|
|
|
24
30
|
## Install
|
|
25
31
|
|
|
26
32
|
```sh
|
|
27
|
-
pip install muse-code-sdk
|
|
33
|
+
pip install muse-code-sdk
|
|
28
34
|
```
|
|
29
35
|
|
|
30
36
|
You also need the Muse Code CLI: a `muse` binary on `PATH`, or an explicit
|
|
@@ -5,19 +5,20 @@ build-backend = "setuptools.build_meta"
|
|
|
5
5
|
|
|
6
6
|
[project]
|
|
7
7
|
name = "muse-code-sdk"
|
|
8
|
-
version = "1.4.
|
|
8
|
+
version = "1.4.12"
|
|
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"
|
|
12
12
|
license = "MIT"
|
|
13
13
|
license-files = ["LICENSE"]
|
|
14
|
-
#
|
|
15
|
-
# supply-chain exception
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
|
|
14
|
+
# Two runtime dependencies, both exact-pinned. pydantic is the deliberate,
|
|
15
|
+
# ruled supply-chain exception; bumps are deliberate reviewed PRs.
|
|
16
|
+
# muse-code-msp is the companion wire-types package generated and released
|
|
17
|
+
# from the same tree, so its pin is this package's own version: a plain
|
|
18
|
+
# `pip install muse-code-sdk` must import, and the release bump moves this
|
|
19
|
+
# package's version and its muse-code-msp pin together; pydantic bumps stay
|
|
20
|
+
# reviewed PRs (test_publish_shape pins the pairing).
|
|
21
|
+
dependencies = ["pydantic==2.13.5", "muse-code-msp==1.4.12"]
|
|
21
22
|
classifiers = [
|
|
22
23
|
# Alpha states the withheld promise: publication is permitted, but the
|
|
23
24
|
# stability promise and conformance claim stay withheld — experimental
|
|
@@ -80,7 +80,7 @@ __all__ = [
|
|
|
80
80
|
]
|
|
81
81
|
|
|
82
82
|
EXPECTED_SCHEMA_FINGERPRINT: Final[str] = (
|
|
83
|
-
"sha256:
|
|
83
|
+
"sha256:cfdb6d2525601b9bac76584ccb0610ab3a5539b5e4f51141f079b99b39fcb4e0"
|
|
84
84
|
)
|
|
85
85
|
"""The stable schema-bundle fingerprint this SDK was built against.
|
|
86
86
|
|
|
@@ -92,7 +92,7 @@ Python SDK reds this lane instead of drifting silently.
|
|
|
92
92
|
# The lockstep rule: the release train bumps pyproject.toml, the
|
|
93
93
|
# distribution-metadata authority; this constant is aligned at publication
|
|
94
94
|
# time.
|
|
95
|
-
__version__: Final[str] = "1.4.
|
|
95
|
+
__version__: Final[str] = "1.4.12"
|
|
96
96
|
|
|
97
97
|
# The facade is imported last so the constants above are already bound: the
|
|
98
98
|
# spawn path reads ``EXPECTED_SCHEMA_FINGERPRINT`` (lazily, at initialize
|
|
@@ -1,18 +1,17 @@
|
|
|
1
|
-
"""The approval round trip: inbound ``approval/requested``
|
|
2
|
-
handler → outbound ``approval/decide``.
|
|
3
|
-
approval router.
|
|
1
|
+
"""The approval round trip: inbound ``approval/requested`` or
|
|
2
|
+
``approval/request`` → the consumer's handler → outbound ``approval/decide``.
|
|
3
|
+
Port of the TypeScript SDK's approval router.
|
|
4
4
|
|
|
5
5
|
Split out of ``session.py`` rather than added to it: the router owns a
|
|
6
6
|
handler, a per-stage latch, and a decision-shaping guard chain that have
|
|
7
7
|
nothing to do with folding a view, and ``Session`` was already the package's
|
|
8
8
|
largest module.
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
request upstream, never a local interface.
|
|
10
|
+
Two frames carry a request in: the ``approval/requested`` notification and
|
|
11
|
+
the ``approval/request`` server request, which the host sends beside it and
|
|
12
|
+
re-sends after a resume. A client-owned connection routes both here, and the
|
|
13
|
+
per-stage latch decides each stage once, whichever arrives first. The
|
|
14
|
+
decision goes out as the ``approval/decide`` command.
|
|
16
15
|
"""
|
|
17
16
|
|
|
18
17
|
from __future__ import annotations
|
|
@@ -62,6 +61,7 @@ _REFRESH_FROM_UPDATE = frozenset(
|
|
|
62
61
|
_REFRESH_FROM_REQUEST = frozenset(
|
|
63
62
|
{
|
|
64
63
|
"approvalId",
|
|
64
|
+
"cuaCardFacts",
|
|
65
65
|
"itemId",
|
|
66
66
|
"judgeEscalated",
|
|
67
67
|
"protectedWrite",
|
|
@@ -112,6 +112,22 @@ class UnofferedChoice:
|
|
|
112
112
|
kind: Literal["unofferedChoice"] = "unofferedChoice"
|
|
113
113
|
|
|
114
114
|
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class FeedbackNotAccepted:
|
|
117
|
+
"""The handler attached ``feedback`` to a choice whose ``acceptsFeedback``
|
|
118
|
+
is not true. The host would refuse that decision, so it is not sent; the
|
|
119
|
+
offered reject (the ``denied`` choice, else the ``abort`` one) is sent
|
|
120
|
+
next, so the approval closes. With neither offered, nothing is sent and the
|
|
121
|
+
approval stays open; a failure to send the reject is reported as
|
|
122
|
+
``SubmitFailed``. Next time, answer without feedback, or pick one of
|
|
123
|
+
``feedback_choice_ids``, the offered choices that accept it."""
|
|
124
|
+
|
|
125
|
+
approval_id: str
|
|
126
|
+
choice_id: str
|
|
127
|
+
feedback_choice_ids: tuple[str, ...]
|
|
128
|
+
kind: Literal["feedbackNotAccepted"] = "feedbackNotAccepted"
|
|
129
|
+
|
|
130
|
+
|
|
115
131
|
@dataclass(frozen=True)
|
|
116
132
|
class HandlerRaised:
|
|
117
133
|
"""The consumer's decision handler raised; nothing reached the wire."""
|
|
@@ -131,14 +147,15 @@ class SubmitFailed:
|
|
|
131
147
|
kind: Literal["submitFailed"] = "submitFailed"
|
|
132
148
|
|
|
133
149
|
|
|
134
|
-
ApprovalFailure = Union[UnofferedChoice, HandlerRaised, SubmitFailed]
|
|
150
|
+
ApprovalFailure = Union[UnofferedChoice, FeedbackNotAccepted, HandlerRaised, SubmitFailed]
|
|
135
151
|
"""Why one approval round trip did not complete.
|
|
136
152
|
|
|
137
153
|
REPORTED, not raised: the trip is driven from a notification, so an exception
|
|
138
154
|
would escape into the consumer's pump with nothing to catch it — the same
|
|
139
|
-
hazard ``Session.apply`` already refuses a malformed frame for. Each
|
|
140
|
-
|
|
141
|
-
because the repair
|
|
155
|
+
hazard ``Session.apply`` already refuses a malformed frame for. Each kind names
|
|
156
|
+
whose repair it is (the consumer's handler; the consumer's choice, for
|
|
157
|
+
``unofferedChoice`` and ``feedbackNotAccepted``; the host), because the repair
|
|
158
|
+
differs.
|
|
142
159
|
"""
|
|
143
160
|
|
|
144
161
|
ApprovalFailureHandler = Callable[[ApprovalFailure], None]
|
|
@@ -146,9 +163,10 @@ ApprovalFailureHandler = Callable[[ApprovalFailure], None]
|
|
|
146
163
|
|
|
147
164
|
|
|
148
165
|
class ApprovalRouter:
|
|
149
|
-
"""Routes folded ``approval/requested`` frames,
|
|
150
|
-
frames that advance a pending approval's
|
|
151
|
-
and authors the ``approval/decide``
|
|
166
|
+
"""Routes folded ``approval/requested`` frames, routed ``approval/request``
|
|
167
|
+
frames, and ``approval/updated`` frames that advance a pending approval's
|
|
168
|
+
stage, to the consumer's handler and authors the ``approval/decide``
|
|
169
|
+
command."""
|
|
152
170
|
|
|
153
171
|
def __init__(self, session_id: str, connection: Connection | None) -> None:
|
|
154
172
|
"""Builds the router; ``connection`` is absent on a fold-only session.
|
|
@@ -194,7 +212,8 @@ class ApprovalRouter:
|
|
|
194
212
|
self._on_failure = handler
|
|
195
213
|
|
|
196
214
|
def requested(self, params: ApprovalRequestParams) -> Awaitable[None] | None:
|
|
197
|
-
"""An ``approval/requested``
|
|
215
|
+
"""An ``approval/requested`` the fold kept pending, or a routed
|
|
216
|
+
``approval/request``. Returns the decision round trip to
|
|
198
217
|
await, or ``None`` when this call schedules nothing — no handler, an
|
|
199
218
|
already-decided stage, or a fold-only session (whose ``SubmitFailed``
|
|
200
219
|
is reported synchronously before returning).
|
|
@@ -279,6 +298,10 @@ class ApprovalRouter:
|
|
|
279
298
|
# Omitted rather than nulled when absent, like ``feedback`` below.
|
|
280
299
|
if "subagentOrigin" in params:
|
|
281
300
|
refreshed["subagentOrigin"] = params["subagentOrigin"]
|
|
301
|
+
# A Computer Use card's facts belong to the call, so they stay with
|
|
302
|
+
# the retained request across stages.
|
|
303
|
+
if "cuaCardFacts" in requested:
|
|
304
|
+
refreshed["cuaCardFacts"] = requested["cuaCardFacts"]
|
|
282
305
|
acked = self._in_flight.get(requested["approvalId"])
|
|
283
306
|
if acked is not None:
|
|
284
307
|
return self._after_ack(acked, refreshed)
|
|
@@ -327,7 +350,15 @@ class ApprovalRouter:
|
|
|
327
350
|
available_choice_ids = tuple(
|
|
328
351
|
choice["choiceId"] for choice in params["availableChoices"]
|
|
329
352
|
)
|
|
330
|
-
|
|
353
|
+
chosen = next(
|
|
354
|
+
(
|
|
355
|
+
choice
|
|
356
|
+
for choice in params["availableChoices"]
|
|
357
|
+
if choice["choiceId"] == choice_id
|
|
358
|
+
),
|
|
359
|
+
None,
|
|
360
|
+
)
|
|
361
|
+
if chosen is None:
|
|
331
362
|
# Select-never-create, enforced on THIS side of the
|
|
332
363
|
# transport. An invented choice bounces -32052 a round trip later,
|
|
333
364
|
# by which time the stage may have advanced — so the diagnosis a
|
|
@@ -341,6 +372,36 @@ class ApprovalRouter:
|
|
|
341
372
|
)
|
|
342
373
|
)
|
|
343
374
|
return
|
|
375
|
+
if feedback is not None and chosen.get("acceptsFeedback") is not True:
|
|
376
|
+
# The host refuses feedback on any other choice (-32052) and
|
|
377
|
+
# keeps its approval open, so refuse it here and answer with the
|
|
378
|
+
# offered reject instead, the choice the Rust SDK sends too: the
|
|
379
|
+
# approval closes and the host's turn goes on. With neither a
|
|
380
|
+
# ``denied`` nor an ``abort`` choice, nothing is sent.
|
|
381
|
+
self._report(
|
|
382
|
+
FeedbackNotAccepted(
|
|
383
|
+
approval_id=params["approvalId"],
|
|
384
|
+
choice_id=choice_id,
|
|
385
|
+
feedback_choice_ids=tuple(
|
|
386
|
+
choice["choiceId"]
|
|
387
|
+
for choice in params["availableChoices"]
|
|
388
|
+
if choice.get("acceptsFeedback") is True
|
|
389
|
+
),
|
|
390
|
+
)
|
|
391
|
+
)
|
|
392
|
+
reject = next(
|
|
393
|
+
(
|
|
394
|
+
choice
|
|
395
|
+
for decision in ("denied", "abort")
|
|
396
|
+
for choice in params["availableChoices"]
|
|
397
|
+
if choice.get("decision") == decision
|
|
398
|
+
),
|
|
399
|
+
None,
|
|
400
|
+
)
|
|
401
|
+
if reject is None:
|
|
402
|
+
return
|
|
403
|
+
choice_id = reject["choiceId"]
|
|
404
|
+
feedback = None
|
|
344
405
|
|
|
345
406
|
decide_params: dict[str, Any] = {
|
|
346
407
|
"approvalId": params["approvalId"],
|
|
@@ -40,11 +40,12 @@ from muse_code_msp import (
|
|
|
40
40
|
ClientInfo,
|
|
41
41
|
InitializeParams,
|
|
42
42
|
InitializeResult,
|
|
43
|
+
RecoveryMode,
|
|
43
44
|
SessionResumeParams,
|
|
44
45
|
SessionStartParams,
|
|
45
46
|
)
|
|
46
47
|
|
|
47
|
-
from ..connection.connection import Connection
|
|
48
|
+
from ..connection.connection import Connection, MspError
|
|
48
49
|
from ..connection.spawn import (
|
|
49
50
|
DEFAULT_SHUTDOWN_TIMEOUT_MS,
|
|
50
51
|
ExitClassification,
|
|
@@ -63,6 +64,7 @@ from .host_death import (
|
|
|
63
64
|
read_session_durability,
|
|
64
65
|
survives_host_death,
|
|
65
66
|
)
|
|
67
|
+
from .approval import ApprovalFailureHandler, ApprovalHandler
|
|
66
68
|
from .session import Session, SessionOpening, _ResumeOpening, _StartOpening
|
|
67
69
|
from .turn_submit import _camel
|
|
68
70
|
|
|
@@ -105,6 +107,8 @@ class StartSessionOptions:
|
|
|
105
107
|
of proceeding on the host default. Client-local: never sent on
|
|
106
108
|
the wire. Setting it without ``permission_profile`` is a usage
|
|
107
109
|
error.
|
|
110
|
+
recovery_mode: Optional recovery selection (wire ``recoveryMode``);
|
|
111
|
+
omitted means no selection is sent.
|
|
108
112
|
"""
|
|
109
113
|
|
|
110
114
|
approval_mode: str | None = None
|
|
@@ -115,6 +119,7 @@ class StartSessionOptions:
|
|
|
115
119
|
model_id: str | None = None
|
|
116
120
|
permission_profile: str | None = None
|
|
117
121
|
require_permission_profile: bool = False
|
|
122
|
+
recovery_mode: RecoveryMode | None = None
|
|
118
123
|
|
|
119
124
|
|
|
120
125
|
@dataclass
|
|
@@ -134,6 +139,18 @@ class ResumeSessionOptions:
|
|
|
134
139
|
wire's ``permissionProfile`` member: the host restores the
|
|
135
140
|
retained authority first, then applies the pick. Omitted means
|
|
136
141
|
no override.
|
|
142
|
+
recovery_mode: Optional recovery selection (wire ``recoveryMode``);
|
|
143
|
+
omitted means no selection is sent.
|
|
144
|
+
on_approval: Answers this session's approvals from its first frame,
|
|
145
|
+
so an approval the host re-sends right after the resume reply
|
|
146
|
+
reaches it. The same contract as ``Session.on_approval``,
|
|
147
|
+
installed before the frames that arrived with the reply are
|
|
148
|
+
applied. Never sent on the wire.
|
|
149
|
+
on_approval_error: Hears this session's approval failures from its
|
|
150
|
+
first frame, as ``Session.on_approval_error`` does later, so a
|
|
151
|
+
failure for an approval that arrived with the resume reply is
|
|
152
|
+
heard whatever task the caller registers from. Never sent on the
|
|
153
|
+
wire.
|
|
137
154
|
"""
|
|
138
155
|
|
|
139
156
|
session_id: str
|
|
@@ -141,6 +158,9 @@ class ResumeSessionOptions:
|
|
|
141
158
|
exclude_items: bool | None = None
|
|
142
159
|
history: Mapping[str, Any] | None = None
|
|
143
160
|
permission_profile: str | None = None
|
|
161
|
+
recovery_mode: RecoveryMode | None = None
|
|
162
|
+
on_approval: ApprovalHandler | None = None
|
|
163
|
+
on_approval_error: ApprovalFailureHandler | None = None
|
|
144
164
|
|
|
145
165
|
|
|
146
166
|
# Drift pins — the Python twin of the TS `AssertNever` on START_FORWARDED /
|
|
@@ -162,7 +182,10 @@ assert (
|
|
|
162
182
|
), "StartSessionOptions drifted from SessionStartParams — forward the new member"
|
|
163
183
|
|
|
164
184
|
_RESUME_EXCLUDED = {"commandId", "config"}
|
|
165
|
-
|
|
185
|
+
_RESUME_OWNED = {"onApproval", "onApprovalError"}
|
|
186
|
+
assert (
|
|
187
|
+
{_camel(f.name) for f in fields(ResumeSessionOptions)} - _RESUME_OWNED
|
|
188
|
+
) | _RESUME_EXCLUDED == set(
|
|
166
189
|
SessionResumeParams.__annotations__
|
|
167
190
|
), "ResumeSessionOptions drifted from SessionResumeParams — forward the new member"
|
|
168
191
|
|
|
@@ -279,6 +302,14 @@ class MuseClient(Generic[_I]):
|
|
|
279
302
|
# spawn-built consumer hears no view frame. ``Connection`` holds ONE
|
|
280
303
|
# notification handler — this client claims it.
|
|
281
304
|
connection.on_notification(self._route)
|
|
305
|
+
# A question or approval still open when a session is resumed is
|
|
306
|
+
# re-sent as a ``userInput/request`` or ``approval/request`` server
|
|
307
|
+
# request. Each is routed and answered with the empty receipt ``{}``:
|
|
308
|
+
# a question as the ``userInput/requested`` twin it shares params with,
|
|
309
|
+
# so its session lists it, and an approval as itself, which the session
|
|
310
|
+
# routes to its approval handler. Every other server-request method
|
|
311
|
+
# keeps the ``-32601`` a bare ``Connection`` answers.
|
|
312
|
+
connection.on_server_request(self._serve_request)
|
|
282
313
|
# Registered at construction, not at the first session: an EOF can land
|
|
283
314
|
# before any session exists, and the flag has to be read at that moment.
|
|
284
315
|
self._closed_watch = asyncio.ensure_future(self._watch_closed())
|
|
@@ -417,6 +448,8 @@ class MuseClient(Generic[_I]):
|
|
|
417
448
|
params["workspaceRoots"] = options.workspace_roots
|
|
418
449
|
if options.model_id is not None:
|
|
419
450
|
params["modelId"] = options.model_id
|
|
451
|
+
if options.recovery_mode is not None:
|
|
452
|
+
params["recoveryMode"] = options.recovery_mode
|
|
420
453
|
# The initial permission-profile selection, forwarded verbatim like the
|
|
421
454
|
# other start members (None stays omitted).
|
|
422
455
|
if options.permission_profile is not None:
|
|
@@ -488,9 +521,17 @@ class MuseClient(Generic[_I]):
|
|
|
488
521
|
params["excludeItems"] = options.exclude_items
|
|
489
522
|
if options.history is not None:
|
|
490
523
|
params["history"] = options.history
|
|
524
|
+
if options.recovery_mode is not None:
|
|
525
|
+
params["recoveryMode"] = options.recovery_mode
|
|
491
526
|
if options.permission_profile is not None:
|
|
492
527
|
params["permissionProfile"] = options.permission_profile
|
|
493
|
-
return await self._open(
|
|
528
|
+
return await self._open(
|
|
529
|
+
"session/resume",
|
|
530
|
+
params,
|
|
531
|
+
_ResumeOpening,
|
|
532
|
+
options.on_approval,
|
|
533
|
+
options.on_approval_error,
|
|
534
|
+
)
|
|
494
535
|
|
|
495
536
|
async def close(self) -> None:
|
|
496
537
|
"""Shut the host down in an orderly way.
|
|
@@ -519,6 +560,8 @@ class MuseClient(Generic[_I]):
|
|
|
519
560
|
verb: str,
|
|
520
561
|
params: dict[str, Any],
|
|
521
562
|
opening: Callable[[Mapping[str, Any]], SessionOpening],
|
|
563
|
+
on_approval: ApprovalHandler | None = None,
|
|
564
|
+
on_approval_error: ApprovalFailureHandler | None = None,
|
|
522
565
|
) -> Session[_I]:
|
|
523
566
|
# The window ``_route`` holds unknown-session frames for: from the
|
|
524
567
|
# request to the Session's registration. Counted, not flagged, so two
|
|
@@ -530,7 +573,10 @@ class MuseClient(Generic[_I]):
|
|
|
530
573
|
raw = await self._connection.command(verb, params)
|
|
531
574
|
result: Mapping[str, Any] = raw
|
|
532
575
|
return self._open_session(
|
|
533
|
-
str(result["session"]["sessionId"]),
|
|
576
|
+
str(result["session"]["sessionId"]),
|
|
577
|
+
opening(result),
|
|
578
|
+
on_approval,
|
|
579
|
+
on_approval_error,
|
|
534
580
|
)
|
|
535
581
|
finally:
|
|
536
582
|
self._opening -= 1
|
|
@@ -538,7 +584,11 @@ class MuseClient(Generic[_I]):
|
|
|
538
584
|
self._held.clear()
|
|
539
585
|
|
|
540
586
|
def _open_session(
|
|
541
|
-
self,
|
|
587
|
+
self,
|
|
588
|
+
session_id: str,
|
|
589
|
+
opening: SessionOpening,
|
|
590
|
+
on_approval: ApprovalHandler | None = None,
|
|
591
|
+
on_approval_error: ApprovalFailureHandler | None = None,
|
|
542
592
|
) -> Session[_I]:
|
|
543
593
|
session: Session[_I] = Session(
|
|
544
594
|
session_id,
|
|
@@ -547,6 +597,12 @@ class MuseClient(Generic[_I]):
|
|
|
547
597
|
discarded=self._discarded,
|
|
548
598
|
opening=opening,
|
|
549
599
|
)
|
|
600
|
+
# Before the held frames below: an approval re-sent with the resume
|
|
601
|
+
# reply must find the handler, and its failure the observer.
|
|
602
|
+
if on_approval_error is not None:
|
|
603
|
+
session.on_approval_error(on_approval_error)
|
|
604
|
+
if on_approval is not None:
|
|
605
|
+
session.on_approval(on_approval)
|
|
550
606
|
self._sessions.add(session)
|
|
551
607
|
# The frames that named this session while its verb was in flight
|
|
552
608
|
# fold NOW, in arrival order — synchronously, so nothing the pump
|
|
@@ -570,6 +626,37 @@ class MuseClient(Generic[_I]):
|
|
|
570
626
|
"reattaching to it"
|
|
571
627
|
)
|
|
572
628
|
|
|
629
|
+
async def _serve_request(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
|
630
|
+
# The connection runs this as its own task, so the twin can land after
|
|
631
|
+
# frames read in the same chunk. The same questions are listed, since
|
|
632
|
+
# the list is keyed by question id and a settled question stays
|
|
633
|
+
# settled; their order can differ when another question arrives in
|
|
634
|
+
# that chunk. The receipt does not depend on ``_route`` finding the
|
|
635
|
+
# session: a request read during an open is held before that session
|
|
636
|
+
# exists.
|
|
637
|
+
method = request.get("method")
|
|
638
|
+
if method == "userInput/request":
|
|
639
|
+
self._route(
|
|
640
|
+
{
|
|
641
|
+
"jsonrpc": "2.0",
|
|
642
|
+
"method": "userInput/requested",
|
|
643
|
+
"params": request.get("params"),
|
|
644
|
+
}
|
|
645
|
+
)
|
|
646
|
+
return {}
|
|
647
|
+
if method == "approval/request":
|
|
648
|
+
self._route(
|
|
649
|
+
{"jsonrpc": "2.0", "method": method, "params": request.get("params")}
|
|
650
|
+
)
|
|
651
|
+
return {}
|
|
652
|
+
raise MspError(
|
|
653
|
+
{
|
|
654
|
+
"code": -32601,
|
|
655
|
+
"message": f"method not found: {method}",
|
|
656
|
+
"data": {"kind": "methodNotFound", "retryable": False},
|
|
657
|
+
}
|
|
658
|
+
)
|
|
659
|
+
|
|
573
660
|
def _route(self, notification: Mapping[str, Any]) -> None:
|
|
574
661
|
# Deliver one view notification to the session(s) it names. A frame
|
|
575
662
|
# naming NO session is dropped here rather than fed to every session
|
|
@@ -206,7 +206,8 @@ class GapFiller(Generic[_I]):
|
|
|
206
206
|
"""
|
|
207
207
|
gap = self._options.fold.pending_gap
|
|
208
208
|
# Only reachable if a caller invokes this with nothing outstanding; the
|
|
209
|
-
# one production caller folds the marker first, which
|
|
209
|
+
# one production caller folds the marker first, which sets it or, for
|
|
210
|
+
# a stale repeat, finds it already set.
|
|
210
211
|
if gap is None:
|
|
211
212
|
return None
|
|
212
213
|
if self._filling:
|
|
@@ -489,6 +490,10 @@ class GapFiller(Generic[_I]):
|
|
|
489
490
|
|
|
490
491
|
|
|
491
492
|
def _view_cursor_of(frame: WireFrame) -> str | None:
|
|
493
|
+
# A routed ``approval/request`` carries its notification's cursor but is
|
|
494
|
+
# not that event: matching it would spend the twin's overlap slot.
|
|
495
|
+
if frame.get("method") == "approval/request":
|
|
496
|
+
return None
|
|
492
497
|
params = frame.get("params")
|
|
493
498
|
cursor = params.get("viewCursor") if isinstance(params, dict) else None
|
|
494
499
|
return cursor if isinstance(cursor, str) else None
|