@robota-sdk/agent-transport 3.0.0-beta.82 → 3.0.0-beta.84

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 (62) hide show
  1. package/CHANGELOG.md +795 -0
  2. package/README.md +80 -118
  3. package/dist/browser/client.d.ts +2 -2
  4. package/dist/browser/client.js +1 -1
  5. package/dist/browser/index.d.ts +87 -13
  6. package/dist/browser/index.d.ts.map +1 -1
  7. package/dist/browser/index.js +1 -1
  8. package/dist/browser/index.js.map +1 -1
  9. package/dist/browser/observer-messages-CEeisRaF.js +2 -0
  10. package/dist/browser/observer-messages-CEeisRaF.js.map +1 -0
  11. package/dist/browser/observer-messages-Dnff2Tdb.d.ts +471 -0
  12. package/dist/browser/observer-messages-Dnff2Tdb.d.ts.map +1 -0
  13. package/dist/node/index.cjs +1 -1
  14. package/dist/node/index.d.cts +308 -16
  15. package/dist/node/index.d.cts.map +1 -1
  16. package/dist/node/index.d.ts +308 -16
  17. package/dist/node/index.d.ts.map +1 -1
  18. package/dist/node/index.js +1 -1
  19. package/dist/node/index.js.map +1 -1
  20. package/dist/node/node.cjs +1 -1
  21. package/dist/node/node.d.cts +69 -1
  22. package/dist/node/node.d.cts.map +1 -1
  23. package/dist/node/node.d.ts +70 -2
  24. package/dist/node/node.d.ts.map +1 -1
  25. package/dist/node/node.js +1 -1
  26. package/dist/node/node.js.map +1 -1
  27. package/package.json +17 -14
  28. package/src/__tests__/bearer-resource-gate.test.ts +120 -0
  29. package/src/__tests__/history-attached-budget.test.ts +216 -0
  30. package/src/__tests__/message-decoders.test.ts +496 -6
  31. package/src/__tests__/project-read-messages.test.ts +118 -0
  32. package/src/__tests__/session-detail-loop-status.test.ts +169 -0
  33. package/src/__tests__/session-directory-messages.test.ts +322 -0
  34. package/src/__tests__/session-error-classification.test.ts +113 -0
  35. package/src/__tests__/session-history-wire.test.ts +342 -0
  36. package/src/__tests__/session-message-handler-observe.test.ts +217 -0
  37. package/src/__tests__/session-message-handler.test.ts +139 -4
  38. package/src/__tests__/settings-messages.test.ts +193 -0
  39. package/src/__tests__/ws-broadcast-events.test.ts +10 -0
  40. package/src/client.ts +9 -1
  41. package/src/index.ts +18 -2
  42. package/src/loop-control-messages.ts +34 -0
  43. package/src/message-decoders.ts +238 -6
  44. package/src/node/bearer-resource-gate.ts +291 -0
  45. package/src/node/index.ts +13 -0
  46. package/src/observer-messages.ts +47 -0
  47. package/src/open-prompts.ts +76 -0
  48. package/src/outbound-delivery.ts +11 -2
  49. package/src/project-read-messages.ts +74 -0
  50. package/src/protocol-session.ts +23 -1
  51. package/src/session-directory-messages.ts +162 -0
  52. package/src/session-error-classification.ts +91 -0
  53. package/src/session-events.ts +100 -19
  54. package/src/session-message-handler.ts +110 -15
  55. package/src/session-query-messages.ts +123 -5
  56. package/src/session-resume-bridge.ts +8 -0
  57. package/src/settings-messages.ts +103 -0
  58. package/src/wire-messages.ts +238 -11
  59. package/dist/browser/message-decoders-BB8n-H2X.d.ts +0 -252
  60. package/dist/browser/message-decoders-BB8n-H2X.d.ts.map +0 -1
  61. package/dist/browser/message-decoders-CDmDoZPR.js +0 -2
  62. package/dist/browser/message-decoders-CDmDoZPR.js.map +0 -1
@@ -0,0 +1,76 @@
1
+ /**
2
+ * #3189: the permission and ask prompts a session has open, as the frames that asked them.
3
+ *
4
+ * The session keeps only the promise each prompt waits on, and emits the prompt once, when it is
5
+ * asked. A client that attaches later would never see a prompt already open. This keeps what the
6
+ * transport forwarded, per session, for a `get-prompts` reply.
7
+ *
8
+ * It is held by the connections that receive prompts and learns everything through their own
9
+ * subscriptions, so it never listens to the session itself. That matters: a session asks only while
10
+ * someone listens for prompts, and a listener of its own here would keep an unattended session's
11
+ * prompt open instead of failing it closed. When the last such connection goes, the session settles
12
+ * every open prompt, so nothing is kept.
13
+ */
14
+
15
+ import type { IProtocolSession } from './protocol-session.js';
16
+ import type { TServerMessage } from './wire-messages.js';
17
+
18
+ export type TOpenPromptFrame = Extract<
19
+ TServerMessage,
20
+ { type: 'permission_request' | 'ask_request' }
21
+ >;
22
+
23
+ interface IOpenPrompts {
24
+ readonly frames: Map<string, TOpenPromptFrame>;
25
+ holders: number;
26
+ }
27
+
28
+ const openPromptsBySession = new WeakMap<IProtocolSession, IOpenPrompts>();
29
+
30
+ /** One connection's hold on its session's open prompts; every holder reports what it forwards. */
31
+ export interface IOpenPromptsHold {
32
+ /** A prompt was asked. */
33
+ record(frame: TOpenPromptFrame): void;
34
+ /** A prompt was settled, by an answer or by the session. */
35
+ forget(id: string): void;
36
+ /** The host made another session current; the previous session's prompts are settled. */
37
+ clear(): void;
38
+ /** This connection no longer receives prompts. */
39
+ release(): void;
40
+ }
41
+
42
+ /** Called by a connection that receives prompts, before it subscribes to them. */
43
+ export function holdOpenPrompts(session: IProtocolSession): IOpenPromptsHold {
44
+ let open = openPromptsBySession.get(session);
45
+ if (open === undefined) {
46
+ open = { frames: new Map(), holders: 0 };
47
+ openPromptsBySession.set(session, open);
48
+ }
49
+ const held = open;
50
+ held.holders += 1;
51
+ let released = false;
52
+ return {
53
+ record: (frame) => {
54
+ if (!released) held.frames.set(frame.event.id, frame);
55
+ },
56
+ forget: (id) => {
57
+ held.frames.delete(id);
58
+ },
59
+ clear: () => {
60
+ held.frames.clear();
61
+ },
62
+ release: () => {
63
+ if (released) return;
64
+ released = true;
65
+ held.holders -= 1;
66
+ if (held.holders === 0 && openPromptsBySession.get(session) === held) {
67
+ openPromptsBySession.delete(session);
68
+ }
69
+ },
70
+ };
71
+ }
72
+
73
+ /** The prompts open now, oldest first; none when no connection that receives prompts is attached. */
74
+ export function listOpenPrompts(session: IProtocolSession): readonly TOpenPromptFrame[] {
75
+ return [...(openPromptsBySession.get(session)?.frames.values() ?? [])];
76
+ }
@@ -64,8 +64,8 @@ export type TOutboundDeliver = ((message: TServerMessage) => void) & {
64
64
 
65
65
  /**
66
66
  * Observe THIS connection's first outbound delivery failure. `event` is the `type` of the frame that
67
- * could not be delivered — a session event's own name for the fan-out (they are identical), and the
68
- * reply's type for a reply (`command_result`, `protocol_error`, …).
67
+ * could not be delivered — for the fan-out, the frame a session event was forwarded as (usually, not
68
+ * always, the event's own name), and the reply's type for a reply (`command_result`, `protocol_error`, …).
69
69
  *
70
70
  * Required, never optional: a carrier that could opt out of observing its own delivery failures is the
71
71
  * silent-failure shape this boundary exists to remove.
@@ -98,6 +98,15 @@ export type TDeliveryErrorHandler = (error: Error, event: TServerMessage['type']
98
98
  const BYTES_PER_MIB = Number('1024') * Number('1024');
99
99
  export const DEFAULT_MAX_PENDING_BYTES = Number('8') * BYTES_PER_MIB;
100
100
 
101
+ /**
102
+ * Backpressure budget for a terminal attached to a session on the same host, in either role.
103
+ *
104
+ * 1 MiB, tighter than the default: an attached terminal reads a local socket, so a backlog this large
105
+ * means it stopped reading rather than that a network is slow. Exceeding it closes that connection
106
+ * only; the session never waits for a reader, so other surfaces keep streaming.
107
+ */
108
+ export const ATTACHED_SURFACE_MAX_PENDING_BYTES = BYTES_PER_MIB;
109
+
101
110
  /**
102
111
  * Is this carrier holding more than the budget allows?
103
112
  *
@@ -0,0 +1,74 @@
1
+ /**
2
+ * #3282 §4c — the Project panel's reads (git status, one file's diff, project memory).
3
+ *
4
+ * `TProjectReadCapableSession` is `IProtocolSession & Partial<ISessionProjectRead>` (see
5
+ * `protocol-session.ts`'s doc comment): the three methods are runtime-probed, never assumed, so
6
+ * adding this capability changes nothing for a session or test double that does not have it — a
7
+ * request against one answers `protocol_error` (host-neutral, not a made-up "kind"), the same way an
8
+ * unknown message type already does.
9
+ */
10
+ import type { TOutboundDeliver } from './outbound-delivery.js';
11
+ import type { TProjectReadCapableSession } from './protocol-session.js';
12
+ import type { TClientMessage } from './wire-messages.js';
13
+
14
+ type TProjectReadMessage = Extract<
15
+ TClientMessage,
16
+ { type: 'project-status' | 'project-diff' | 'project-memory' }
17
+ >;
18
+
19
+ export function isProjectReadMessage(msg: TClientMessage): msg is TProjectReadMessage {
20
+ return msg.type === 'project-status' || msg.type === 'project-diff' || msg.type === 'project-memory';
21
+ }
22
+
23
+ function notSupported(deliver: TOutboundDeliver, requestId: string): void {
24
+ deliver({
25
+ type: 'protocol_error',
26
+ message: 'This host does not support the Project panel.',
27
+ requestId,
28
+ });
29
+ }
30
+
31
+ export function handleProjectReadMessage(
32
+ session: TProjectReadCapableSession,
33
+ deliver: TOutboundDeliver,
34
+ msg: TProjectReadMessage,
35
+ ): void {
36
+ const { requestId } = msg;
37
+ if (msg.type === 'project-status') {
38
+ if (!session.readProjectStatus) return notSupported(deliver, requestId);
39
+ session.readProjectStatus().then(
40
+ (result) => deliver({ type: 'project_status', requestId, result }),
41
+ (error: unknown) =>
42
+ deliver({
43
+ type: 'project_status',
44
+ requestId,
45
+ result: { kind: 'failed', message: error instanceof Error ? error.message : String(error) },
46
+ }),
47
+ );
48
+ } else if (msg.type === 'project-diff') {
49
+ if (!session.readProjectDiff) return notSupported(deliver, requestId);
50
+ session.readProjectDiff(msg.path).then(
51
+ (result) => deliver({ type: 'project_diff', requestId, result }),
52
+ (error: unknown) =>
53
+ deliver({
54
+ type: 'project_diff',
55
+ requestId,
56
+ result: { kind: 'failed', message: error instanceof Error ? error.message : String(error) },
57
+ }),
58
+ );
59
+ } else {
60
+ if (!session.readProjectMemory) return notSupported(deliver, requestId);
61
+ session.readProjectMemory().then(
62
+ (result) => deliver({ type: 'project_memory', requestId, result }),
63
+ (error: unknown) =>
64
+ deliver({
65
+ type: 'project_memory',
66
+ requestId,
67
+ result: {
68
+ kind: 'unavailable',
69
+ message: error instanceof Error ? error.message : String(error),
70
+ },
71
+ }),
72
+ );
73
+ }
74
+ }
@@ -1,13 +1,18 @@
1
1
  import type {
2
+ ISessionAgentJobs,
2
3
  ISessionBackgroundGroups,
3
4
  ISessionBackgroundTasks,
4
5
  ISessionCommands,
5
6
  ISessionConversationRead,
6
7
  ISessionDriverAttribution,
7
8
  ISessionEvents,
9
+ ISessionExecutionDetail,
8
10
  ISessionExecutionState,
9
11
  ISessionExecutionWorkspace,
12
+ ISessionProjectRead,
10
13
  ISessionPromptResolution,
14
+ ISessionSelfPacedLoopControl,
15
+ ISessionStatusRead,
11
16
  ISessionTurnControl,
12
17
  ISessionTurnSubmission,
13
18
  } from '@robota-sdk/agent-interface-session';
@@ -18,6 +23,7 @@ export interface IProtocolSession
18
23
  ISessionTurnSubmission,
19
24
  ISessionTurnControl,
20
25
  ISessionCommands,
26
+ ISessionStatusRead,
21
27
  ISessionEvents,
22
28
  ISessionPromptResolution,
23
29
  ISessionConversationRead,
@@ -25,4 +31,20 @@ export interface IProtocolSession
25
31
  ISessionDriverAttribution,
26
32
  ISessionBackgroundTasks,
27
33
  ISessionBackgroundGroups,
28
- ISessionExecutionWorkspace {}
34
+ ISessionExecutionWorkspace,
35
+ ISessionExecutionDetail,
36
+ ISessionSelfPacedLoopControl,
37
+ // #3282 §4: the agent switcher's read (`listAgentDefinitions`/`getDefaultAgentType`) — the
38
+ // group's write members (`spawnAgentJob` and friends) stay unused by any wire handler; they run
39
+ // only through `executeCommand`, same as every other command-only write.
40
+ ISessionAgentJobs {}
41
+
42
+ /**
43
+ * #3282 §4c: a protocol session that ALSO answers the Project panel's reads. `Partial` (not a fourth
44
+ * mandatory role on {@link IProtocolSession} itself) deliberately: every existing test double and
45
+ * every other host that builds an `IProtocolSession` keeps type-checking unchanged, and the panel's
46
+ * three methods are runtime-probed (`typeof session.readProjectStatus === 'function'`) rather than
47
+ * assumed — `InteractiveSession` (the one production implementation) provides all three, satisfying
48
+ * this type with no further wiring.
49
+ */
50
+ export type TProjectReadCapableSession = IProtocolSession & Partial<ISessionProjectRead>;
@@ -0,0 +1,162 @@
1
+ import { isSessionChangeRefusal, isSessionDeleteRefusal } from '@robota-sdk/agent-interface-session';
2
+
3
+ import type { TOutboundDeliver } from './outbound-delivery.js';
4
+ import type { TClientMessage } from './wire-messages.js';
5
+ import type { ISessionDirectory } from '@robota-sdk/agent-interface-session';
6
+
7
+ type TSessionDirectoryMessage = Extract<
8
+ TClientMessage,
9
+ { type: 'list-sessions' | 'new-session' | 'switch-session' }
10
+ >;
11
+
12
+ export function isSessionDirectoryMessage(msg: TClientMessage): msg is TSessionDirectoryMessage {
13
+ return (
14
+ msg.type === 'list-sessions' || msg.type === 'new-session' || msg.type === 'switch-session'
15
+ );
16
+ }
17
+
18
+ type TSessionRenameMessage = Extract<TClientMessage, { type: 'rename-session' }>;
19
+ type TSessionDeleteMessage = Extract<TClientMessage, { type: 'delete-session' }>;
20
+
21
+ export function isSessionRenameMessage(msg: TClientMessage): msg is TSessionRenameMessage {
22
+ return msg.type === 'rename-session';
23
+ }
24
+
25
+ export function isSessionDeleteMessage(msg: TClientMessage): msg is TSessionDeleteMessage {
26
+ return msg.type === 'delete-session';
27
+ }
28
+
29
+ /**
30
+ * #3189: the host's sessions. Listing answers on its own correlated reply. A new session or a switch
31
+ * answers nothing on success: `session_switched` from the connection's own session is the signal. A
32
+ * refusal answers `session_change_failed`, carrying the host's code (or `failed` for an error that is
33
+ * not a declared refusal) and the request's `requestId`.
34
+ */
35
+ export function handleSessionDirectoryMessage(
36
+ deliver: TOutboundDeliver,
37
+ msg: TSessionDirectoryMessage,
38
+ directory: ISessionDirectory | undefined,
39
+ ): void {
40
+ if (msg.type === 'list-sessions') {
41
+ listSessions(deliver, msg.requestId, directory);
42
+ return;
43
+ }
44
+ const request = msg.requestId !== undefined ? { requestId: msg.requestId } : {};
45
+ if (!directory) {
46
+ deliver({
47
+ type: 'session_change_failed',
48
+ code: 'not_available',
49
+ message: 'Sessions cannot be switched on this host.',
50
+ ...request,
51
+ });
52
+ return;
53
+ }
54
+ const change = (): Promise<void> =>
55
+ msg.type === 'new-session' ? directory.newSession() : directory.switchSession(msg.sessionId);
56
+ void Promise.resolve()
57
+ .then(change)
58
+ .catch((error: Error | string) =>
59
+ deliver({
60
+ type: 'session_change_failed',
61
+ code: isSessionChangeRefusal(error) ? error.code : 'failed',
62
+ message: failureMessage(error),
63
+ ...request,
64
+ }),
65
+ );
66
+ }
67
+
68
+ function listSessions(
69
+ deliver: TOutboundDeliver,
70
+ requestId: string,
71
+ directory: ISessionDirectory | undefined,
72
+ ): void {
73
+ if (!directory) {
74
+ deliver({
75
+ type: 'sessions_error',
76
+ requestId,
77
+ code: 'not_available',
78
+ message: 'Session listing is not available on this host.',
79
+ });
80
+ return;
81
+ }
82
+ try {
83
+ deliver({ type: 'sessions', requestId, listing: directory.listSessions() });
84
+ } catch (error) {
85
+ deliver({
86
+ type: 'sessions_error',
87
+ requestId,
88
+ code: 'list_failed',
89
+ message: failureMessage(error instanceof Error ? error : String(error)),
90
+ });
91
+ }
92
+ }
93
+
94
+ function failureMessage(error: Error | string): string {
95
+ return error instanceof Error ? error.message : String(error);
96
+ }
97
+
98
+ /**
99
+ * #3289 §1: rename a session from the list — current or not. Success replies with the new name so
100
+ * the client can update it optimistically; the client also re-lists to pick up everything else a
101
+ * rename can imply (nothing today, but the reply shape matches `delete-session`'s on purpose).
102
+ */
103
+ export function handleSessionRenameMessage(
104
+ deliver: TOutboundDeliver,
105
+ msg: TSessionRenameMessage,
106
+ directory: ISessionDirectory | undefined,
107
+ ): void {
108
+ if (!directory) {
109
+ deliver({
110
+ type: 'session_rename_failed',
111
+ requestId: msg.requestId,
112
+ message: 'Sessions cannot be renamed on this host.',
113
+ });
114
+ return;
115
+ }
116
+ directory.renameSession(msg.sessionId, msg.name).then(
117
+ () =>
118
+ deliver({
119
+ type: 'session_renamed_in_list',
120
+ requestId: msg.requestId,
121
+ sessionId: msg.sessionId,
122
+ name: msg.name,
123
+ }),
124
+ (error: Error | string) =>
125
+ deliver({
126
+ type: 'session_rename_failed',
127
+ requestId: msg.requestId,
128
+ message: failureMessage(error),
129
+ }),
130
+ );
131
+ }
132
+
133
+ /**
134
+ * #3289 §1: remove a stored session's record. Refused when it is live on another client or running a
135
+ * turn; deleting the current session switches this binding away first (the directory's own job), so
136
+ * by the time this answers, the caller's own session is never the one just removed.
137
+ */
138
+ export function handleSessionDeleteMessage(
139
+ deliver: TOutboundDeliver,
140
+ msg: TSessionDeleteMessage,
141
+ directory: ISessionDirectory | undefined,
142
+ ): void {
143
+ if (!directory) {
144
+ deliver({
145
+ type: 'session_delete_failed',
146
+ requestId: msg.requestId,
147
+ code: 'not_available',
148
+ message: 'Sessions cannot be deleted on this host.',
149
+ });
150
+ return;
151
+ }
152
+ directory.deleteSession(msg.sessionId).then(
153
+ () => deliver({ type: 'session_deleted', requestId: msg.requestId, sessionId: msg.sessionId }),
154
+ (error: Error | string) =>
155
+ deliver({
156
+ type: 'session_delete_failed',
157
+ requestId: msg.requestId,
158
+ code: isSessionDeleteRefusal(error) ? error.code : 'failed',
159
+ message: failureMessage(error),
160
+ }),
161
+ );
162
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * What the `error` wire frame should say about a session failure, beyond its raw `message` (#3289
3
+ * §3). A renderer maps `code` to one plain sentence with the next step, keeping `message` behind a
4
+ * "Details" disclosure — see `agent-ui-web`'s `SessionNotices`.
5
+ *
6
+ * Walks wrapped layers (`originalError`/`cause`, a few deep) because a higher-level catch can wrap the
7
+ * provider's own typed error in another one (e.g. a "Conversation failed" `ProviderError`) before it
8
+ * reaches here — the same reason `agent-core`'s `classifyProviderFailure` walks layers rather than
9
+ * looking at the outermost error alone.
10
+ */
11
+
12
+ import {
13
+ AuthenticationError,
14
+ ModelNotAvailableError,
15
+ NetworkError,
16
+ ProviderError,
17
+ RateLimitError,
18
+ } from '@robota-sdk/agent-core';
19
+
20
+ /** Kept in sync with `wire-messages.ts`'s `error` variant — see that type for what each means. */
21
+ export type TSessionErrorCode =
22
+ 'auth' | 'rate_limit' | 'model_unavailable' | 'network' | 'provider';
23
+
24
+ export interface ISessionErrorClassification {
25
+ code?: TSessionErrorCode;
26
+ provider?: string;
27
+ retryAfterSeconds?: number;
28
+ /** For `code: 'model_unavailable'`, the model the request tried, when the failure named one. */
29
+ model?: string;
30
+ }
31
+
32
+ const MAX_WRAP_DEPTH = 8;
33
+
34
+ /** The error and everything it wraps, outermost first, followed to a small depth and never twice. */
35
+ function layersOf(error: unknown): unknown[] {
36
+ const layers: unknown[] = [];
37
+ const seen = new Set<unknown>();
38
+ let frontier: unknown[] = [error];
39
+ for (let depth = 0; depth <= MAX_WRAP_DEPTH && frontier.length > 0; depth++) {
40
+ const next: unknown[] = [];
41
+ for (const layer of frontier) {
42
+ if (layer === undefined || layer === null || seen.has(layer)) continue;
43
+ seen.add(layer);
44
+ layers.push(layer);
45
+ if (typeof layer === 'object') {
46
+ const record = layer as Record<string, unknown>;
47
+ next.push(record['originalError'], record['cause']);
48
+ }
49
+ }
50
+ frontier = next;
51
+ }
52
+ return layers;
53
+ }
54
+
55
+ /**
56
+ * Classify a session failure for the wire, or leave it uncoded when it names no provider at all —
57
+ * an unrelated internal failure keeps showing its raw `message` exactly as before this classification
58
+ * existed.
59
+ */
60
+ export function classifySessionErrorForWire(error: unknown): ISessionErrorClassification {
61
+ // A generic ProviderError never wins outright — a wrapper (e.g. "Conversation failed") can sit
62
+ // above the real, more specific cause, so it is kept only as a fallback while deeper layers are
63
+ // still searched for one of the specific types below.
64
+ let fallback: ISessionErrorClassification | undefined;
65
+ for (const layer of layersOf(error)) {
66
+ if (layer instanceof AuthenticationError) {
67
+ return { code: 'auth', ...(layer.provider !== undefined && { provider: layer.provider }) };
68
+ }
69
+ if (layer instanceof RateLimitError) {
70
+ return {
71
+ code: 'rate_limit',
72
+ ...(layer.provider !== undefined && { provider: layer.provider }),
73
+ ...(layer.retryAfter !== undefined && { retryAfterSeconds: layer.retryAfter }),
74
+ };
75
+ }
76
+ if (layer instanceof ModelNotAvailableError) {
77
+ return {
78
+ code: 'model_unavailable',
79
+ provider: layer.provider,
80
+ ...(layer.model !== undefined && { model: layer.model }),
81
+ };
82
+ }
83
+ if (layer instanceof NetworkError) {
84
+ return { code: 'network', ...(layer.provider !== undefined && { provider: layer.provider }) };
85
+ }
86
+ if (fallback === undefined && layer instanceof ProviderError) {
87
+ fallback = { code: 'provider', provider: layer.provider };
88
+ }
89
+ }
90
+ return fallback ?? {};
91
+ }