@assistant-ui/react-a2a 0.2.31 → 0.2.32

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.
package/src/A2AClient.ts CHANGED
@@ -63,6 +63,37 @@ const OPAQUE_FIELDS = new Set([
63
63
  "scopes",
64
64
  ]);
65
65
 
66
+ const JSONRPC_STATE_MAP: Record<string, string> = {
67
+ "input-required": "input_required",
68
+ "auth-required": "auth_required",
69
+ unknown: "unspecified",
70
+ };
71
+
72
+ // JSON-RPC file parts nest the payload under `file`; the internal A2APart is
73
+ // flat, so the nested fields map onto url/raw/mediaType/filename.
74
+ function normalizeParts(value: unknown[]): unknown[] {
75
+ return value.map((raw) => {
76
+ const part = normalizeKeys(raw, false);
77
+ if (part === null || typeof part !== "object" || Array.isArray(part))
78
+ return part;
79
+ const record = part as Record<string, unknown>;
80
+ if (record.kind === undefined) return part;
81
+ const { kind, ...rest } = record;
82
+ const file = rest.file;
83
+ if (kind !== "file" || file === null || typeof file !== "object")
84
+ return rest;
85
+ const { file: _file, ...others } = rest;
86
+ const nested = file as Record<string, unknown>;
87
+ return {
88
+ ...others,
89
+ ...(nested.uri !== undefined ? { url: nested.uri } : {}),
90
+ ...(nested.bytes !== undefined ? { raw: nested.bytes } : {}),
91
+ ...(nested.mimeType !== undefined ? { mediaType: nested.mimeType } : {}),
92
+ ...(nested.name !== undefined ? { filename: nested.name } : {}),
93
+ };
94
+ });
95
+ }
96
+
66
97
  function normalizeKeys(obj: unknown, opaque = false): unknown {
67
98
  if (Array.isArray(obj)) return obj.map((v) => normalizeKeys(v, opaque));
68
99
  if (obj !== null && typeof obj === "object") {
@@ -80,12 +111,15 @@ function normalizeKeys(obj: unknown, opaque = false): unknown {
80
111
  const camelKey = toCamelCase(key);
81
112
  const isOpaqueChild = OPAQUE_FIELDS.has(camelKey);
82
113
 
83
- if (
84
- camelKey === "state" &&
85
- typeof value === "string" &&
86
- value.startsWith("TASK_STATE_")
87
- ) {
88
- result[camelKey] = value.slice(11).toLowerCase();
114
+ if (camelKey === "state" && typeof value === "string") {
115
+ // Proto-style (TASK_STATE_WORKING) and the JSON-RPC state names map
116
+ // onto the internal snake_case states; anything unrecognized is
117
+ // preserved verbatim.
118
+ if (value.startsWith("TASK_STATE_")) {
119
+ result[camelKey] = value.slice(11).toLowerCase();
120
+ } else {
121
+ result[camelKey] = JSONRPC_STATE_MAP[value] ?? value;
122
+ }
89
123
  } else if (
90
124
  camelKey === "role" &&
91
125
  typeof value === "string" &&
@@ -94,9 +128,11 @@ function normalizeKeys(obj: unknown, opaque = false): unknown {
94
128
  result[camelKey] = value.slice(5).toLowerCase();
95
129
  } else if (camelKey === "content" && Array.isArray(value)) {
96
130
  // v0.3 servers used "content" for message/artifact parts; normalize to "parts" for backward compat
97
- result.parts = normalizeKeys(value, false);
98
- } else if (camelKey !== "parts" || !("parts" in result)) {
131
+ result.parts = normalizeParts(value);
132
+ } else if (camelKey === "parts" && Array.isArray(value)) {
99
133
  // dedup: "content" was already mapped to parts above; don't overwrite
134
+ if (!("parts" in result)) result.parts = normalizeParts(value);
135
+ } else if (camelKey !== "parts" || !("parts" in result)) {
100
136
  result[camelKey] = isOpaqueChild ? value : normalizeKeys(value, false);
101
137
  }
102
138
  }
@@ -123,33 +159,78 @@ function toWireMessage(msg: A2AMessage): unknown {
123
159
  function discriminateStreamResponse(
124
160
  data: Record<string, unknown>,
125
161
  ): A2AStreamEvent | null {
126
- if ("task" in data && data.task) {
127
- return { type: "task", task: data.task as A2ATask };
162
+ if ("task" in data) {
163
+ const task = toWrappedTask(data.task);
164
+ if (task) return { type: "task", task };
128
165
  }
129
- if ("message" in data && data.message) {
130
- return { type: "message", message: data.message as A2AMessage };
166
+ if ("message" in data) {
167
+ const message = toWrappedMessage(data.message);
168
+ if (message) return { type: "message", message };
131
169
  }
132
- if ("statusUpdate" in data && data.statusUpdate) {
133
- return {
134
- type: "statusUpdate",
135
- event: data.statusUpdate as A2AStreamEvent extends {
136
- type: "statusUpdate";
137
- event: infer E;
138
- }
139
- ? E
140
- : never,
141
- };
170
+ if ("statusUpdate" in data) {
171
+ const statusUpdate = toWrappedStatusUpdate(data.statusUpdate);
172
+ if (statusUpdate) {
173
+ return {
174
+ type: "statusUpdate",
175
+ event: statusUpdate as A2AStreamEvent extends {
176
+ type: "statusUpdate";
177
+ event: infer E;
178
+ }
179
+ ? E
180
+ : never,
181
+ };
182
+ }
142
183
  }
143
- if ("artifactUpdate" in data && data.artifactUpdate) {
144
- return {
145
- type: "artifactUpdate",
146
- event: data.artifactUpdate as A2AStreamEvent extends {
147
- type: "artifactUpdate";
148
- event: infer E;
149
- }
150
- ? E
151
- : never,
152
- };
184
+ if ("artifactUpdate" in data) {
185
+ const artifactUpdate = toWrappedArtifactUpdate(data.artifactUpdate);
186
+ if (artifactUpdate) {
187
+ return {
188
+ type: "artifactUpdate",
189
+ event: artifactUpdate as A2AStreamEvent extends {
190
+ type: "artifactUpdate";
191
+ event: infer E;
192
+ }
193
+ ? E
194
+ : never,
195
+ };
196
+ }
197
+ }
198
+ // JSON-RPC streaming results are the event itself, flat, discriminated by
199
+ // `kind` (per the A2A JSON-RPC schema), rather than wrapped in a
200
+ // REST-style single-key envelope. The field sets cannot collide with the
201
+ // wrapper keys above, so this is a pure fallthrough.
202
+ const { kind, ...flat } = data;
203
+ switch (kind) {
204
+ case "task":
205
+ if (!isTask(flat)) break;
206
+ return { type: "task", task: flat };
207
+ case "message":
208
+ if (!isMessage(flat)) break;
209
+ return { type: "message", message: flat };
210
+ case "status-update": {
211
+ if (!isStatusUpdate(flat)) break;
212
+ const { final: _final, ...event } = flat;
213
+ return {
214
+ type: "statusUpdate",
215
+ event: event as unknown as A2AStreamEvent extends {
216
+ type: "statusUpdate";
217
+ event: infer E;
218
+ }
219
+ ? E
220
+ : never,
221
+ };
222
+ }
223
+ case "artifact-update":
224
+ if (!isArtifactUpdate(flat)) break;
225
+ return {
226
+ type: "artifactUpdate",
227
+ event: flat as unknown as A2AStreamEvent extends {
228
+ type: "artifactUpdate";
229
+ event: infer E;
230
+ }
231
+ ? E
232
+ : never,
233
+ };
153
234
  }
154
235
  return null;
155
236
  }
@@ -182,10 +263,20 @@ const ROLES: ReadonlySet<string> = new Set(
182
263
  const isRole = (value: unknown): value is A2ARole =>
183
264
  typeof value === "string" && ROLES.has(value);
184
265
 
266
+ // Ids reach task state and the next request body unchecked by anything
267
+ // downstream, so an id that is present and not null must be a string.
268
+ // An omitted or null id keeps the acceptance each path already had.
269
+ const hasOptionalStringIds = (
270
+ value: Record<string, unknown>,
271
+ keys: readonly string[],
272
+ ): boolean =>
273
+ keys.every((key) => value[key] == null || typeof value[key] === "string");
274
+
185
275
  const isTask = (value: unknown): value is A2ATask =>
186
276
  isRecord(value) &&
187
277
  typeof value.id === "string" &&
188
278
  value.id.length > 0 &&
279
+ hasOptionalStringIds(value, ["contextId"]) &&
189
280
  isRecord(value.status) &&
190
281
  isTaskState(value.status.state);
191
282
 
@@ -193,13 +284,146 @@ const isMessage = (value: unknown): value is A2AMessage =>
193
284
  isRecord(value) &&
194
285
  typeof value.messageId === "string" &&
195
286
  value.messageId.length > 0 &&
287
+ hasOptionalStringIds(value, ["contextId", "taskId"]) &&
196
288
  isRole(value.role) &&
197
289
  Array.isArray(value.parts) &&
198
290
  value.parts.every(isRecord);
199
291
 
292
+ // Legacy wrappers use ProtoJSON, where omitted and null fields decode to proto
293
+ // defaults. Normalize those defaults before enforcing semantic requirements.
294
+ // Filling a null id with the ProtoJSON default does not make it a string, and
295
+ // the shape guards below check the fields they name rather than the ids. The
296
+ // runtime reads these straight into task state and the next request body.
297
+ const hasStringIds = (
298
+ value: Record<string, unknown>,
299
+ keys: readonly string[],
300
+ ): boolean => keys.every((key) => typeof value[key] === "string");
301
+
302
+ const toWrappedTaskStatus = (
303
+ value: unknown,
304
+ ): Record<string, unknown> | null => {
305
+ if (!isRecord(value)) return null;
306
+ return {
307
+ ...value,
308
+ state: value.state == null ? "unspecified" : value.state,
309
+ };
310
+ };
311
+
312
+ const toWrappedTask = (value: unknown): A2ATask | null => {
313
+ if (!isRecord(value)) return null;
314
+ const status = toWrappedTaskStatus(value.status);
315
+ if (!status) return null;
316
+
317
+ const task = {
318
+ ...value,
319
+ contextId: value.contextId == null ? "" : value.contextId,
320
+ status,
321
+ };
322
+ return isTask(task) && hasStringIds(task, ["contextId"]) ? task : null;
323
+ };
324
+
325
+ const toWrappedMessage = (value: unknown): A2AMessage | null => {
326
+ if (!isRecord(value)) return null;
327
+
328
+ const message = {
329
+ ...value,
330
+ contextId: value.contextId == null ? "" : value.contextId,
331
+ taskId: value.taskId == null ? "" : value.taskId,
332
+ role: value.role == null ? "unspecified" : value.role,
333
+ parts: value.parts == null ? [] : value.parts,
334
+ };
335
+ return isMessage(message) && hasStringIds(message, ["contextId", "taskId"])
336
+ ? message
337
+ : null;
338
+ };
339
+
340
+ const isStatusUpdate = (
341
+ value: unknown,
342
+ allowEmptyTaskId = false,
343
+ ): value is Record<string, unknown> =>
344
+ isRecord(value) &&
345
+ typeof value.taskId === "string" &&
346
+ (allowEmptyTaskId || value.taskId.length > 0) &&
347
+ hasOptionalStringIds(value, ["contextId"]) &&
348
+ isRecord(value.status) &&
349
+ isTaskState(value.status.state);
350
+
351
+ const toWrappedStatusUpdate = (
352
+ value: unknown,
353
+ ): Record<string, unknown> | null => {
354
+ if (!isRecord(value) || !isRecord(value.status)) return null;
355
+
356
+ const statusUpdate = {
357
+ ...value,
358
+ taskId: value.taskId == null ? "" : value.taskId,
359
+ contextId: value.contextId == null ? "" : value.contextId,
360
+ status: toWrappedTaskStatus(value.status),
361
+ };
362
+ return isStatusUpdate(statusUpdate, true) &&
363
+ hasStringIds(statusUpdate, ["taskId", "contextId"])
364
+ ? statusUpdate
365
+ : null;
366
+ };
367
+
368
+ const isArtifact = (value: unknown): value is Record<string, unknown> =>
369
+ isRecord(value) &&
370
+ typeof value.artifactId === "string" &&
371
+ Array.isArray(value.parts) &&
372
+ value.parts.every(isRecord);
373
+
374
+ const isArtifactUpdate = (value: unknown): value is Record<string, unknown> =>
375
+ isRecord(value) &&
376
+ hasOptionalStringIds(value, ["contextId", "taskId"]) &&
377
+ isArtifact(value.artifact);
378
+
379
+ const toWrappedArtifact = (value: unknown): Record<string, unknown> | null => {
380
+ if (!isRecord(value)) return null;
381
+
382
+ const artifact = {
383
+ ...value,
384
+ artifactId: value.artifactId == null ? "" : value.artifactId,
385
+ parts: value.parts == null ? [] : value.parts,
386
+ };
387
+ return isArtifact(artifact) ? artifact : null;
388
+ };
389
+
390
+ const toWrappedArtifactUpdate = (
391
+ value: unknown,
392
+ ): Record<string, unknown> | null => {
393
+ if (!isRecord(value)) return null;
394
+ const artifact = toWrappedArtifact(value.artifact);
395
+ if (!artifact) return null;
396
+
397
+ const artifactUpdate = {
398
+ ...value,
399
+ taskId: value.taskId == null ? "" : value.taskId,
400
+ contextId: value.contextId == null ? "" : value.contextId,
401
+ artifact,
402
+ };
403
+ return isArtifactUpdate(artifactUpdate) &&
404
+ hasStringIds(artifactUpdate, ["taskId", "contextId"])
405
+ ? artifactUpdate
406
+ : null;
407
+ };
408
+
200
409
  const isStringArray = (value: unknown): value is string[] =>
201
410
  Array.isArray(value) && value.every((item) => typeof item === "string");
202
411
 
412
+ const toJsonRpcError = (error: unknown): A2AError => {
413
+ const rpcError = error as { code?: number; message?: string; data?: unknown };
414
+ return new A2AError({
415
+ code: rpcError.code ?? -1,
416
+ status: "JSONRPC_ERROR",
417
+ message: rpcError.message ?? "A2A JSON-RPC error",
418
+ details:
419
+ rpcError.data === undefined
420
+ ? undefined
421
+ : Array.isArray(rpcError.data)
422
+ ? rpcError.data
423
+ : [rpcError.data],
424
+ });
425
+ };
426
+
203
427
  const invalidAgentCard = (): never => {
204
428
  throw new Error(
205
429
  "Invalid A2A agent card response: expected a valid agent card payload.",
@@ -522,6 +746,19 @@ export class A2AClient {
522
746
  await this.throwResponseError(response);
523
747
  }
524
748
  const json = await response.json();
749
+ if (json && typeof json === "object" && "jsonrpc" in json) {
750
+ if ("error" in json && json.error) {
751
+ throw toJsonRpcError(json.error);
752
+ }
753
+ if ("result" in json) {
754
+ const result = normalizeKeys(json.result);
755
+ if (isRecord(result) && typeof result.kind === "string") {
756
+ const { kind: _kind, ...rest } = result;
757
+ return rest as T;
758
+ }
759
+ return result as T;
760
+ }
761
+ }
525
762
  return normalizeKeys(json) as T;
526
763
  }
527
764
 
@@ -798,13 +1035,13 @@ export class A2AClient {
798
1035
  try {
799
1036
  let parsed = JSON.parse(event.data);
800
1037
 
801
- if (
802
- parsed &&
803
- typeof parsed === "object" &&
804
- "jsonrpc" in parsed &&
805
- "result" in parsed
806
- ) {
807
- parsed = parsed.result;
1038
+ if (parsed && typeof parsed === "object" && "jsonrpc" in parsed) {
1039
+ if ("error" in parsed && parsed.error) {
1040
+ throw toJsonRpcError(parsed.error);
1041
+ }
1042
+ if ("result" in parsed) {
1043
+ parsed = parsed.result;
1044
+ }
808
1045
  }
809
1046
 
810
1047
  const normalized = normalizeKeys(parsed) as Record<string, unknown>;
@@ -812,6 +1049,7 @@ export class A2AClient {
812
1049
  if (!streamEvent) noteSkip(event.data, "unrecognized event shape");
813
1050
  return streamEvent;
814
1051
  } catch (error) {
1052
+ if (error instanceof A2AError) throw error;
815
1053
  noteSkip(
816
1054
  event.data,
817
1055
  error instanceof Error ? error.message : String(error),
@@ -150,6 +150,95 @@ describe("A2AThreadRuntimeCore", () => {
150
150
  });
151
151
  }
152
152
 
153
+ describe("late history loading", () => {
154
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
155
+
156
+ function createLateHistory() {
157
+ const user = createHistoryMessage("user", "user", "Question");
158
+ return {
159
+ user,
160
+ history: {
161
+ load: vi.fn().mockResolvedValue({
162
+ headId: user.id,
163
+ messages: [{ parentId: null, message: user }],
164
+ }),
165
+ append: vi.fn().mockResolvedValue(undefined),
166
+ },
167
+ };
168
+ }
169
+
170
+ it("loads history when the adapter arrives after the first load", async () => {
171
+ const client = createMockClient();
172
+ const core = createCore(client);
173
+ const { user, history } = createLateHistory();
174
+
175
+ await core.__internal_load();
176
+ expect(history.load).not.toHaveBeenCalled();
177
+ expect(core.getMessages()).toEqual([]);
178
+
179
+ core.updateOptions({ client, history });
180
+ await flush();
181
+
182
+ expect(history.load).toHaveBeenCalledOnce();
183
+ expect(core.getMessages().map((message) => message.id)).toEqual([
184
+ user.id,
185
+ ]);
186
+ expect(core.isLoading).toBe(false);
187
+ });
188
+
189
+ it("fetches the agent card once across the early load and the late history load", async () => {
190
+ const agentCard = { name: "Agent", url: "https://agent.example" };
191
+ const client = createMockClient({
192
+ getAgentCard: vi.fn().mockResolvedValue(agentCard),
193
+ });
194
+ const core = createCore(client);
195
+ const { history } = createLateHistory();
196
+
197
+ await core.__internal_load();
198
+ expect(core.getAgentCard()).toEqual(agentCard);
199
+
200
+ core.updateOptions({ client, history });
201
+ await flush();
202
+
203
+ expect(client.getAgentCard).toHaveBeenCalledOnce();
204
+ expect(history.load).toHaveBeenCalledOnce();
205
+ });
206
+
207
+ it("does not load late history over a thread that already has messages", async () => {
208
+ const client = createMockClient();
209
+ const core = createCore(client);
210
+ const { history } = createLateHistory();
211
+
212
+ await core.__internal_load();
213
+ await core.append({
214
+ ...createUserAppendMessage("Typed"),
215
+ startRun: false,
216
+ } as AppendMessage);
217
+ expect(core.getMessages()).toHaveLength(1);
218
+
219
+ core.updateOptions({ client, history });
220
+ await flush();
221
+
222
+ expect(history.load).not.toHaveBeenCalled();
223
+ expect(core.getMessages()).toHaveLength(1);
224
+ });
225
+
226
+ it("does not reload when the adapter is replaced after a completed load", async () => {
227
+ const client = createMockClient();
228
+ const { history } = createLateHistory();
229
+ const core = createCore(client, { history });
230
+ const replacement = createLateHistory().history;
231
+
232
+ await core.__internal_load();
233
+ expect(history.load).toHaveBeenCalledOnce();
234
+
235
+ core.updateOptions({ client, history: replacement });
236
+ await flush();
237
+
238
+ expect(replacement.load).not.toHaveBeenCalled();
239
+ });
240
+ });
241
+
153
242
  // --- Basic state ---
154
243
 
155
244
  describe("initial state", () => {
@@ -371,6 +460,122 @@ describe("A2AThreadRuntimeCore", () => {
371
460
  });
372
461
  });
373
462
 
463
+ describe("updateOptions", () => {
464
+ it("keeps the server-assigned contextId across re-renders", async () => {
465
+ const streamMessage = vi.fn().mockImplementation(async function* () {
466
+ yield statusUpdateEvent("completed", "Answer");
467
+ });
468
+ const client = createMockClient({ streamMessage });
469
+ const core = new A2AThreadRuntimeCore({
470
+ client,
471
+ notifyUpdate: notifyUpdate as unknown as () => void,
472
+ });
473
+
474
+ await core.append(createUserAppendMessage("First"));
475
+
476
+ // useA2ARuntime re-applies its options on every render, including the
477
+ // renders triggered by the stream's own notifyUpdate calls.
478
+ core.updateOptions({ client, contextId: undefined });
479
+
480
+ await core.append(createUserAppendMessage("Second"));
481
+
482
+ const secondSend = streamMessage.mock.calls[1]?.[0];
483
+ expect(secondSend?.contextId).toBe("ctx-1");
484
+ });
485
+
486
+ it("resets the contextId when the thread is switched", async () => {
487
+ const streamMessage = vi.fn().mockImplementation(async function* () {
488
+ yield statusUpdateEvent("completed", "Answer");
489
+ });
490
+ const client = createMockClient({ streamMessage });
491
+ const core = new A2AThreadRuntimeCore({
492
+ client,
493
+ notifyUpdate: notifyUpdate as unknown as () => void,
494
+ });
495
+
496
+ await core.append(createUserAppendMessage("First"));
497
+ core.applyExternalMessages([]);
498
+ core.resetContext();
499
+ await core.append(createUserAppendMessage("Fresh thread"));
500
+
501
+ const secondSend = streamMessage.mock.calls[1]?.[0];
502
+ expect(secondSend?.contextId).toBeUndefined();
503
+ });
504
+
505
+ it("does not persist a partial message when switching away mid-run", async () => {
506
+ let releaseStream!: () => void;
507
+ const gate = new Promise<void>((resolve) => {
508
+ releaseStream = resolve;
509
+ });
510
+ const streamMessage = vi.fn().mockImplementation(async function* () {
511
+ yield statusUpdateEvent("working");
512
+ await gate;
513
+ });
514
+ const history = {
515
+ load: vi.fn().mockResolvedValue({ messages: [] }),
516
+ append: vi.fn().mockResolvedValue(undefined),
517
+ };
518
+ const core = createCore({ streamMessage }, { history });
519
+
520
+ const run = core.append(createUserAppendMessage("First"));
521
+ await vi.waitFor(() => {
522
+ expect(streamMessage).toHaveBeenCalledTimes(1);
523
+ });
524
+ history.append.mockClear();
525
+
526
+ core.applyExternalMessages([]);
527
+ core.resetContext();
528
+ releaseStream();
529
+ await run.catch(() => {});
530
+
531
+ const cancelledAppend = history.append.mock.calls.find((call) => {
532
+ const entry = call[0] as
533
+ | { message?: { status?: { reason?: string } } }
534
+ | undefined;
535
+ return entry?.message?.status?.reason === "cancelled";
536
+ });
537
+ expect(cancelledAppend).toBeUndefined();
538
+ });
539
+
540
+ it("keeps the contextId across a bare external apply", async () => {
541
+ const streamMessage = vi.fn().mockImplementation(async function* () {
542
+ yield statusUpdateEvent("completed", "Answer");
543
+ });
544
+ const client = createMockClient({ streamMessage });
545
+ const core = new A2AThreadRuntimeCore({
546
+ client,
547
+ notifyUpdate: notifyUpdate as unknown as () => void,
548
+ });
549
+
550
+ await core.append(createUserAppendMessage("First"));
551
+ // Branch switches, deletes, and cancel resyncs route through
552
+ // applyExternalMessages without a thread switch.
553
+ core.applyExternalMessages(core.getMessages());
554
+ await core.append(createUserAppendMessage("Second"));
555
+
556
+ const secondSend = streamMessage.mock.calls[1]?.[0];
557
+ expect(secondSend?.contextId).toBe("ctx-1");
558
+ });
559
+
560
+ it("applies a changed contextId option", async () => {
561
+ const streamMessage = vi.fn().mockImplementation(async function* () {
562
+ yield statusUpdateEvent("completed", "Answer");
563
+ });
564
+ const client = createMockClient({ streamMessage });
565
+ const core = new A2AThreadRuntimeCore({
566
+ client,
567
+ notifyUpdate: notifyUpdate as unknown as () => void,
568
+ });
569
+
570
+ await core.append(createUserAppendMessage("First"));
571
+ core.updateOptions({ client, contextId: "ctx-override" });
572
+ await core.append(createUserAppendMessage("Second"));
573
+
574
+ const secondSend = streamMessage.mock.calls[1]?.[0];
575
+ expect(secondSend?.contextId).toBe("ctx-override");
576
+ });
577
+ });
578
+
374
579
  // --- Edit & Reload ---
375
580
 
376
581
  describe("edit", () => {