muse-code-sdk 1.4.7__tar.gz → 1.4.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. {muse_code_sdk-1.4.7/src/muse_code_sdk.egg-info → muse_code_sdk-1.4.9}/PKG-INFO +1 -1
  2. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/pyproject.toml +1 -1
  3. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/src/muse_code/__init__.py +2 -2
  4. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/src/muse_code/facade/client.py +7 -0
  5. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/src/muse_code/facade/turn_handle.py +4 -3
  6. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/src/muse_code/fold/__init__.py +2 -0
  7. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/src/muse_code/fold/session_fold.py +45 -1
  8. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9/src/muse_code_sdk.egg-info}/PKG-INFO +1 -1
  9. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/tests/test_facade_turn.py +45 -0
  10. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/tests/test_session_fold.py +95 -0
  11. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/LICENSE +0 -0
  12. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/README.md +0 -0
  13. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/setup.cfg +0 -0
  14. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/src/muse_code/connection/__init__.py +0 -0
  15. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/src/muse_code/connection/connection.py +0 -0
  16. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/src/muse_code/connection/discovery.py +0 -0
  17. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/src/muse_code/connection/spawn.py +0 -0
  18. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/src/muse_code/connection/validation.py +0 -0
  19. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/src/muse_code/errors.py +0 -0
  20. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/src/muse_code/facade/__init__.py +0 -0
  21. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/src/muse_code/facade/approval.py +0 -0
  22. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/src/muse_code/facade/discarded.py +0 -0
  23. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/src/muse_code/facade/gap_fill.py +0 -0
  24. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/src/muse_code/facade/host_death.py +0 -0
  25. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/src/muse_code/facade/host_start.py +0 -0
  26. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/src/muse_code/facade/session.py +0 -0
  27. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/src/muse_code/facade/turn_submit.py +0 -0
  28. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/src/muse_code/fold/item_store.py +0 -0
  29. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/src/muse_code/fold/state_store.py +0 -0
  30. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/src/muse_code/pending/__init__.py +0 -0
  31. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/src/muse_code/pending/pending_command_set.py +0 -0
  32. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/src/muse_code/py.typed +0 -0
  33. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/src/muse_code/replay.py +0 -0
  34. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/src/muse_code/sync_facade.py +0 -0
  35. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/src/muse_code_sdk.egg-info/SOURCES.txt +0 -0
  36. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/src/muse_code_sdk.egg-info/dependency_links.txt +0 -0
  37. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/src/muse_code_sdk.egg-info/requires.txt +0 -0
  38. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/src/muse_code_sdk.egg-info/top_level.txt +0 -0
  39. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/tests/test_asyncio_cancellation.py +0 -0
  40. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/tests/test_compatibility_row.py +0 -0
  41. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/tests/test_connection.py +0 -0
  42. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/tests/test_connection_error_arms.py +0 -0
  43. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/tests/test_docs_generation.py +0 -0
  44. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/tests/test_external_audience.py +0 -0
  45. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/tests/test_facade_approval.py +0 -0
  46. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/tests/test_facade_gap_fill.py +0 -0
  47. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/tests/test_facade_open_same_chunk.py +0 -0
  48. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/tests/test_facade_persistent_approval.py +0 -0
  49. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/tests/test_facade_required_selection.py +0 -0
  50. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/tests/test_host_discovery.py +0 -0
  51. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/tests/test_item_store.py +0 -0
  52. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/tests/test_manifests.py +0 -0
  53. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/tests/test_muse_serve_child.py +0 -0
  54. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/tests/test_pending_command_corpus.py +0 -0
  55. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/tests/test_pending_command_set.py +0 -0
  56. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/tests/test_pending_command_wire_binding.py +0 -0
  57. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/tests/test_permission_state_wire_types.py +0 -0
  58. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/tests/test_public_mirror_text.py +0 -0
  59. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/tests/test_publish_shape.py +0 -0
  60. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/tests/test_source_closure.py +0 -0
  61. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/tests/test_spawn_handshake.py +0 -0
  62. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/tests/test_spawn_host_lost.py +0 -0
  63. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/tests/test_state_store.py +0 -0
  64. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/tests/test_sync_facade.py +0 -0
  65. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/tests/test_tolerance_corpus.py +0 -0
  66. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/tests/test_transcript_replay.py +0 -0
  67. {muse_code_sdk-1.4.7 → muse_code_sdk-1.4.9}/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.7
3
+ Version: 1.4.9
4
4
  Summary: Muse Code SDK for MSP: asyncio-first facade over the generated wire types (muse-code-msp), with a thin sync wrapper.
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/meta-models/muse-code-sdk
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
 
6
6
  [project]
7
7
  name = "muse-code-sdk"
8
- version = "1.4.7"
8
+ version = "1.4.9"
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:6de32db08bbbe0deb478d2bfcaba001e9fb490986bc80db3aceb4d996abf7df9"
83
+ "sha256:5d3b43e5ccdb7dc4f5a32c380f5f2cde0f10e3059b93610afb2e73f73b1d914c"
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.7"
95
+ __version__: Final[str] = "1.4.9"
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
@@ -130,12 +130,17 @@ class ResumeSessionOptions:
130
130
  exclude_items: Ask the host to omit item bodies from the history.
131
131
  history: The history preference forwarded as the wire's ``history``
132
132
  member.
133
+ permission_profile: The permission profile pick forwarded as the
134
+ wire's ``permissionProfile`` member: the host restores the
135
+ retained authority first, then applies the pick. Omitted means
136
+ no override.
133
137
  """
134
138
 
135
139
  session_id: str
136
140
  cursor: str | None = None
137
141
  exclude_items: bool | None = None
138
142
  history: Mapping[str, Any] | None = None
143
+ permission_profile: str | None = None
139
144
 
140
145
 
141
146
  # Drift pins — the Python twin of the TS `AssertNever` on START_FORWARDED /
@@ -483,6 +488,8 @@ class MuseClient(Generic[_I]):
483
488
  params["excludeItems"] = options.exclude_items
484
489
  if options.history is not None:
485
490
  params["history"] = options.history
491
+ if options.permission_profile is not None:
492
+ params["permissionProfile"] = options.permission_profile
486
493
  return await self._open("session/resume", params, _ResumeOpening)
487
494
 
488
495
  async def close(self) -> None:
@@ -97,9 +97,10 @@ class _TerminalUnknown:
97
97
 
98
98
  TurnOutcome = Union[_Completed, _Unqueued, _TerminalUnknown]
99
99
  """How a turn ended, from a waiter's point of view. ``turn/retracted``,
100
- ``turn/retryScheduled``, and ``turn/foregroundCompleted`` are deliberately
101
- absent: a retracted turn still reaches its own terminal, and a scheduled
102
- retry or a foreground notice is explicitly non-terminal."""
100
+ ``turn/retryScheduled``, ``turn/modelRetryScheduled``, and
101
+ ``turn/foregroundCompleted`` are deliberately absent: a retracted turn still
102
+ reaches its own terminal, and a scheduled retry, a scheduled backoff, or a
103
+ foreground notice is explicitly non-terminal."""
103
104
 
104
105
 
105
106
  def is_launch_failure(outcome: TurnOutcome) -> bool:
@@ -21,6 +21,7 @@ from .session_fold import (
21
21
  LIVE_HOST_STATE_PROJECTIONS,
22
22
  VIEW_EVENT_METHODS,
23
23
  ApprovalPending,
24
+ ApprovalReviewingNotice,
24
25
  ApprovalResolvedFold,
25
26
  DeliveryGap,
26
27
  FoldItems,
@@ -48,6 +49,7 @@ __all__ = [
48
49
  "VIEW_EVENT_METHODS",
49
50
  "Appended",
50
51
  "ApprovalPending",
52
+ "ApprovalReviewingNotice",
51
53
  "ApprovalResolvedFold",
52
54
  "BufferedForAbsentItem",
53
55
  "DeliveryGap",
@@ -10,7 +10,8 @@ are neither an item nor a state family:
10
10
 
11
11
  - the TURN LIFECYCLE — ``turn/started``, ``turn/completed``,
12
12
  ``turn/retracted``, ``turn/unqueued``, and the non-terminal
13
- ``turn/retryScheduled`` and ``turn/foregroundCompleted``;
13
+ ``turn/retryScheduled``, ``turn/modelRetryScheduled``, and
14
+ ``turn/foregroundCompleted``;
14
15
  - the APPROVAL and USER-INPUT view events as fold INPUTS — the pending sets
15
16
  and their first durable terminal. The decision flow (choosing and sending a
16
17
  resolution) is the facade's, not the fold's;
@@ -71,8 +72,12 @@ VIEW_EVENT_METHODS: frozenset[str] = frozenset(
71
72
  "turn/foregroundCompleted",
72
73
  "turn/retracted",
73
74
  "turn/retryScheduled",
75
+ "turn/modelRetryScheduled",
74
76
  "turn/unqueued",
75
77
  "approval/requested",
78
+ # The reviewer-held twin of `approval/requested`: routed to its own
79
+ # non-storing notice.
80
+ "approval/reviewing",
76
81
  "approval/updated",
77
82
  "approval/resolved",
78
83
  "userInput/requested",
@@ -139,6 +144,9 @@ class TurnEntry:
139
144
  ``turn/completed`` folded.
140
145
  error: Present iff the terminal was ``"failed"``.
141
146
  retry_scheduled: The latest scheduled model retry; non-terminal.
147
+ model_retry_scheduled: The latest scheduled provider-backoff retry;
148
+ non-terminal. The model-attempt twin of ``retry_scheduled``;
149
+ clears on the turn's terminal.
142
150
  foreground_completed: The latest foreground-completed notice;
143
151
  non-terminal. Tells a renderer the answer is done while
144
152
  ``blockingAgents`` hold the turn; clears on the turn's terminal.
@@ -150,6 +158,7 @@ class TurnEntry:
150
158
  terminal: str | None = None
151
159
  error: Params | None = None
152
160
  retry_scheduled: Params | None = None
161
+ model_retry_scheduled: Params | None = None
153
162
  foreground_completed: Params | None = None
154
163
 
155
164
 
@@ -275,6 +284,19 @@ class ModelRouteUnservedNotice:
275
284
  provider_id: str | None
276
285
 
277
286
 
287
+ @dataclass(frozen=True)
288
+ class ApprovalReviewingNotice:
289
+ """The ``approval/reviewing`` typed-activity signal.
290
+
291
+ The host's built-in reviewer holds this approval. A notice, never a
292
+ pending entry: it seeds no store and opens no decision — the approval is
293
+ absent from ``pending_approvals`` until ``approval/requested`` releases it
294
+ to the human (or ``approval/resolved`` settles it) on the same id.
295
+ """
296
+
297
+ approval_id: str
298
+
299
+
278
300
  @dataclass(frozen=True)
279
301
  class IgnoredUnrecognizedMethod:
280
302
  """A newer host's method this SDK has never heard of."""
@@ -324,6 +346,7 @@ FoldOutcome = (
324
346
  | UserInputSettledFold
325
347
  | DeliveryGap
326
348
  | ModelRouteUnservedNotice
349
+ | ApprovalReviewingNotice
327
350
  | IgnoredUnrecognizedMethod
328
351
  | IgnoredMissingParams
329
352
  | IgnoredStaleFrame
@@ -422,6 +445,7 @@ class _InternalTurn:
422
445
  terminal: str | None = None
423
446
  error: Params | None = None
424
447
  retry_scheduled: Params | None = None
448
+ model_retry_scheduled: Params | None = None
425
449
  foreground_completed: Params | None = None
426
450
 
427
451
 
@@ -572,10 +596,14 @@ class SessionFold:
572
596
  return self._turn_unqueued(params)
573
597
  if method == "turn/retryScheduled":
574
598
  return self._turn_retry_scheduled(params)
599
+ if method == "turn/modelRetryScheduled":
600
+ return self._turn_model_retry_scheduled(params)
575
601
  if method == "turn/foregroundCompleted":
576
602
  return self._turn_foreground_completed(params)
577
603
  if method == "approval/requested":
578
604
  return self._approval_requested(params)
605
+ if method == "approval/reviewing":
606
+ return ApprovalReviewingNotice(approval_id=str(params["approvalId"]))
579
607
  if method == "approval/updated":
580
608
  return self._approval_updated(params)
581
609
  if method == "approval/resolved":
@@ -675,6 +703,9 @@ class SessionFold:
675
703
  # The retry hint is a "retrying in Ns" countdown, and it clears on the
676
704
  # turn's next event — which a completion is.
677
705
  turn.retry_scheduled = None
706
+ # Same for the provider-backoff hint: a finished turn is not
707
+ # waiting out a backoff.
708
+ turn.model_retry_scheduled = None
678
709
  # Same for the foreground hint: a finished turn is not
679
710
  # background-waiting.
680
711
  turn.foreground_completed = None
@@ -720,6 +751,18 @@ class SessionFold:
720
751
  turn.retry_scheduled = params
721
752
  return TurnFold(turn_id, turn.state)
722
753
 
754
+ def _turn_model_retry_scheduled(self, params: Params) -> FoldOutcome:
755
+ # Non-terminal by contract: it never resolves a turn-wait.
756
+ turn_id = str(params["turnId"])
757
+ turn = self._turn_for(turn_id)
758
+ # ...but it is still a turn frame, so it takes the same redelivery
759
+ # guard as the retry hint: a replayed page must not re-plant the
760
+ # provider-backoff hint on a turn that already left `running`.
761
+ if turn.state != "running":
762
+ return IgnoredStaleFrame("turn/modelRetryScheduled", turn_id)
763
+ turn.model_retry_scheduled = params
764
+ return TurnFold(turn_id, turn.state)
765
+
723
766
  def _turn_foreground_completed(self, params: Params) -> FoldOutcome:
724
767
  # Non-terminal by contract: it never resolves a turn-wait.
725
768
  turn_id = str(params["turnId"])
@@ -753,6 +796,7 @@ class SessionFold:
753
796
  terminal=turn.terminal,
754
797
  error=turn.error,
755
798
  retry_scheduled=turn.retry_scheduled,
799
+ model_retry_scheduled=turn.model_retry_scheduled,
756
800
  foreground_completed=turn.foreground_completed,
757
801
  )
758
802
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: muse-code-sdk
3
- Version: 1.4.7
3
+ Version: 1.4.9
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
@@ -139,6 +139,25 @@ def turn_retry_scheduled(turn_id: str, view_cursor: str) -> dict[str, Any]:
139
139
  }
140
140
 
141
141
 
142
+ def turn_model_retry_scheduled(turn_id: str, view_cursor: str) -> dict[str, Any]:
143
+ return {
144
+ "method": "turn/modelRetryScheduled",
145
+ "params": {
146
+ "attempt": 2,
147
+ "errorKind": "overloaded",
148
+ "httpStatus": 529,
149
+ "maxAttempts": 5,
150
+ "nextAttempt": 3,
151
+ "retryDelayMs": 4_000,
152
+ "sessionId": SESSION,
153
+ "sourceRange": SOURCE,
154
+ "system": "meta",
155
+ "turnId": turn_id,
156
+ "viewCursor": view_cursor,
157
+ },
158
+ }
159
+
160
+
142
161
  def turn_foreground_completed(turn_id: str, view_cursor: str) -> dict[str, Any]:
143
162
  return {
144
163
  "method": "turn/foregroundCompleted",
@@ -331,6 +350,32 @@ async def test_turn_retry_scheduled_never_settles_the_wait() -> None:
331
350
  await watcher
332
351
 
333
352
 
353
+ @pytest.mark.asyncio
354
+ async def test_turn_model_retry_scheduled_never_settles_the_wait() -> None:
355
+ s = session()
356
+ turn = s.turn("t-1")
357
+ s.apply(turn_started("t-1", "v:1"))
358
+ s.apply(turn_model_retry_scheduled("t-1", "v:2"))
359
+
360
+ settled = False
361
+
362
+ async def watch() -> None:
363
+ nonlocal settled
364
+ await turn.completed
365
+ settled = True
366
+
367
+ import asyncio
368
+
369
+ watcher = asyncio.ensure_future(watch())
370
+ for _ in range(5):
371
+ await asyncio.sleep(0)
372
+ assert settled is False, "a scheduled backoff is a fact about a RUNNING turn"
373
+
374
+ s.apply(turn_completed("t-1", "completed", "v:3"))
375
+ assert (await turn.completed).kind == "completed"
376
+ await watcher
377
+
378
+
334
379
  @pytest.mark.asyncio
335
380
  async def test_turn_foreground_completed_never_settles_the_wait() -> None:
336
381
  s = session()
@@ -15,6 +15,8 @@ from typing import Any
15
15
  from muse_code.fold import (
16
16
  LIVE_HOST_STATE_PROJECTIONS,
17
17
  VIEW_EVENT_METHODS,
18
+ ApprovalPending,
19
+ ApprovalReviewingNotice,
18
20
  ApprovalResolvedFold,
19
21
  DeliveryGap,
20
22
  FoldItems,
@@ -110,6 +112,25 @@ def retry_scheduled(turn_id: str, view_cursor: str) -> dict[str, Any]:
110
112
  }
111
113
 
112
114
 
115
+ def model_retry_scheduled(turn_id: str, view_cursor: str) -> dict[str, Any]:
116
+ return {
117
+ "method": "turn/modelRetryScheduled",
118
+ "params": {
119
+ "attempt": 2,
120
+ "errorKind": "overloaded",
121
+ "httpStatus": 529,
122
+ "maxAttempts": 5,
123
+ "nextAttempt": 3,
124
+ "retryDelayMs": 4_000,
125
+ "sessionId": SESSION,
126
+ "sourceRange": SOURCE,
127
+ "system": "meta",
128
+ "turnId": turn_id,
129
+ "viewCursor": view_cursor,
130
+ },
131
+ }
132
+
133
+
113
134
  def foreground_completed(turn_id: str, view_cursor: str) -> dict[str, Any]:
114
135
  return {
115
136
  "method": "turn/foregroundCompleted",
@@ -159,6 +180,13 @@ def approval_requested(
159
180
  }
160
181
 
161
182
 
183
+ def approval_reviewing(approval_id: str, view_cursor: str) -> dict[str, Any]:
184
+ # The reviewer-held twin of `approval/requested`: same params shape,
185
+ # different method.
186
+ event = approval_requested(approval_id, view_cursor)
187
+ return {"method": "approval/reviewing", "params": event["params"]}
188
+
189
+
162
190
  def approval_updated(
163
191
  approval_id: str, view_cursor: str, source_index: int = 1, command: str | None = None
164
192
  ) -> dict[str, Any]:
@@ -493,6 +521,58 @@ def test_a_replayed_page_never_re_plants_the_retry_hint_on_a_turn_that_left_runn
493
521
  assert fold.apply(retry) == IgnoredStaleFrame("turn/retryScheduled", "turn-1")
494
522
 
495
523
 
524
+ def test_turn_model_retry_scheduled_is_non_terminal_the_turn_keeps_running() -> None:
525
+ fold = SessionFold()
526
+ fold.apply(turn_started("turn-1", "cmd-1", "v:s:1"))
527
+ fold.apply(model_retry_scheduled("turn-1", "v:s:2"))
528
+
529
+ assert fold.active_turn_id == "turn-1", "a scheduled backoff never settles a turn"
530
+ held = fold.turn("turn-1")
531
+ assert held is not None
532
+ assert held.state == "running"
533
+ assert held.model_retry_scheduled is not None
534
+ assert held.model_retry_scheduled["nextAttempt"] == 3
535
+
536
+
537
+ def test_turn_completed_clears_the_provider_backoff_hint() -> None:
538
+ fold = SessionFold()
539
+ fold.apply(turn_started("turn-1", "cmd-1", "v:s:1"))
540
+ fold.apply(model_retry_scheduled("turn-1", "v:s:2"))
541
+ held = fold.turn("turn-1")
542
+ assert held is not None and held.model_retry_scheduled is not None, (
543
+ "the hint is set while the turn runs"
544
+ )
545
+
546
+ fold.apply(turn_completed("turn-1", "completed", "v:s:3"))
547
+
548
+ held = fold.turn("turn-1")
549
+ assert held is not None and held.model_retry_scheduled is None
550
+
551
+
552
+ def test_a_replayed_page_never_re_plants_the_backoff_hint_on_a_turn_that_left_running() -> None:
553
+ fold = SessionFold()
554
+ backoff = model_retry_scheduled("turn-1", "v:s:2")
555
+ page = [
556
+ turn_started("turn-1", "cmd-1", "v:s:1"),
557
+ backoff,
558
+ turn_completed("turn-1", "cancelled", "v:s:3"),
559
+ turn_retracted("turn-1", "cmd-1", "v:s:4"),
560
+ ]
561
+ for event in page:
562
+ fold.apply(event)
563
+ held = fold.turn("turn-1")
564
+ assert held is not None and held.model_retry_scheduled is None, "in-order delivery is clean"
565
+
566
+ for event in page:
567
+ fold.apply(event)
568
+
569
+ held = fold.turn("turn-1")
570
+ assert held is not None
571
+ assert held.state == "retracted"
572
+ assert held.model_retry_scheduled is None, "the replayed hint is dropped"
573
+ assert fold.apply(backoff) == IgnoredStaleFrame("turn/modelRetryScheduled", "turn-1")
574
+
575
+
496
576
  def test_turn_completed_clears_the_foreground_hint() -> None:
497
577
  fold = SessionFold()
498
578
  fold.apply(turn_started("turn-1", "cmd-1", "v:s:1"))
@@ -1001,6 +1081,19 @@ def test_the_items_surface_is_an_allowlist_pinned_by_member_set() -> None:
1001
1081
  assert state_public == {"families", "get", "has"}
1002
1082
 
1003
1083
 
1084
+ def test_approval_reviewing_is_a_notice_that_opens_no_pending_approval() -> None:
1085
+ # The host's built-in reviewer holds the approval; opening a pending
1086
+ # entry here would defeat the presentation hold. The release arrives as
1087
+ # `approval/requested` on the same id.
1088
+ fold = SessionFold()
1089
+ outcome = fold.apply(approval_reviewing("a-1", "v:s:1"))
1090
+ assert outcome == ApprovalReviewingNotice(approval_id="a-1")
1091
+ assert fold.pending_approvals() == []
1092
+ released = fold.apply(approval_requested("a-1", "v:s:2"))
1093
+ assert isinstance(released, ApprovalPending)
1094
+ assert len(fold.pending_approvals()) == 1
1095
+
1096
+
1004
1097
  def test_every_generated_view_notification_is_routed_never_the_default() -> None:
1005
1098
  # The runtime twin of the TS AssertNever coverage pins, PLUS the routing
1006
1099
  # table: every generated notification except the handshake's
@@ -1081,9 +1174,11 @@ def test_every_generated_view_notification_is_routed_never_the_default() -> None
1081
1174
  "turn/completed": turn_completed("turn-t", "completed", "v:t:6"),
1082
1175
  "turn/retracted": turn_retracted("turn-t2", "cmd-t2", "v:t:7"),
1083
1176
  "turn/retryScheduled": retry_scheduled("turn-t3", "v:t:8"),
1177
+ "turn/modelRetryScheduled": model_retry_scheduled("turn-t3", "v:t:8b"),
1084
1178
  "turn/foregroundCompleted": foreground_completed("turn-t5", "v:t:23"),
1085
1179
  "turn/unqueued": turn_unqueued("turn-t4", "cmd-t4", "v:t:9"),
1086
1180
  "approval/requested": approval_requested("a-t", "v:t:10"),
1181
+ "approval/reviewing": approval_reviewing("a-r", "v:t:23"),
1087
1182
  "approval/updated": approval_updated("a-t", "v:t:10b"),
1088
1183
  "approval/resolved": approval_resolved("a-t", "approved", "v:t:11"),
1089
1184
  "userInput/requested": user_input_requested("u-t", "v:t:12"),
File without changes
File without changes
File without changes