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.
- muse_code/__init__.py +129 -0
- muse_code/connection/__init__.py +49 -0
- muse_code/connection/connection.py +905 -0
- muse_code/connection/discovery.py +55 -0
- muse_code/connection/spawn.py +1067 -0
- muse_code/connection/validation.py +149 -0
- muse_code/errors.py +199 -0
- muse_code/facade/__init__.py +89 -0
- muse_code/facade/approval.py +279 -0
- muse_code/facade/client.py +444 -0
- muse_code/facade/discarded.py +45 -0
- muse_code/facade/gap_fill.py +483 -0
- muse_code/facade/host_death.py +254 -0
- muse_code/facade/session.py +977 -0
- muse_code/facade/turn_handle.py +440 -0
- muse_code/facade/turn_submit.py +289 -0
- muse_code/fold/__init__.py +82 -0
- muse_code/fold/item_store.py +268 -0
- muse_code/fold/session_fold.py +785 -0
- muse_code/fold/state_store.py +141 -0
- muse_code/pending/__init__.py +53 -0
- muse_code/pending/pending_command_set.py +842 -0
- muse_code/py.typed +0 -0
- muse_code/replay.py +144 -0
- muse_code/sync_facade.py +493 -0
- muse_code_sdk-1.3.0.dist-info/METADATA +47 -0
- muse_code_sdk-1.3.0.dist-info/RECORD +30 -0
- muse_code_sdk-1.3.0.dist-info/WHEEL +5 -0
- muse_code_sdk-1.3.0.dist-info/licenses/LICENSE +21 -0
- muse_code_sdk-1.3.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|