muse-code-sdk 1.4.9__tar.gz → 1.4.11__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.9 → muse_code_sdk-1.4.11}/PKG-INFO +7 -2
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/README.md +6 -1
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/pyproject.toml +1 -1
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/src/muse_code/__init__.py +2 -2
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/src/muse_code/facade/approval.py +74 -18
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/src/muse_code/facade/client.py +92 -5
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/src/muse_code/facade/gap_fill.py +6 -1
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/src/muse_code/facade/session.py +128 -5
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/src/muse_code/fold/__init__.py +2 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/src/muse_code/fold/session_fold.py +74 -16
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/src/muse_code/sync_facade.py +15 -1
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/src/muse_code_sdk.egg-info/PKG-INFO +7 -2
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/src/muse_code_sdk.egg-info/SOURCES.txt +3 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/tests/test_external_audience.py +188 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/tests/test_facade_approval.py +184 -7
- muse_code_sdk-1.4.11/tests/test_facade_approval_resend.py +498 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/tests/test_facade_gap_fill.py +59 -1
- muse_code_sdk-1.4.11/tests/test_facade_user_input_resend.py +384 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/tests/test_public_mirror_text.py +44 -0
- muse_code_sdk-1.4.11/tests/test_recovery_observation.py +293 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/tests/test_session_fold.py +232 -11
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/tests/test_sync_facade.py +29 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/LICENSE +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/setup.cfg +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/src/muse_code/connection/__init__.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/src/muse_code/connection/connection.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/src/muse_code/connection/discovery.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/src/muse_code/connection/spawn.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/src/muse_code/connection/validation.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/src/muse_code/errors.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/src/muse_code/facade/__init__.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/src/muse_code/facade/discarded.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/src/muse_code/facade/host_death.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/src/muse_code/facade/host_start.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/src/muse_code/facade/turn_handle.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/src/muse_code/facade/turn_submit.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/src/muse_code/fold/item_store.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/src/muse_code/fold/state_store.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/src/muse_code/pending/__init__.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/src/muse_code/pending/pending_command_set.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/src/muse_code/py.typed +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/src/muse_code/replay.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/src/muse_code_sdk.egg-info/dependency_links.txt +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/src/muse_code_sdk.egg-info/requires.txt +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/src/muse_code_sdk.egg-info/top_level.txt +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/tests/test_asyncio_cancellation.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/tests/test_compatibility_row.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/tests/test_connection.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/tests/test_connection_error_arms.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/tests/test_docs_generation.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/tests/test_facade_open_same_chunk.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/tests/test_facade_persistent_approval.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/tests/test_facade_required_selection.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/tests/test_facade_turn.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/tests/test_host_discovery.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/tests/test_item_store.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/tests/test_manifests.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/tests/test_muse_serve_child.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/tests/test_pending_command_corpus.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/tests/test_pending_command_set.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/tests/test_pending_command_wire_binding.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/tests/test_permission_state_wire_types.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/tests/test_publish_shape.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/tests/test_source_closure.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/tests/test_spawn_handshake.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/tests/test_spawn_host_lost.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/tests/test_state_store.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/tests/test_tolerance_corpus.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/tests/test_transcript_replay.py +0 -0
- {muse_code_sdk-1.4.9 → muse_code_sdk-1.4.11}/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.11
|
|
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
|
|
@@ -27,7 +27,12 @@ wrapper for scripts and notebooks.
|
|
|
27
27
|
one you already run.
|
|
28
28
|
- Stream a turn's items and text deltas as async iterators; await the turn's
|
|
29
29
|
server-authored outcome.
|
|
30
|
-
- Answer tool-approval requests with a handler you register
|
|
30
|
+
- Answer tool-approval requests with a handler you register, or pass it as
|
|
31
|
+
`ResumeSessionOptions(on_approval=...)`, with `on_approval_error=...` for
|
|
32
|
+
its failures, so approvals still pending at resume reach it.
|
|
33
|
+
- Answer the agent's questions: list them with
|
|
34
|
+
`session.fold.pending_user_inputs()` and answer with
|
|
35
|
+
`session.answer_user_input`.
|
|
31
36
|
- A client-side fold keeps a live, typed view of the session — items, session
|
|
32
37
|
state, pending commands — with gap recovery after reconnects, and safe
|
|
33
38
|
same-command retry that can never double-submit.
|
|
@@ -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.
|
|
@@ -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.11"
|
|
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"
|
|
@@ -80,7 +80,7 @@ __all__ = [
|
|
|
80
80
|
]
|
|
81
81
|
|
|
82
82
|
EXPECTED_SCHEMA_FINGERPRINT: Final[str] = (
|
|
83
|
-
"sha256:
|
|
83
|
+
"sha256:f282ad16e21520d0cda68cb190149b86e2b062eef1dd282749360c05aa3d1082"
|
|
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.11"
|
|
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
|
|
@@ -112,6 +111,22 @@ class UnofferedChoice:
|
|
|
112
111
|
kind: Literal["unofferedChoice"] = "unofferedChoice"
|
|
113
112
|
|
|
114
113
|
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class FeedbackNotAccepted:
|
|
116
|
+
"""The handler attached ``feedback`` to a choice whose ``acceptsFeedback``
|
|
117
|
+
is not true. The host would refuse that decision, so it is not sent; the
|
|
118
|
+
offered reject (the ``denied`` choice, else the ``abort`` one) is sent
|
|
119
|
+
next, so the approval closes. With neither offered, nothing is sent and the
|
|
120
|
+
approval stays open; a failure to send the reject is reported as
|
|
121
|
+
``SubmitFailed``. Next time, answer without feedback, or pick one of
|
|
122
|
+
``feedback_choice_ids``, the offered choices that accept it."""
|
|
123
|
+
|
|
124
|
+
approval_id: str
|
|
125
|
+
choice_id: str
|
|
126
|
+
feedback_choice_ids: tuple[str, ...]
|
|
127
|
+
kind: Literal["feedbackNotAccepted"] = "feedbackNotAccepted"
|
|
128
|
+
|
|
129
|
+
|
|
115
130
|
@dataclass(frozen=True)
|
|
116
131
|
class HandlerRaised:
|
|
117
132
|
"""The consumer's decision handler raised; nothing reached the wire."""
|
|
@@ -131,14 +146,15 @@ class SubmitFailed:
|
|
|
131
146
|
kind: Literal["submitFailed"] = "submitFailed"
|
|
132
147
|
|
|
133
148
|
|
|
134
|
-
ApprovalFailure = Union[UnofferedChoice, HandlerRaised, SubmitFailed]
|
|
149
|
+
ApprovalFailure = Union[UnofferedChoice, FeedbackNotAccepted, HandlerRaised, SubmitFailed]
|
|
135
150
|
"""Why one approval round trip did not complete.
|
|
136
151
|
|
|
137
152
|
REPORTED, not raised: the trip is driven from a notification, so an exception
|
|
138
153
|
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
|
|
154
|
+
hazard ``Session.apply`` already refuses a malformed frame for. Each kind names
|
|
155
|
+
whose repair it is (the consumer's handler; the consumer's choice, for
|
|
156
|
+
``unofferedChoice`` and ``feedbackNotAccepted``; the host), because the repair
|
|
157
|
+
differs.
|
|
142
158
|
"""
|
|
143
159
|
|
|
144
160
|
ApprovalFailureHandler = Callable[[ApprovalFailure], None]
|
|
@@ -146,9 +162,10 @@ ApprovalFailureHandler = Callable[[ApprovalFailure], None]
|
|
|
146
162
|
|
|
147
163
|
|
|
148
164
|
class ApprovalRouter:
|
|
149
|
-
"""Routes folded ``approval/requested`` frames,
|
|
150
|
-
frames that advance a pending approval's
|
|
151
|
-
and authors the ``approval/decide``
|
|
165
|
+
"""Routes folded ``approval/requested`` frames, routed ``approval/request``
|
|
166
|
+
frames, and ``approval/updated`` frames that advance a pending approval's
|
|
167
|
+
stage, to the consumer's handler and authors the ``approval/decide``
|
|
168
|
+
command."""
|
|
152
169
|
|
|
153
170
|
def __init__(self, session_id: str, connection: Connection | None) -> None:
|
|
154
171
|
"""Builds the router; ``connection`` is absent on a fold-only session.
|
|
@@ -194,7 +211,8 @@ class ApprovalRouter:
|
|
|
194
211
|
self._on_failure = handler
|
|
195
212
|
|
|
196
213
|
def requested(self, params: ApprovalRequestParams) -> Awaitable[None] | None:
|
|
197
|
-
"""An ``approval/requested``
|
|
214
|
+
"""An ``approval/requested`` the fold kept pending, or a routed
|
|
215
|
+
``approval/request``. Returns the decision round trip to
|
|
198
216
|
await, or ``None`` when this call schedules nothing — no handler, an
|
|
199
217
|
already-decided stage, or a fold-only session (whose ``SubmitFailed``
|
|
200
218
|
is reported synchronously before returning).
|
|
@@ -327,7 +345,15 @@ class ApprovalRouter:
|
|
|
327
345
|
available_choice_ids = tuple(
|
|
328
346
|
choice["choiceId"] for choice in params["availableChoices"]
|
|
329
347
|
)
|
|
330
|
-
|
|
348
|
+
chosen = next(
|
|
349
|
+
(
|
|
350
|
+
choice
|
|
351
|
+
for choice in params["availableChoices"]
|
|
352
|
+
if choice["choiceId"] == choice_id
|
|
353
|
+
),
|
|
354
|
+
None,
|
|
355
|
+
)
|
|
356
|
+
if chosen is None:
|
|
331
357
|
# Select-never-create, enforced on THIS side of the
|
|
332
358
|
# transport. An invented choice bounces -32052 a round trip later,
|
|
333
359
|
# by which time the stage may have advanced — so the diagnosis a
|
|
@@ -341,6 +367,36 @@ class ApprovalRouter:
|
|
|
341
367
|
)
|
|
342
368
|
)
|
|
343
369
|
return
|
|
370
|
+
if feedback is not None and chosen.get("acceptsFeedback") is not True:
|
|
371
|
+
# The host refuses feedback on any other choice (-32052) and
|
|
372
|
+
# keeps its approval open, so refuse it here and answer with the
|
|
373
|
+
# offered reject instead, the choice the Rust SDK sends too: the
|
|
374
|
+
# approval closes and the host's turn goes on. With neither a
|
|
375
|
+
# ``denied`` nor an ``abort`` choice, nothing is sent.
|
|
376
|
+
self._report(
|
|
377
|
+
FeedbackNotAccepted(
|
|
378
|
+
approval_id=params["approvalId"],
|
|
379
|
+
choice_id=choice_id,
|
|
380
|
+
feedback_choice_ids=tuple(
|
|
381
|
+
choice["choiceId"]
|
|
382
|
+
for choice in params["availableChoices"]
|
|
383
|
+
if choice.get("acceptsFeedback") is True
|
|
384
|
+
),
|
|
385
|
+
)
|
|
386
|
+
)
|
|
387
|
+
reject = next(
|
|
388
|
+
(
|
|
389
|
+
choice
|
|
390
|
+
for decision in ("denied", "abort")
|
|
391
|
+
for choice in params["availableChoices"]
|
|
392
|
+
if choice.get("decision") == decision
|
|
393
|
+
),
|
|
394
|
+
None,
|
|
395
|
+
)
|
|
396
|
+
if reject is None:
|
|
397
|
+
return
|
|
398
|
+
choice_id = reject["choiceId"]
|
|
399
|
+
feedback = None
|
|
344
400
|
|
|
345
401
|
decide_params: dict[str, Any] = {
|
|
346
402
|
"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
|
|
@@ -26,6 +26,7 @@ from typing import (
|
|
|
26
26
|
Literal,
|
|
27
27
|
Mapping,
|
|
28
28
|
Protocol,
|
|
29
|
+
Sequence,
|
|
29
30
|
TypeVar,
|
|
30
31
|
Union,
|
|
31
32
|
cast,
|
|
@@ -39,16 +40,21 @@ from muse_code_msp import (
|
|
|
39
40
|
ItemDeltaParams,
|
|
40
41
|
TurnCompletedParams,
|
|
41
42
|
TurnUnqueuedParams,
|
|
43
|
+
UserInputAnswer,
|
|
44
|
+
UserInputAnswerResult,
|
|
42
45
|
)
|
|
43
46
|
|
|
44
|
-
from ..connection.connection import Connection
|
|
47
|
+
from ..connection.connection import Connection, ProtocolError
|
|
45
48
|
from ..fold.item_store import IgnoredStaleRevision
|
|
46
49
|
from ..fold.session_fold import (
|
|
47
50
|
ApprovalPending,
|
|
51
|
+
ApprovalResolvedFold,
|
|
48
52
|
DeliveryGap,
|
|
49
53
|
FoldItems,
|
|
50
54
|
FoldOutcome,
|
|
51
55
|
FoldSessionState,
|
|
56
|
+
IgnoredStaleFrame,
|
|
57
|
+
IgnoredUnrecognizedMethod,
|
|
52
58
|
ItemFold,
|
|
53
59
|
Params,
|
|
54
60
|
PendingApproval,
|
|
@@ -101,6 +107,28 @@ _USER_MESSAGE = "userMessage"
|
|
|
101
107
|
assert _USER_MESSAGE in ITEM_KIND_KNOWN_VALUES
|
|
102
108
|
|
|
103
109
|
|
|
110
|
+
def _is_routable_approval_request(request: Mapping[str, Any]) -> bool:
|
|
111
|
+
"""True when a routed ``approval/request`` carries every member the approval
|
|
112
|
+
router reads: its ``approvalId``, the requirement's ``approvalId`` and
|
|
113
|
+
``sourceIndex``, and each offered choice's ``choiceId``."""
|
|
114
|
+
if not isinstance(request.get("approvalId"), str):
|
|
115
|
+
return False
|
|
116
|
+
requirement = request.get("currentRequirementId")
|
|
117
|
+
choices = request.get("availableChoices")
|
|
118
|
+
if not isinstance(requirement, dict) or not isinstance(choices, list):
|
|
119
|
+
return False
|
|
120
|
+
index = requirement.get("sourceIndex")
|
|
121
|
+
return (
|
|
122
|
+
isinstance(requirement.get("approvalId"), str)
|
|
123
|
+
and isinstance(index, int)
|
|
124
|
+
and not isinstance(index, bool)
|
|
125
|
+
and all(
|
|
126
|
+
isinstance(choice, dict) and isinstance(choice.get("choiceId"), str)
|
|
127
|
+
for choice in choices
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
104
132
|
class SessionFoldView(Protocol):
|
|
105
133
|
"""The fold as a consumer may READ it — the TS ``SessionFoldView`` allowlist.
|
|
106
134
|
|
|
@@ -336,6 +364,10 @@ class SessionApplyOutcome(Generic[_I]):
|
|
|
336
364
|
apply that started the fill, never on the buffered frame's own
|
|
337
365
|
return. A second ``view/gap`` landing mid-fill rides the same
|
|
338
366
|
channel: it extends the running walk, so its own ``io`` is empty.
|
|
367
|
+
A repeat of a ``next`` already named while the hole is open folds
|
|
368
|
+
as ``IgnoredStaleFrame`` and extends nothing; after a failed fill
|
|
369
|
+
it still restarts that fill, and the restarted walk's work
|
|
370
|
+
settles on this frame's ``io``.
|
|
339
371
|
io: The client->server I/O this event triggered, settled — resolves to
|
|
340
372
|
the retirements that I/O produced. A SEPARATE channel from
|
|
341
373
|
``retirements`` because ``apply`` is synchronous by contract: a
|
|
@@ -397,6 +429,11 @@ class Session(Generic[_I]):
|
|
|
397
429
|
self._discarded = discarded
|
|
398
430
|
self._connection = connection
|
|
399
431
|
self._fold: SessionFold = SessionFold()
|
|
432
|
+
# Approvals the fold holds a durable terminal for. Read by a re-sent
|
|
433
|
+
# ``approval/request``, which skips the fold, so it authors nothing for
|
|
434
|
+
# an approval already resolved; mirrors the fold's own record, keyed
|
|
435
|
+
# for O(1).
|
|
436
|
+
self._resolved_approval_ids: set[str] = set()
|
|
400
437
|
self._pending: PendingCommandSet[_I] = PendingCommandSet(
|
|
401
438
|
discarded_command_ids=(
|
|
402
439
|
discarded.command_ids if discarded is not None else set()
|
|
@@ -545,7 +582,12 @@ class Session(Generic[_I]):
|
|
|
545
582
|
# already reported synchronously) — appending an already-settled stub
|
|
546
583
|
# instead would force ``_settled_io``'s gather arm, which needs a
|
|
547
584
|
# running loop a fold-only ``apply`` may not have.
|
|
548
|
-
|
|
585
|
+
# A stale repeat of an earlier gap still restarts a failed fill, as
|
|
586
|
+
# every folded ``view/gap`` (a ``DeliveryGap`` or a stale repeat)
|
|
587
|
+
# reaches ``start()`` (a no-op while a fill runs).
|
|
588
|
+
if isinstance(outcome, DeliveryGap) or (
|
|
589
|
+
isinstance(outcome, IgnoredStaleFrame) and outcome.method == "view/gap"
|
|
590
|
+
):
|
|
549
591
|
fill = self._gaps.start()
|
|
550
592
|
if fill is not None:
|
|
551
593
|
sink.tasks.append(fill)
|
|
@@ -569,7 +611,32 @@ class Session(Generic[_I]):
|
|
|
569
611
|
# everything downstream — the fold, the iterators, and the protocol
|
|
570
612
|
# retirements.
|
|
571
613
|
retirements: List[PendingRetirement[_I]] = []
|
|
614
|
+
# A re-sent ``approval/request`` server request skips the fold, which
|
|
615
|
+
# lists only the ``approval/requested`` notification, and reaches
|
|
616
|
+
# approval routing like that notification: the per-stage latch decides
|
|
617
|
+
# each stage once, and an approval already resolved authors nothing.
|
|
618
|
+
# Its handler task can land after the ``approval/resolved`` read with
|
|
619
|
+
# it, so in that case Python skips a request TypeScript would still
|
|
620
|
+
# route in arrival order. A request missing the members the router
|
|
621
|
+
# reads is dropped, never raised on, whichever path applies it: a held
|
|
622
|
+
# frame replayed inside ``resume_session`` included. A routed or
|
|
623
|
+
# dropped request reports ``IgnoredUnrecognizedMethod``, a sanctioned
|
|
624
|
+
# interim like ``session/started``'s (a dedicated outcome is tracked);
|
|
625
|
+
# one with no params dict falls through to the fold's
|
|
626
|
+
# ``IgnoredMissingParams``, like every other frame.
|
|
627
|
+
request = event.get("params")
|
|
628
|
+
if event.get("method") == "approval/request" and isinstance(request, dict):
|
|
629
|
+
if (
|
|
630
|
+
_is_routable_approval_request(request)
|
|
631
|
+
and request["approvalId"] not in self._resolved_approval_ids
|
|
632
|
+
):
|
|
633
|
+
self._approval_requested(
|
|
634
|
+
cast(ApprovalRequestParams, request), sink.tasks
|
|
635
|
+
)
|
|
636
|
+
return IgnoredUnrecognizedMethod("approval/request")
|
|
572
637
|
outcome = self._fold.apply(event)
|
|
638
|
+
if isinstance(outcome, ApprovalResolvedFold):
|
|
639
|
+
self._resolved_approval_ids.add(outcome.approval_id)
|
|
573
640
|
# The fold drops a params-less frame as IgnoredMissingParams;
|
|
574
641
|
# the arms below read ``params`` on the turn/item arms, so return early.
|
|
575
642
|
method = str(event.get("method"))
|
|
@@ -627,9 +694,9 @@ class Session(Generic[_I]):
|
|
|
627
694
|
)
|
|
628
695
|
# ``view/gap`` deliberately routes to no turn: it names a hole in
|
|
629
696
|
# DELIVERY, so what it moves is the fold's currency, and the recovery
|
|
630
|
-
# it triggers is started by ``apply`` off the ``DeliveryGap`` verdict
|
|
631
|
-
#
|
|
632
|
-
# but routed to no turn.
|
|
697
|
+
# it triggers is started by ``apply`` off the ``DeliveryGap`` verdict,
|
|
698
|
+
# or off a stale ``view/gap``'s ``IgnoredStaleFrame``, rather than from
|
|
699
|
+
# here. Every other method is folded but routed to no turn.
|
|
633
700
|
|
|
634
701
|
# The retired entries are gone from the set, so their replay memory
|
|
635
702
|
# goes with them.
|
|
@@ -782,6 +849,62 @@ class Session(Generic[_I]):
|
|
|
782
849
|
:data:`~muse_code.facade.approval.ApprovalFailure`."""
|
|
783
850
|
self._approvals.on_approval_error(handler)
|
|
784
851
|
|
|
852
|
+
# ---- user-input answers ---------------------------------------------------
|
|
853
|
+
|
|
854
|
+
async def answer_user_input(
|
|
855
|
+
self, user_input_id: str, answers: Sequence[UserInputAnswer]
|
|
856
|
+
) -> UserInputAnswerResult:
|
|
857
|
+
"""Answer a question this session lists in ``fold.pending_user_inputs()``.
|
|
858
|
+
|
|
859
|
+
Sends ``userInput/answer`` with one answer per question and returns
|
|
860
|
+
the host's admission. The question leaves the pending list only when
|
|
861
|
+
the server's ``userInput/settled`` arrives.
|
|
862
|
+
|
|
863
|
+
Args:
|
|
864
|
+
user_input_id: The question to answer.
|
|
865
|
+
answers: One answer per question.
|
|
866
|
+
|
|
867
|
+
Returns:
|
|
868
|
+
The host's admission of the answer.
|
|
869
|
+
|
|
870
|
+
Raises:
|
|
871
|
+
MuseSessionDiscardedError: The session was discarded after an
|
|
872
|
+
ephemeral host death; nothing is sent.
|
|
873
|
+
RuntimeError: The session was constructed fold-only; nothing is sent.
|
|
874
|
+
ProtocolError: The acknowledgement has no command id or names
|
|
875
|
+
another question.
|
|
876
|
+
"""
|
|
877
|
+
# Refused before anything is sent, as ``send_user_turn`` is: a
|
|
878
|
+
# discarded session cannot be reattached.
|
|
879
|
+
if self._discharge is not None and self._discharge.kind == "discharged":
|
|
880
|
+
from ..errors import MuseSessionDiscardedError
|
|
881
|
+
|
|
882
|
+
raise MuseSessionDiscardedError(
|
|
883
|
+
f"session {self.session_id} was discarded after an ephemeral host "
|
|
884
|
+
"death; it cannot answer a question"
|
|
885
|
+
)
|
|
886
|
+
connection = self._submit.require_connection("answer_user_input")
|
|
887
|
+
# No explicit ``commandId``: ``Connection.command`` mints and stamps it,
|
|
888
|
+
# and refuses an ack that echoes a different one. An ack with none is
|
|
889
|
+
# refused here, with one naming another question.
|
|
890
|
+
ack = await connection.command(
|
|
891
|
+
"userInput/answer",
|
|
892
|
+
{
|
|
893
|
+
"sessionId": self.session_id,
|
|
894
|
+
"userInputId": user_input_id,
|
|
895
|
+
"answers": list(answers),
|
|
896
|
+
},
|
|
897
|
+
)
|
|
898
|
+
if (
|
|
899
|
+
not isinstance(ack.get("commandId"), str)
|
|
900
|
+
or ack.get("userInputId") != user_input_id
|
|
901
|
+
):
|
|
902
|
+
raise ProtocolError(
|
|
903
|
+
f"userInput/answer acknowledged command {ack.get('commandId')} for "
|
|
904
|
+
f"question {ack.get('userInputId')}, expected question {user_input_id}"
|
|
905
|
+
)
|
|
906
|
+
return cast(UserInputAnswerResult, ack)
|
|
907
|
+
|
|
785
908
|
# ---- consumer-driven pending-retirement verbs ---------------------------------
|
|
786
909
|
|
|
787
910
|
def stop_retrying(self, command_id: str) -> PendingRetirement[_I] | None:
|
|
@@ -32,6 +32,7 @@ from .session_fold import (
|
|
|
32
32
|
IgnoredUnrecognizedMethod,
|
|
33
33
|
ItemDeltaFold,
|
|
34
34
|
ItemFold,
|
|
35
|
+
MonitorInboxNotice,
|
|
35
36
|
PendingApproval,
|
|
36
37
|
SessionFold,
|
|
37
38
|
SessionStateFold,
|
|
@@ -67,6 +68,7 @@ __all__ = [
|
|
|
67
68
|
"ItemFold",
|
|
68
69
|
"ItemLike",
|
|
69
70
|
"ItemStore",
|
|
71
|
+
"MonitorInboxNotice",
|
|
70
72
|
"PendingApproval",
|
|
71
73
|
"Replaced",
|
|
72
74
|
"SessionFold",
|