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.
Files changed (71) hide show
  1. {muse_code_sdk-1.4.10/src/muse_code_sdk.egg-info → muse_code_sdk-1.4.12}/PKG-INFO +11 -4
  2. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/README.md +9 -3
  3. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/pyproject.toml +9 -8
  4. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/__init__.py +2 -2
  5. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/facade/approval.py +79 -18
  6. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/facade/client.py +92 -5
  7. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/facade/gap_fill.py +6 -1
  8. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/facade/session.py +128 -5
  9. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/fold/__init__.py +2 -0
  10. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/fold/session_fold.py +53 -8
  11. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/sync_facade.py +15 -1
  12. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12/src/muse_code_sdk.egg-info}/PKG-INFO +11 -4
  13. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code_sdk.egg-info/SOURCES.txt +3 -0
  14. muse_code_sdk-1.4.12/src/muse_code_sdk.egg-info/requires.txt +2 -0
  15. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_facade_approval.py +231 -7
  16. muse_code_sdk-1.4.12/tests/test_facade_approval_resend.py +498 -0
  17. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_facade_gap_fill.py +59 -1
  18. muse_code_sdk-1.4.12/tests/test_facade_user_input_resend.py +384 -0
  19. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_manifests.py +7 -3
  20. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_public_mirror_text.py +10 -3
  21. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_publish_shape.py +84 -14
  22. muse_code_sdk-1.4.12/tests/test_recovery_observation.py +305 -0
  23. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_session_fold.py +147 -1
  24. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_sync_facade.py +29 -0
  25. muse_code_sdk-1.4.10/src/muse_code_sdk.egg-info/requires.txt +0 -1
  26. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/LICENSE +0 -0
  27. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/setup.cfg +0 -0
  28. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/connection/__init__.py +0 -0
  29. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/connection/connection.py +0 -0
  30. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/connection/discovery.py +0 -0
  31. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/connection/spawn.py +0 -0
  32. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/connection/validation.py +0 -0
  33. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/errors.py +0 -0
  34. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/facade/__init__.py +0 -0
  35. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/facade/discarded.py +0 -0
  36. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/facade/host_death.py +0 -0
  37. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/facade/host_start.py +0 -0
  38. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/facade/turn_handle.py +0 -0
  39. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/facade/turn_submit.py +0 -0
  40. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/fold/item_store.py +0 -0
  41. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/fold/state_store.py +0 -0
  42. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/pending/__init__.py +0 -0
  43. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/pending/pending_command_set.py +0 -0
  44. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/py.typed +0 -0
  45. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code/replay.py +0 -0
  46. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code_sdk.egg-info/dependency_links.txt +0 -0
  47. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/src/muse_code_sdk.egg-info/top_level.txt +0 -0
  48. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_asyncio_cancellation.py +0 -0
  49. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_compatibility_row.py +0 -0
  50. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_connection.py +0 -0
  51. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_connection_error_arms.py +0 -0
  52. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_docs_generation.py +0 -0
  53. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_external_audience.py +0 -0
  54. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_facade_open_same_chunk.py +0 -0
  55. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_facade_persistent_approval.py +0 -0
  56. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_facade_required_selection.py +0 -0
  57. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_facade_turn.py +0 -0
  58. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_host_discovery.py +0 -0
  59. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_item_store.py +0 -0
  60. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_muse_serve_child.py +0 -0
  61. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_pending_command_corpus.py +0 -0
  62. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_pending_command_set.py +0 -0
  63. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_pending_command_wire_binding.py +0 -0
  64. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_permission_state_wire_types.py +0 -0
  65. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_source_closure.py +0 -0
  66. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_spawn_handshake.py +0 -0
  67. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_spawn_host_lost.py +0 -0
  68. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_state_store.py +0 -0
  69. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_tolerance_corpus.py +0 -0
  70. {muse_code_sdk-1.4.10 → muse_code_sdk-1.4.12}/tests/test_transcript_replay.py +0 -0
  71. {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.10
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. One runtime dependency: `pydantic`, exact-pinned.
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 muse-code-msp
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. One runtime dependency: `pydantic`, exact-pinned.
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 muse-code-msp
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.10"
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
- # Exactly ONE runtime dependency, exact-pinned: a deliberate, ruled
15
- # supply-chain exception. Any second dependency is an owner escalation;
16
- # bumps are deliberate reviewed PRs.
17
- # muse-code-msp is deliberately NOT declared: it is not on the registry yet,
18
- # and promoting it is the owner escalation decided with the first publish
19
- # (the @muse-code/msp devDependency precedent; test_publish_shape pins this).
20
- dependencies = ["pydantic==2.13.5"]
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:5d3b43e5ccdb7dc4f5a32c380f5f2cde0f10e3059b93610afb2e73f73b1d914c"
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.10"
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`` → the consumer's
2
- handler → outbound ``approval/decide``. Port of the TypeScript SDK's
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
- SHAPE NOTE, established from the bundle rather than assumed. ``approval/request``
11
- as a SERVER REQUEST is not enrolled for the view stream this router rides —
12
- the schema carries ``approval/requested`` as a NOTIFICATION — so the round
13
- trip is notification-in / command-out, which is exactly the pair the
14
- protocol names. The server-request form would be a protocol enrollment
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 arm names
140
- a DIFFERENT owner (the consumer's handler, the consumer's choice, the host),
141
- because the repair differs.
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, and ``approval/updated``
150
- frames that advance a pending approval's stage, to the consumer's handler
151
- and authors the ``approval/decide`` command."""
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`` folded. Returns the decision round trip to
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
- if choice_id not in available_choice_ids:
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
- assert {_camel(f.name) for f in fields(ResumeSessionOptions)} | _RESUME_EXCLUDED == set(
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("session/resume", params, _ResumeOpening)
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"]), opening(result)
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, session_id: str, opening: SessionOpening
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 always sets it.
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