muse-code-sdk 1.3.0__py3-none-any.whl

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.
@@ -0,0 +1,785 @@
1
+ """``SessionFold`` — the SS4 client fold, bound to the generated wire types
2
+ (spec 638 FR-638-007 carrying spec 14990 FR-007).
3
+
4
+ The two stores compose here, bound to ``muse_code_msp``'s shapes: items are
5
+ the generated ``Item`` dicts, state-family values are the generated
6
+ ``session/*`` params objects, and every event this fold accepts is the params
7
+ object the wire actually carries. Nothing here restates a wire shape
8
+ (INV-638-01/02).
9
+
10
+ Beyond the two-store composition, the fold owns the parts of the view that
11
+ are neither an item nor a state family:
12
+
13
+ - the TURN LIFECYCLE — ``turn/started``, ``turn/completed``,
14
+ ``turn/retracted``, ``turn/unqueued``, and the non-terminal
15
+ ``turn/retryScheduled``;
16
+ - the APPROVAL and USER-INPUT view events as fold INPUTS — the pending sets
17
+ and their first durable terminal. The decision flow (choosing and sending a
18
+ resolution) is the facade's, not the fold's;
19
+ - the DELIVERY marker ``view/gap`` — it seeds no store, it moves the fold's
20
+ CURRENCY (``pending_gap``/``current``, FM-003). The recovery that fills the
21
+ hole needs client-to-server I/O and is therefore the facade's.
22
+
23
+ INV-006 governs all of it: the fold never invents a terminal. A retract and a
24
+ reclaim are not ``TurnTerminal`` values and are never rendered as one, an
25
+ unrecognized ``TurnTerminal`` is kept verbatim, and a second resolution never
26
+ displaces the first durable one.
27
+
28
+ Where the TS facade seals its read surfaces at compile time
29
+ (``DeepReadonly``, interface allowlists), this port seals them at runtime
30
+ instead: ``items`` and ``session_state`` return thin read-only VIEWS that
31
+ expose no mutator at all — the same invariant, enforced in the idiom the
32
+ language has.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from dataclasses import dataclass
38
+ from typing import Any, Callable, Literal, Mapping
39
+
40
+ from .item_store import (
41
+ DeltaApplyOutcome,
42
+ ItemApplyOutcome,
43
+ ItemLike,
44
+ ItemStore,
45
+ TerminalUnknownItemAnnotation,
46
+ )
47
+ from .state_store import SessionStateStore, StateApplyOutcome, StateValue
48
+
49
+ Params = Mapping[str, Any]
50
+ """A wire params object at runtime: a JSON object decoded to a dict."""
51
+
52
+ SESSION_STATE_METHODS: tuple[str, ...] = (
53
+ "session/approvalModeChanged",
54
+ "session/branchChanged",
55
+ "session/contextUsage",
56
+ "session/goalChanged",
57
+ "session/modelChanged",
58
+ "session/nameChanged",
59
+ "session/reasoningEffortChanged",
60
+ "session/todoListChanged",
61
+ "session/tokenUsage",
62
+ )
63
+ """The session-state families, each keyed in the store by its method name."""
64
+
65
+ VIEW_EVENT_METHODS: frozenset[str] = frozenset(
66
+ (
67
+ "item/started",
68
+ "item/updated",
69
+ "item/completed",
70
+ "item/delta",
71
+ "turn/started",
72
+ "turn/completed",
73
+ "turn/retracted",
74
+ "turn/retryScheduled",
75
+ "turn/unqueued",
76
+ "approval/requested",
77
+ "approval/updated",
78
+ "approval/resolved",
79
+ "userInput/requested",
80
+ "userInput/settled",
81
+ "view/gap",
82
+ # The one-shot login-swap route disclosure (#25603, tdd SS4.6.8):
83
+ # routed to its own non-storing outcome — a notice, never sticky
84
+ # session state (a setModel repair would never clear a stored copy,
85
+ # and a snapshot reseed would wipe it).
86
+ "session/modelRouteUnserved",
87
+ )
88
+ ) | frozenset(SESSION_STATE_METHODS)
89
+ """Every notification method this fold routes to its own arm.
90
+
91
+ Totality against the generated layer — every ``muse_code_msp.NOTIFICATIONS``
92
+ entry except the handshake's ``initialized`` and the live host-state
93
+ projections is a fold input — is pinned by ``test_session_fold.py``'s
94
+ coverage test, the runtime twin of the TS compile-time ``AssertNever`` pins.
95
+ """
96
+
97
+ LIVE_HOST_STATE_PROJECTIONS: frozenset[str] = frozenset(
98
+ ("skill/changed", "usage/changed", "session/statusChanged")
99
+ )
100
+ """Notifications that are live host-state projections, not view events
101
+ (ADR 32471 D3, tdd SS3.22.2; ADR 32563 D3, tdd SS3.23): no ``viewCursor``,
102
+ no ``sourceRange``, nothing durable to fold. The consumer reaction is an
103
+ ACTION (re-issue ``skill/list``) or its own rendering (``usage/changed``
104
+ carries the full usage payload), so these ride the client's notification
105
+ callback
106
+ rather than this fold; the totality test pins the exclusion in both
107
+ directions, exactly like the TS fold's ``LiveHostStateProjection``."""
108
+
109
+ TurnState = Literal["running", "settled", "retracted", "unqueued"]
110
+ """Where a turn stands. Client fold state — not a wire vocabulary."""
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class TurnEntry:
115
+ """A turn as the fold has observed it.
116
+
117
+ Attributes:
118
+ turn_id: The wire turn id.
119
+ state: Where the turn stands.
120
+ command_id: The submitting command, when an event carried it.
121
+ terminal: The server's terminal, verbatim. ``None`` unless
122
+ ``turn/completed`` folded (INV-006).
123
+ error: Present iff the terminal was ``"failed"`` (tdd SS4.5.1).
124
+ retry_scheduled: The latest scheduled model retry; non-terminal
125
+ (tdd SS4.5.1).
126
+ """
127
+
128
+ turn_id: str
129
+ state: TurnState
130
+ command_id: str | None = None
131
+ terminal: str | None = None
132
+ error: Params | None = None
133
+ retry_scheduled: Params | None = None
134
+
135
+
136
+ @dataclass(frozen=True)
137
+ class PendingApproval:
138
+ """An approval awaiting its first durable terminal.
139
+
140
+ Attributes:
141
+ approval_id: The wire approval id.
142
+ requested: The (latest) ``approval/requested`` params, verbatim.
143
+ latest_update: The most recent ``approval/updated`` refresh, if any.
144
+ """
145
+
146
+ approval_id: str
147
+ requested: Params
148
+ latest_update: Params | None = None
149
+
150
+
151
+ @dataclass(frozen=True)
152
+ class ItemFold:
153
+ """An item event folded into the item store."""
154
+
155
+ outcome: ItemApplyOutcome
156
+
157
+
158
+ @dataclass(frozen=True)
159
+ class ItemDeltaFold:
160
+ """An ``item/delta`` folded into the item store's accumulators."""
161
+
162
+ outcome: DeltaApplyOutcome
163
+
164
+
165
+ @dataclass(frozen=True)
166
+ class TurnFold:
167
+ """A turn-lifecycle event folded."""
168
+
169
+ turn_id: str
170
+ state: TurnState
171
+
172
+
173
+ @dataclass(frozen=True)
174
+ class SessionStateFold:
175
+ """A session-state event folded into the state store."""
176
+
177
+ outcome: StateApplyOutcome
178
+
179
+
180
+ @dataclass(frozen=True)
181
+ class ApprovalPending:
182
+ """An approval opened or refreshed; it awaits a durable terminal."""
183
+
184
+ approval_id: str
185
+
186
+
187
+ @dataclass(frozen=True)
188
+ class ApprovalResolvedFold:
189
+ """An approval's durable terminal folded.
190
+
191
+ Attributes:
192
+ approval_id: The wire approval id.
193
+ first_terminal: ``False`` when a durable terminal had already landed;
194
+ the first wins (tdd SS5).
195
+ """
196
+
197
+ approval_id: str
198
+ first_terminal: bool
199
+
200
+
201
+ @dataclass(frozen=True)
202
+ class UserInputPending:
203
+ """A user-input prompt opened or refreshed."""
204
+
205
+ user_input_id: str
206
+
207
+
208
+ @dataclass(frozen=True)
209
+ class UserInputSettledFold:
210
+ """A user-input prompt's durable settlement folded.
211
+
212
+ Attributes:
213
+ user_input_id: The wire prompt id.
214
+ first_settlement: ``False`` when a settlement had already landed.
215
+ """
216
+
217
+ user_input_id: str
218
+ first_settlement: bool
219
+
220
+
221
+ @dataclass(frozen=True)
222
+ class DeliveryGap:
223
+ """A ``view/gap`` folded: push delivery dropped the open interval
224
+ ``(after, next)``, so this fold is not current until a sanctioned
225
+ recovery fills the hole (tdd SS4.8, FM-003).
226
+
227
+ The bounds are the FRAME's own, verbatim — the coalesced outstanding hole
228
+ is ``SessionFold.pending_gap``, which is what a recovery walks.
229
+ """
230
+
231
+ after: str
232
+ next: str
233
+
234
+
235
+ @dataclass(frozen=True)
236
+ class ModelRouteUnservedNotice:
237
+ """The one-shot ``session/modelRouteUnserved`` disclosure (tdd SS4.6.8).
238
+
239
+ An accepted first-party login credential update installed a provider that
240
+ cannot serve the session's STANDING model route. A notice, never sticky
241
+ session state: it seeds no store — render it once; a routable
242
+ ``session/setModel`` is the repair.
243
+ """
244
+
245
+ command_id: str
246
+ installed_provider_id: str
247
+ model_id: str
248
+ provider_id: str | None
249
+
250
+
251
+ @dataclass(frozen=True)
252
+ class IgnoredUnrecognizedMethod:
253
+ """A newer host's method this SDK has never heard of (SS1.5.4)."""
254
+
255
+ method: str
256
+
257
+
258
+ @dataclass(frozen=True)
259
+ class IgnoredMissingParams:
260
+ """A frame that carried no foldable ``params`` object.
261
+
262
+ The wire's ``Notification`` declares ``params`` optional, and JSON-RPC
263
+ permits ``null`` and positional arrays — none of which any arm can fold,
264
+ since every one reads at least a ``sessionId``. Dropped rather than
265
+ raised: SS1.5.4's posture is that a frame this SDK cannot fold leaves the
266
+ rest of the fold intact, and an exception here would kill the embedding
267
+ consumer's notification pump.
268
+ """
269
+
270
+ method: str
271
+
272
+
273
+ @dataclass(frozen=True)
274
+ class IgnoredStaleFrame:
275
+ """A recognized frame the fold deliberately dropped, state unchanged.
276
+
277
+ A ``turn/started`` or ``turn/completed`` for a turn that already left
278
+ ``running``, an ``approval/updated`` for an approval it never saw
279
+ requested (or whose entry a resolution already deleted), or a redelivered
280
+ ``approval/requested`` / ``userInput/requested`` for an approval/prompt
281
+ that already has its durable terminal. Truthful by contract: a "pending"
282
+ outcome here would make a renderer show an entry the fold does not hold.
283
+ """
284
+
285
+ method: str
286
+ id: str
287
+
288
+
289
+ FoldOutcome = (
290
+ ItemFold
291
+ | ItemDeltaFold
292
+ | TurnFold
293
+ | SessionStateFold
294
+ | ApprovalPending
295
+ | ApprovalResolvedFold
296
+ | UserInputPending
297
+ | UserInputSettledFold
298
+ | DeliveryGap
299
+ | ModelRouteUnservedNotice
300
+ | IgnoredUnrecognizedMethod
301
+ | IgnoredMissingParams
302
+ | IgnoredStaleFrame
303
+ )
304
+ """What one ``apply`` did — enough to drive a render without re-diffing."""
305
+
306
+
307
+ class FoldItems:
308
+ """The fold's read-only view of its :class:`ItemStore`.
309
+
310
+ Every mutation goes through :meth:`SessionFold.apply`; a directly
311
+ reachable ``seed``/``apply_delta`` would let a consumer desync the
312
+ composite view (items cleared, turn/pending maps not), breaking INV-002
313
+ replay equality. An ALLOWLIST: this view defines exactly the read
314
+ surface, so a mutator added to the store later never leaks onto it.
315
+ """
316
+
317
+ __slots__ = ("_store",)
318
+
319
+ def __init__(self, store: ItemStore) -> None:
320
+ """Wraps the fold-owned store; consumers never construct one."""
321
+ self._store = store
322
+
323
+ def accumulated(self, item_id: str, field: str = "text") -> str | None:
324
+ """The accumulated delta text for a field path, or ``None``."""
325
+ return self._store.accumulated(item_id, field)
326
+
327
+ def accumulated_fields(self, item_id: str) -> list[str]:
328
+ """Every field path with accumulated deltas, sorted."""
329
+ return self._store.accumulated_fields(item_id)
330
+
331
+ @property
332
+ def ephemeral_session_discarded(self) -> bool:
333
+ """Whether an ephemeral host death discarded this session."""
334
+ return self._store.ephemeral_session_discarded
335
+
336
+ def get(self, item_id: str) -> ItemLike | None:
337
+ """The held item at its latest revision, or ``None``."""
338
+ return self._store.get(item_id)
339
+
340
+ def has(self, item_id: str) -> bool:
341
+ """Whether the fold holds this item."""
342
+ return self._store.has(item_id)
343
+
344
+ def is_terminal_unknown(self, item_id: str) -> bool:
345
+ """Whether this item carries the terminal-unknown annotation."""
346
+ return self._store.is_terminal_unknown(item_id)
347
+
348
+ def last_opened_item_id(self) -> str | None:
349
+ """The id of the most recently opened item, or ``None``."""
350
+ return self._store.last_opened_item_id()
351
+
352
+ def list(self) -> list[ItemLike]:
353
+ """Items in first-opened order (tdd SS4.9.1)."""
354
+ return self._store.list()
355
+
356
+ @property
357
+ def size(self) -> int:
358
+ """How many items the fold holds."""
359
+ return self._store.size
360
+
361
+
362
+ class FoldSessionState:
363
+ """The fold's read-only view of its :class:`SessionStateStore`.
364
+
365
+ Families are keyed by notification method name (``session/*``). Named
366
+ ``session_state`` on the fold rather than ``state`` because the surface
367
+ contract reserves ``state`` for the FR-008 aggregate that arrives with
368
+ snapshot ingestion.
369
+ """
370
+
371
+ __slots__ = ("_store",)
372
+
373
+ def __init__(self, store: SessionStateStore) -> None:
374
+ """Wraps the fold-owned store; consumers never construct one."""
375
+ self._store = store
376
+
377
+ def get(self, family: str) -> StateValue:
378
+ """The family's held params object, ``None`` when absent or cleared."""
379
+ return self._store.get(family)
380
+
381
+ def has(self, family: str) -> bool:
382
+ """Whether the family holds any fact (an explicit clear counts)."""
383
+ return self._store.has(family)
384
+
385
+ def families(self) -> list[str]:
386
+ """Every family that holds a value, in insertion order."""
387
+ return self._store.families()
388
+
389
+
390
+ @dataclass
391
+ class _InternalTurn:
392
+ turn_id: str
393
+ state: TurnState
394
+ command_id: str | None = None
395
+ terminal: str | None = None
396
+ error: Params | None = None
397
+ retry_scheduled: Params | None = None
398
+
399
+
400
+ @dataclass
401
+ class _InternalApproval:
402
+ requested: Params
403
+ latest_update: Params | None = None
404
+
405
+
406
+ class SessionFold:
407
+ """The whole client-side session view: items, session state, turns, and
408
+ the pending approval/user-input sets."""
409
+
410
+ def __init__(self) -> None:
411
+ """Create an empty fold."""
412
+ self._item_store = ItemStore()
413
+ self._state_store = SessionStateStore()
414
+ self._items_view = FoldItems(self._item_store)
415
+ self._state_view = FoldSessionState(self._state_store)
416
+ self._turns: dict[str, _InternalTurn] = {}
417
+ self._pending_approvals: dict[str, _InternalApproval] = {}
418
+ self._resolved_approvals: dict[str, Params] = {}
419
+ self._pending_user_inputs: dict[str, Params] = {}
420
+ self._settled_user_inputs: dict[str, Params] = {}
421
+ self._active_turn_id: str | None = None
422
+ self._pending_gap: dict[str, str] | None = None
423
+
424
+ @property
425
+ def items(self) -> FoldItems:
426
+ """Items, snapshot-only: the mutators are ``apply()``'s, not yours."""
427
+ return self._items_view
428
+
429
+ @property
430
+ def session_state(self) -> FoldSessionState:
431
+ """The session-state families, keyed by notification method name."""
432
+ return self._state_view
433
+
434
+ def mark_ephemeral_host_death(
435
+ self, is_in_progress: Callable[[ItemLike], bool]
436
+ ) -> list[TerminalUnknownItemAnnotation]:
437
+ """Discharge the SS2.13.3b ephemeral host-death obligation.
438
+
439
+ Every item still in progress becomes terminal-unknown and the fold
440
+ refuses further item events (spec FM-002). A FOLD-level method rather
441
+ than a ``FoldItems`` entry on purpose: ``items`` is snapshot-only and
442
+ this IS a mutator. The in-progress probe stays the caller's, so the
443
+ store remains wire-shape-blind (INV-638-01).
444
+
445
+ Args:
446
+ is_in_progress: The wire-aware probe, e.g.
447
+ ``lambda item: item.get("status") == "inProgress"``.
448
+
449
+ Returns:
450
+ The terminal-unknown annotations, in first-opened item order.
451
+ """
452
+ return self._item_store.mark_ephemeral_host_death(is_in_progress)
453
+
454
+ @property
455
+ def active_turn_id(self) -> str | None:
456
+ """The turn currently running, if one is."""
457
+ return self._active_turn_id
458
+
459
+ @property
460
+ def pending_gap(self) -> Mapping[str, str] | None:
461
+ """The outstanding delivery hole, or ``None`` (tdd SS4.8, FM-003).
462
+
463
+ COALESCED, keeping the FIRST ``after``: consecutive overflows are one
464
+ hole from a filler's point of view. A recovery walks
465
+ ``(after, next)`` and calls :meth:`gap_filled` with the target it
466
+ actually reached, so a hole that grew while the walk was in flight
467
+ cannot be reported filled.
468
+ """
469
+ return self._pending_gap
470
+
471
+ @property
472
+ def current(self) -> bool:
473
+ """Is this fold current with the session view?
474
+
475
+ FM-003's "only then reports current": ``False`` from the moment a
476
+ ``view/gap`` folds until its hole is filled. Live events keep folding
477
+ meanwhile — they are real facts and dropping them would add a second
478
+ hole — so this flag is the ONLY statement that the transcript has a
479
+ hole in it.
480
+ """
481
+ return self._pending_gap is None
482
+
483
+ def gap_filled(self, next_cursor: str) -> bool:
484
+ """A recovery filled the hole up to ``next_cursor``.
485
+
486
+ EQUALITY, never a relational compare (tdd SS4.1): the argument is the
487
+ target the walk reached, and it clears only when that is still the
488
+ outstanding target. A gap that arrived mid-walk left a newer
489
+ ``next``, so this answers ``False`` and the fold stays not-current —
490
+ the truthful answer, since the second hole is unfilled.
491
+
492
+ Args:
493
+ next_cursor: The ``next`` bound the walk actually reached.
494
+
495
+ Returns:
496
+ Whether the outstanding hole cleared.
497
+ """
498
+ if self._pending_gap is None or self._pending_gap["next"] != next_cursor:
499
+ return False
500
+ self._pending_gap = None
501
+ return True
502
+
503
+ def apply(self, event: Mapping[str, Any]) -> FoldOutcome:
504
+ """Fold one view event (a wire notification's ``method`` + ``params``).
505
+
506
+ The unrecognized-method arm is not defensive padding: the wire is an
507
+ external boundary and SS1.5.4 makes a NEW notification additive
508
+ evolution, so a host newer than this SDK really does reach it.
509
+ Ignoring the frame keeps the rest of the fold intact, which is what
510
+ tolerance means.
511
+
512
+ Args:
513
+ event: A mapping with ``method`` and (usually) ``params`` — the
514
+ generated ``Notification`` frame folds as-is, envelope
515
+ members tolerated.
516
+
517
+ Returns:
518
+ What the fold did with the frame.
519
+ """
520
+ method = str(event.get("method"))
521
+ params = event.get("params")
522
+ # `params: null`, a scalar, or a positional ARRAY are all legal
523
+ # JSON-RPC spellings no arm can fold (every arm reads at least a
524
+ # sessionId). Classify here so every caller inherits it (the TS
525
+ # overload-pair rule).
526
+ if not isinstance(params, dict):
527
+ return IgnoredMissingParams(method)
528
+
529
+ if method in ("item/started", "item/updated", "item/completed"):
530
+ return ItemFold(self._item_store.apply(params["item"]))
531
+ if method == "item/delta":
532
+ return ItemDeltaFold(
533
+ self._item_store.apply_delta(
534
+ params["itemId"], params["delta"], params.get("field", "text")
535
+ )
536
+ )
537
+ if method == "turn/started":
538
+ return self._turn_started(params)
539
+ if method == "turn/completed":
540
+ return self._turn_completed(params)
541
+ if method == "turn/retracted":
542
+ return self._turn_retracted(params)
543
+ if method == "turn/unqueued":
544
+ return self._turn_unqueued(params)
545
+ if method == "turn/retryScheduled":
546
+ return self._turn_retry_scheduled(params)
547
+ if method == "approval/requested":
548
+ return self._approval_requested(params)
549
+ if method == "approval/updated":
550
+ return self._approval_updated(params)
551
+ if method == "approval/resolved":
552
+ return self._approval_resolved(params)
553
+ if method == "userInput/requested":
554
+ return self._user_input_requested(params)
555
+ if method == "userInput/settled":
556
+ return self._user_input_settled(params)
557
+ if method == "view/gap":
558
+ return self._delivery_gap(params)
559
+ if method == "session/modelRouteUnserved":
560
+ return ModelRouteUnservedNotice(
561
+ command_id=str(params.get("commandId", "")),
562
+ installed_provider_id=str(params.get("installedProviderId", "")),
563
+ model_id=str(params.get("modelId", "")),
564
+ provider_id=(
565
+ None
566
+ if params.get("providerId") is None
567
+ else str(params.get("providerId"))
568
+ ),
569
+ )
570
+ if method in SESSION_STATE_METHODS:
571
+ return SessionStateFold(
572
+ self._state_store.apply(method, params, params.get("viewCursor"))
573
+ )
574
+ return IgnoredUnrecognizedMethod(method)
575
+
576
+ def turns(self) -> list[TurnEntry]:
577
+ """Turns in first-observed order."""
578
+ return [self._turn_snapshot(turn) for turn in self._turns.values()]
579
+
580
+ def turn(self, turn_id: str) -> TurnEntry | None:
581
+ """One turn's entry, or ``None`` if the fold never observed it."""
582
+ held = self._turns.get(turn_id)
583
+ return None if held is None else self._turn_snapshot(held)
584
+
585
+ def pending_approvals(self) -> list[PendingApproval]:
586
+ """Approvals awaiting a durable terminal, in first-observed order."""
587
+ return [
588
+ PendingApproval(
589
+ approval_id=approval_id,
590
+ requested=held.requested,
591
+ latest_update=held.latest_update,
592
+ )
593
+ for approval_id, held in self._pending_approvals.items()
594
+ ]
595
+
596
+ def resolved_approvals(self) -> list[Params]:
597
+ """The winning durable terminal per approval, in first-observed order."""
598
+ return list(self._resolved_approvals.values())
599
+
600
+ def pending_user_inputs(self) -> list[Params]:
601
+ """Prompts awaiting a durable settlement, in first-observed order.
602
+
603
+ The entries ARE the generated request params — the wire shape already
604
+ carries ``userInputId``, so a wrapper would only duplicate it.
605
+ """
606
+ return list(self._pending_user_inputs.values())
607
+
608
+ def settled_user_inputs(self) -> list[Params]:
609
+ """The winning durable settlement per prompt, in first-observed order."""
610
+ return list(self._settled_user_inputs.values())
611
+
612
+ # ---- turn lifecycle -----------------------------------------------------
613
+
614
+ def _turn_started(self, params: Params) -> FoldOutcome:
615
+ # A turn the fold has already seen leave `running` never re-opens. A
616
+ # redelivered `turn/started` after a terminal/retract/reclaim would
617
+ # mint `{state: "running", terminal: ...}` — a shape TurnEntry's own
618
+ # docs say cannot exist — and re-point `active_turn_id` at a turn no
619
+ # completion will follow. `_turn_for` mints fresh entries as
620
+ # `running`, so a first sighting (and a redelivery while genuinely
621
+ # running) still folds.
622
+ turn_id = str(params["turnId"])
623
+ turn = self._turn_for(turn_id)
624
+ if turn.state != "running":
625
+ return IgnoredStaleFrame("turn/started", turn_id)
626
+ turn.command_id = params.get("commandId")
627
+ turn.state = "running"
628
+ self._active_turn_id = turn_id
629
+ return TurnFold(turn_id, turn.state)
630
+
631
+ def _turn_completed(self, params: Params) -> FoldOutcome:
632
+ # A turn the fold never saw start still settles here: single-shot and
633
+ # gap-filled turns arrive terminal-first (tdd SS4.4.1's sibling rule).
634
+ turn_id = str(params["turnId"])
635
+ turn = self._turn_for(turn_id)
636
+ # ...but a RETRACTED or RECLAIMED turn stays that way. Replaying the
637
+ # completion frame that preceded an accepted retract would flip the
638
+ # entry back to `settled` and lose the retract fact entirely.
639
+ if turn.state in ("retracted", "unqueued"):
640
+ return IgnoredStaleFrame("turn/completed", turn_id)
641
+ turn.state = "settled"
642
+ turn.terminal = params.get("terminal")
643
+ if params.get("error") is not None:
644
+ turn.error = params["error"]
645
+ # The retry hint is a "retrying in Ns" countdown, and it clears on the
646
+ # turn's next event — which a completion is (tdd SS4.5.1).
647
+ turn.retry_scheduled = None
648
+ self._clear_active(turn_id)
649
+ return TurnFold(turn_id, turn.state)
650
+
651
+ def _turn_retracted(self, params: Params) -> FoldOutcome:
652
+ turn_id = str(params["turnId"])
653
+ turn = self._turn_for(turn_id)
654
+ turn.command_id = params.get("commandId")
655
+ # A retract is NOT a TurnTerminal; leaving `terminal` unset is the
656
+ # whole point (INV-006). The retracted user message re-arrives via
657
+ # `item/updated` with `retracted: true`, which the item half folds.
658
+ turn.state = "retracted"
659
+ self._clear_active(turn_id)
660
+ return TurnFold(turn_id, turn.state)
661
+
662
+ def _turn_unqueued(self, params: Params) -> FoldOutcome:
663
+ # The reclaim of a QUEUED submit: this turn was pre-minted at
664
+ # admission and never launched, so no `turn/started` preceded it and
665
+ # no `turn/completed` will follow (tdd SS3.6, SS4.5.1). It therefore
666
+ # never becomes the active turn and never carries a terminal.
667
+ turn_id = str(params["turnId"])
668
+ turn = self._turn_for(turn_id)
669
+ turn.command_id = params.get("commandId")
670
+ turn.state = "unqueued"
671
+ self._clear_active(turn_id)
672
+ return TurnFold(turn_id, turn.state)
673
+
674
+ def _turn_retry_scheduled(self, params: Params) -> FoldOutcome:
675
+ # Non-terminal by contract: it never resolves a turn-wait (SS3.1.4).
676
+ turn_id = str(params["turnId"])
677
+ turn = self._turn_for(turn_id)
678
+ # ...but it is still a turn frame, so it takes the same redelivery
679
+ # guard as the other arms: a replayed page must not re-plant the hint
680
+ # on a turn that already left `running`.
681
+ if turn.state != "running":
682
+ return IgnoredStaleFrame("turn/retryScheduled", turn_id)
683
+ turn.retry_scheduled = params
684
+ return TurnFold(turn_id, turn.state)
685
+
686
+ def _turn_for(self, turn_id: str) -> _InternalTurn:
687
+ held = self._turns.get(turn_id)
688
+ if held is not None:
689
+ return held
690
+ fresh = _InternalTurn(turn_id=turn_id, state="running")
691
+ self._turns[turn_id] = fresh
692
+ return fresh
693
+
694
+ def _clear_active(self, turn_id: str) -> None:
695
+ if self._active_turn_id == turn_id:
696
+ self._active_turn_id = None
697
+
698
+ @staticmethod
699
+ def _turn_snapshot(turn: _InternalTurn) -> TurnEntry:
700
+ return TurnEntry(
701
+ turn_id=turn.turn_id,
702
+ state=turn.state,
703
+ command_id=turn.command_id,
704
+ terminal=turn.terminal,
705
+ error=turn.error,
706
+ retry_scheduled=turn.retry_scheduled,
707
+ )
708
+
709
+ # ---- the delivery-plane marker ------------------------------------------
710
+
711
+ def _delivery_gap(self, params: Params) -> FoldOutcome:
712
+ # Coalesced on the FIRST `after`: a second overflow while the first
713
+ # hole is still open extends the range rather than replacing it.
714
+ # Replacing it would move the lower bound past events that were never
715
+ # delivered, so a walk from the newer `after` would skip them
716
+ # permanently — a silent hole manufactured by the very state that
717
+ # exists to name one.
718
+ first_after = (
719
+ self._pending_gap["after"] if self._pending_gap is not None else str(params["after"])
720
+ )
721
+ self._pending_gap = {
722
+ "after": first_after,
723
+ "next": str(params["next"]),
724
+ "sessionId": str(params["sessionId"]),
725
+ }
726
+ # The FRAME's own bounds, not the coalesced ones: this is a report of
727
+ # what arrived. `pending_gap` is the outstanding hole.
728
+ return DeliveryGap(after=str(params["after"]), next=str(params["next"]))
729
+
730
+ # ---- approvals and user input as fold inputs ----------------------------
731
+
732
+ def _approval_requested(self, params: Params) -> FoldOutcome:
733
+ approval_id = str(params["approvalId"])
734
+ # A request for an approval that already has its durable terminal (a
735
+ # redelivered or pre-join frame) never re-opens it: no second
736
+ # resolution is coming, so the resurrected entry would pend forever
737
+ # (tdd SS5's first-terminal-wins, request side).
738
+ if approval_id in self._resolved_approvals:
739
+ return IgnoredStaleFrame("approval/requested", approval_id)
740
+ # A re-request REPLACES the entry, `latest_update` included: a
741
+ # re-issued request already embodies the latest refresh (tdd SS5.6.3),
742
+ # so pairing the new request with the OLD update would show stale
743
+ # stage/choices and a decide against them bounces -32053.
744
+ self._pending_approvals[approval_id] = _InternalApproval(requested=params)
745
+ return ApprovalPending(approval_id)
746
+
747
+ def _approval_updated(self, params: Params) -> FoldOutcome:
748
+ # An update REFRESHES a pending view; it never opens one. An update
749
+ # for an approval this fold never saw requested (a pre-join frame) is
750
+ # dropped rather than synthesized into a request it cannot honestly
751
+ # describe. A POST-RESOLVE update lands here too, because the resolve
752
+ # deleted the entry; surfacing the failed-persistence report such a
753
+ # frame can carry is the facade's, tracked by issue #23379.
754
+ approval_id = str(params["approvalId"])
755
+ held = self._pending_approvals.get(approval_id)
756
+ if held is None:
757
+ return IgnoredStaleFrame("approval/updated", approval_id)
758
+ held.latest_update = params
759
+ return ApprovalPending(approval_id)
760
+
761
+ def _approval_resolved(self, params: Params) -> FoldOutcome:
762
+ approval_id = str(params["approvalId"])
763
+ self._pending_approvals.pop(approval_id, None)
764
+ first_terminal = approval_id not in self._resolved_approvals
765
+ # The FIRST durable terminal decision is the one that stands (tdd
766
+ # SS5); a later one is a losing racer's echo, never an overwrite.
767
+ if first_terminal:
768
+ self._resolved_approvals[approval_id] = params
769
+ return ApprovalResolvedFold(approval_id, first_terminal)
770
+
771
+ def _user_input_requested(self, params: Params) -> FoldOutcome:
772
+ user_input_id = str(params["userInputId"])
773
+ # Symmetric with `_approval_requested`: a settled prompt stays settled.
774
+ if user_input_id in self._settled_user_inputs:
775
+ return IgnoredStaleFrame("userInput/requested", user_input_id)
776
+ self._pending_user_inputs[user_input_id] = params
777
+ return UserInputPending(user_input_id)
778
+
779
+ def _user_input_settled(self, params: Params) -> FoldOutcome:
780
+ user_input_id = str(params["userInputId"])
781
+ self._pending_user_inputs.pop(user_input_id, None)
782
+ first_settlement = user_input_id not in self._settled_user_inputs
783
+ if first_settlement:
784
+ self._settled_user_inputs[user_input_id] = params
785
+ return UserInputSettledFold(user_input_id, first_settlement)