@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/dist/A2AClient.d.ts.map +1 -1
- package/dist/A2AClient.js +184 -24
- package/dist/A2AClient.js.map +1 -1
- package/dist/A2AThreadRuntimeCore.d.ts +7 -12
- package/dist/A2AThreadRuntimeCore.d.ts.map +1 -1
- package/dist/A2AThreadRuntimeCore.js +58 -145
- package/dist/A2AThreadRuntimeCore.js.map +1 -1
- package/dist/conversions.d.ts +6 -2
- package/dist/conversions.d.ts.map +1 -1
- package/dist/conversions.js +28 -9
- package/dist/conversions.js.map +1 -1
- package/dist/useA2ARuntime.d.ts.map +1 -1
- package/dist/useA2ARuntime.js +17 -7
- package/dist/useA2ARuntime.js.map +1 -1
- package/package.json +6 -6
- package/src/A2AClient.test.ts +664 -0
- package/src/A2AClient.ts +277 -39
- package/src/A2AThreadRuntimeCore.test.ts +205 -0
- package/src/A2AThreadRuntimeCore.ts +92 -210
- package/src/conversions.test.ts +49 -0
- package/src/conversions.ts +56 -7
- package/src/useA2ARuntime.test.tsx +150 -26
- package/src/useA2ARuntime.ts +33 -12
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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 =
|
|
98
|
-
} else if (camelKey
|
|
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
|
|
127
|
-
|
|
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
|
|
130
|
-
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
type: "statusUpdate"
|
|
137
|
-
event:
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
type: "artifactUpdate"
|
|
148
|
-
event:
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
804
|
-
|
|
805
|
-
"result" in parsed
|
|
806
|
-
|
|
807
|
-
|
|
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", () => {
|