@assistant-ui/react-a2a 0.2.22 → 0.2.24
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 +60 -5
- package/dist/A2AClient.js.map +1 -1
- package/dist/A2AThreadRuntimeCore.d.ts +15 -3
- package/dist/A2AThreadRuntimeCore.d.ts.map +1 -1
- package/dist/A2AThreadRuntimeCore.js +162 -45
- package/dist/A2AThreadRuntimeCore.js.map +1 -1
- package/dist/conversions.d.ts +6 -3
- package/dist/conversions.d.ts.map +1 -1
- package/dist/conversions.js +32 -3
- package/dist/conversions.js.map +1 -1
- package/dist/useA2ARuntime.d.ts.map +1 -1
- package/dist/useA2ARuntime.js +30 -18
- package/dist/useA2ARuntime.js.map +1 -1
- package/package.json +10 -6
- package/src/A2AClient.test.ts +142 -0
- package/src/A2AClient.ts +114 -8
- package/src/A2AThreadRuntimeCore.test.ts +458 -0
- package/src/A2AThreadRuntimeCore.ts +215 -57
- package/src/conversions.test.ts +109 -2
- package/src/conversions.ts +46 -4
- package/src/useA2ARuntime.test.tsx +142 -0
- package/src/useA2ARuntime.ts +55 -36
package/src/A2AClient.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SSEEventDecoder, type SSEEvent } from "assistant-stream/utils";
|
|
2
|
+
import { isRecord } from "@assistant-ui/core/internal";
|
|
2
3
|
import type {
|
|
3
4
|
A2AAgentCard,
|
|
4
5
|
A2AErrorInfo,
|
|
@@ -153,6 +154,114 @@ function discriminateStreamResponse(
|
|
|
153
154
|
return null;
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
const TASK_STATES: ReadonlySet<string> = new Set(
|
|
158
|
+
Object.keys({
|
|
159
|
+
unspecified: true,
|
|
160
|
+
submitted: true,
|
|
161
|
+
working: true,
|
|
162
|
+
completed: true,
|
|
163
|
+
failed: true,
|
|
164
|
+
canceled: true,
|
|
165
|
+
input_required: true,
|
|
166
|
+
rejected: true,
|
|
167
|
+
auth_required: true,
|
|
168
|
+
} satisfies Record<A2ATaskState, true>),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const isTaskState = (value: unknown): value is A2ATaskState =>
|
|
172
|
+
typeof value === "string" && TASK_STATES.has(value);
|
|
173
|
+
|
|
174
|
+
const isTask = (value: unknown): value is A2ATask =>
|
|
175
|
+
isRecord(value) &&
|
|
176
|
+
typeof value.id === "string" &&
|
|
177
|
+
value.id.length > 0 &&
|
|
178
|
+
isRecord(value.status) &&
|
|
179
|
+
isTaskState(value.status.state);
|
|
180
|
+
|
|
181
|
+
const isMessage = (value: unknown): value is A2AMessage =>
|
|
182
|
+
isRecord(value) &&
|
|
183
|
+
typeof value.messageId === "string" &&
|
|
184
|
+
value.messageId.length > 0 &&
|
|
185
|
+
typeof value.role === "string" &&
|
|
186
|
+
value.role.length > 0 &&
|
|
187
|
+
Array.isArray(value.parts) &&
|
|
188
|
+
value.parts.every(isRecord);
|
|
189
|
+
|
|
190
|
+
const isStringArray = (value: unknown): value is string[] =>
|
|
191
|
+
Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
192
|
+
|
|
193
|
+
const invalidAgentCard = (): never => {
|
|
194
|
+
throw new Error(
|
|
195
|
+
"Invalid A2A agent card response: expected a valid agent card payload.",
|
|
196
|
+
);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const parseCardString = (value: unknown): string =>
|
|
200
|
+
value == null ? "" : typeof value === "string" ? value : invalidAgentCard();
|
|
201
|
+
|
|
202
|
+
const parseCardStringArray = (value: unknown): string[] =>
|
|
203
|
+
value == null ? [] : isStringArray(value) ? value : invalidAgentCard();
|
|
204
|
+
|
|
205
|
+
const parseCardRecordArray = (value: unknown): Record<string, unknown>[] =>
|
|
206
|
+
value == null
|
|
207
|
+
? []
|
|
208
|
+
: Array.isArray(value) && value.every(isRecord)
|
|
209
|
+
? (value as Record<string, unknown>[])
|
|
210
|
+
: invalidAgentCard();
|
|
211
|
+
|
|
212
|
+
const parseCardRecord = (value: unknown): Record<string, unknown> =>
|
|
213
|
+
value == null ? {} : isRecord(value) ? value : invalidAgentCard();
|
|
214
|
+
|
|
215
|
+
// Proto3 JSON parsing treats omitted and null fields as defaults, so a valid
|
|
216
|
+
// card may arrive without its empty lists, strings, or capabilities. Fill
|
|
217
|
+
// those per the proto3 JSON mapping rules; a payload without a name or with a
|
|
218
|
+
// present field of the wrong type rejects.
|
|
219
|
+
const parseAgentCardResponse = (value: unknown): A2AAgentCard => {
|
|
220
|
+
if (
|
|
221
|
+
!isRecord(value) ||
|
|
222
|
+
typeof value.name !== "string" ||
|
|
223
|
+
value.name.length === 0
|
|
224
|
+
) {
|
|
225
|
+
return invalidAgentCard();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
...value,
|
|
230
|
+
name: value.name,
|
|
231
|
+
description: parseCardString(value.description),
|
|
232
|
+
version: parseCardString(value.version),
|
|
233
|
+
supportedInterfaces: parseCardRecordArray(value.supportedInterfaces).map(
|
|
234
|
+
(entry) => ({
|
|
235
|
+
...entry,
|
|
236
|
+
url: parseCardString(entry.url),
|
|
237
|
+
protocolBinding: parseCardString(entry.protocolBinding),
|
|
238
|
+
protocolVersion: parseCardString(entry.protocolVersion),
|
|
239
|
+
}),
|
|
240
|
+
),
|
|
241
|
+
capabilities: parseCardRecord(value.capabilities),
|
|
242
|
+
defaultInputModes: parseCardStringArray(value.defaultInputModes),
|
|
243
|
+
defaultOutputModes: parseCardStringArray(value.defaultOutputModes),
|
|
244
|
+
skills: parseCardRecordArray(value.skills).map((entry) => ({
|
|
245
|
+
...entry,
|
|
246
|
+
id: parseCardString(entry.id),
|
|
247
|
+
name: parseCardString(entry.name),
|
|
248
|
+
description: parseCardString(entry.description),
|
|
249
|
+
tags: parseCardStringArray(entry.tags),
|
|
250
|
+
})),
|
|
251
|
+
} as A2AAgentCard;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const parseSendMessageResponse = (value: unknown): A2ATask | A2AMessage => {
|
|
255
|
+
if (isRecord(value)) {
|
|
256
|
+
const candidate = value.task ?? value.message ?? value;
|
|
257
|
+
if (isTask(candidate) || isMessage(candidate)) return candidate;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
throw new Error(
|
|
261
|
+
"Invalid A2A message:send response: expected a valid task or message payload.",
|
|
262
|
+
);
|
|
263
|
+
};
|
|
264
|
+
|
|
156
265
|
function signalInit(signal?: AbortSignal): RequestInit {
|
|
157
266
|
return signal ? { signal } : {};
|
|
158
267
|
}
|
|
@@ -273,14 +382,15 @@ export class A2AClient {
|
|
|
273
382
|
await this.throwResponseError(response);
|
|
274
383
|
}
|
|
275
384
|
const json = await response.json();
|
|
276
|
-
return normalizeKeys(json)
|
|
385
|
+
return parseAgentCardResponse(normalizeKeys(json));
|
|
277
386
|
}
|
|
278
387
|
|
|
279
388
|
async getExtendedAgentCard(signal?: AbortSignal): Promise<A2AAgentCard> {
|
|
280
|
-
|
|
389
|
+
const result = await this.fetchJSON<unknown>(
|
|
281
390
|
`${this.getBasePath()}/extendedAgentCard`,
|
|
282
391
|
signalInit(signal),
|
|
283
392
|
);
|
|
393
|
+
return parseAgentCardResponse(result);
|
|
284
394
|
}
|
|
285
395
|
|
|
286
396
|
// --- Message ---
|
|
@@ -297,7 +407,7 @@ export class A2AClient {
|
|
|
297
407
|
if (configuration) body.configuration = configuration;
|
|
298
408
|
if (metadata) body.metadata = metadata;
|
|
299
409
|
|
|
300
|
-
const result = await this.fetchJSON<
|
|
410
|
+
const result = await this.fetchJSON<unknown>(
|
|
301
411
|
`${this.getBasePath()}/message:send`,
|
|
302
412
|
{
|
|
303
413
|
method: "POST",
|
|
@@ -306,11 +416,7 @@ export class A2AClient {
|
|
|
306
416
|
},
|
|
307
417
|
);
|
|
308
418
|
|
|
309
|
-
|
|
310
|
-
if ("task" in result && result.task) return result.task as A2ATask;
|
|
311
|
-
if ("message" in result && result.message)
|
|
312
|
-
return result.message as A2AMessage;
|
|
313
|
-
return result as unknown as A2ATask | A2AMessage;
|
|
419
|
+
return parseSendMessageResponse(result);
|
|
314
420
|
}
|
|
315
421
|
|
|
316
422
|
async *streamMessage(
|
|
@@ -40,6 +40,57 @@ function createUserAppendMessage(text: string): AppendMessage {
|
|
|
40
40
|
} as unknown as AppendMessage;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
function createHistoryMessage(
|
|
44
|
+
id: string,
|
|
45
|
+
role: "user" | "assistant",
|
|
46
|
+
text: string,
|
|
47
|
+
): ThreadMessage {
|
|
48
|
+
return {
|
|
49
|
+
id,
|
|
50
|
+
role,
|
|
51
|
+
createdAt: new Date(),
|
|
52
|
+
content: [{ type: "text", text }],
|
|
53
|
+
status: { type: "complete", reason: "stop" },
|
|
54
|
+
...(role === "assistant"
|
|
55
|
+
? {
|
|
56
|
+
metadata: {
|
|
57
|
+
unstable_state: null,
|
|
58
|
+
unstable_annotations: [],
|
|
59
|
+
unstable_data: [],
|
|
60
|
+
steps: [],
|
|
61
|
+
custom: {},
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
: {}),
|
|
65
|
+
} as ThreadMessage;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function createBranchedHistory() {
|
|
69
|
+
const user = createHistoryMessage("user", "user", "Question");
|
|
70
|
+
const firstAssistant = createHistoryMessage(
|
|
71
|
+
"assistant-a",
|
|
72
|
+
"assistant",
|
|
73
|
+
"First answer",
|
|
74
|
+
);
|
|
75
|
+
const secondAssistant = createHistoryMessage(
|
|
76
|
+
"assistant-b",
|
|
77
|
+
"assistant",
|
|
78
|
+
"Second answer",
|
|
79
|
+
);
|
|
80
|
+
const history = {
|
|
81
|
+
load: vi.fn().mockResolvedValue({
|
|
82
|
+
headId: secondAssistant.id,
|
|
83
|
+
messages: [
|
|
84
|
+
{ parentId: null, message: user },
|
|
85
|
+
{ parentId: user.id, message: firstAssistant },
|
|
86
|
+
{ parentId: user.id, message: secondAssistant },
|
|
87
|
+
],
|
|
88
|
+
}),
|
|
89
|
+
append: vi.fn().mockResolvedValue(undefined),
|
|
90
|
+
};
|
|
91
|
+
return { user, firstAssistant, secondAssistant, history };
|
|
92
|
+
}
|
|
93
|
+
|
|
43
94
|
function statusUpdateEvent(state: string, text?: string): A2AStreamEvent {
|
|
44
95
|
return {
|
|
45
96
|
type: "statusUpdate",
|
|
@@ -119,6 +170,203 @@ describe("A2AThreadRuntimeCore", () => {
|
|
|
119
170
|
});
|
|
120
171
|
});
|
|
121
172
|
|
|
173
|
+
describe("history loading", () => {
|
|
174
|
+
it("preserves sibling branches and selects the persisted head", async () => {
|
|
175
|
+
const { user, firstAssistant, secondAssistant, history } =
|
|
176
|
+
createBranchedHistory();
|
|
177
|
+
const core = createCore({}, { history });
|
|
178
|
+
|
|
179
|
+
await core.__internal_load();
|
|
180
|
+
|
|
181
|
+
expect(core.getMessages().map((message) => message.id)).toEqual([
|
|
182
|
+
user.id,
|
|
183
|
+
secondAssistant.id,
|
|
184
|
+
]);
|
|
185
|
+
expect(
|
|
186
|
+
core.getMessageRepository().messages.map(({ message, parentId }) => ({
|
|
187
|
+
id: message.id,
|
|
188
|
+
parentId,
|
|
189
|
+
})),
|
|
190
|
+
).toEqual([
|
|
191
|
+
{ id: user.id, parentId: null },
|
|
192
|
+
{ id: firstAssistant.id, parentId: user.id },
|
|
193
|
+
{ id: secondAssistant.id, parentId: user.id },
|
|
194
|
+
]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("falls back to linear history when stored parents are invalid", async () => {
|
|
198
|
+
const user = createHistoryMessage("user", "user", "Question");
|
|
199
|
+
const assistant = createHistoryMessage(
|
|
200
|
+
"assistant",
|
|
201
|
+
"assistant",
|
|
202
|
+
"Answer",
|
|
203
|
+
);
|
|
204
|
+
const history = {
|
|
205
|
+
load: vi.fn().mockResolvedValue({
|
|
206
|
+
headId: assistant.id,
|
|
207
|
+
messages: [
|
|
208
|
+
{ parentId: "missing", message: user },
|
|
209
|
+
{ parentId: user.id, message: assistant },
|
|
210
|
+
],
|
|
211
|
+
}),
|
|
212
|
+
append: vi.fn().mockResolvedValue(undefined),
|
|
213
|
+
};
|
|
214
|
+
const core = createCore({}, { history });
|
|
215
|
+
|
|
216
|
+
await expect(core.__internal_load()).resolves.toBeUndefined();
|
|
217
|
+
|
|
218
|
+
expect(core.getMessages().map((message) => message.id)).toEqual([
|
|
219
|
+
user.id,
|
|
220
|
+
assistant.id,
|
|
221
|
+
]);
|
|
222
|
+
expect(
|
|
223
|
+
core.getMessageRepository().messages.map(({ message, parentId }) => ({
|
|
224
|
+
id: message.id,
|
|
225
|
+
parentId,
|
|
226
|
+
})),
|
|
227
|
+
).toEqual([
|
|
228
|
+
{ id: user.id, parentId: null },
|
|
229
|
+
{ id: assistant.id, parentId: user.id },
|
|
230
|
+
]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("falls back to linear history when stored ids are duplicated", async () => {
|
|
234
|
+
const first = createHistoryMessage("duplicate", "user", "First");
|
|
235
|
+
const replacement = createHistoryMessage(
|
|
236
|
+
"duplicate",
|
|
237
|
+
"assistant",
|
|
238
|
+
"Replacement",
|
|
239
|
+
);
|
|
240
|
+
const tail = createHistoryMessage("tail", "user", "Tail");
|
|
241
|
+
const history = {
|
|
242
|
+
load: vi.fn().mockResolvedValue({
|
|
243
|
+
headId: tail.id,
|
|
244
|
+
messages: [
|
|
245
|
+
{ parentId: null, message: first },
|
|
246
|
+
{ parentId: null, message: replacement },
|
|
247
|
+
{ parentId: replacement.id, message: tail },
|
|
248
|
+
],
|
|
249
|
+
}),
|
|
250
|
+
append: vi.fn().mockResolvedValue(undefined),
|
|
251
|
+
};
|
|
252
|
+
const core = createCore({}, { history });
|
|
253
|
+
|
|
254
|
+
await expect(core.__internal_load()).resolves.toBeUndefined();
|
|
255
|
+
|
|
256
|
+
const repository = core.getMessageRepository();
|
|
257
|
+
expect(
|
|
258
|
+
repository.messages.map(({ message, parentId }) => ({
|
|
259
|
+
id: message.id,
|
|
260
|
+
parentId,
|
|
261
|
+
})),
|
|
262
|
+
).toEqual([
|
|
263
|
+
{ id: replacement.id, parentId: null },
|
|
264
|
+
{ id: tail.id, parentId: replacement.id },
|
|
265
|
+
]);
|
|
266
|
+
expect(repository.messages[0]!.message.content).toEqual(
|
|
267
|
+
replacement.content,
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("falls back to linear history when stored parents form a cycle", async () => {
|
|
272
|
+
const first = createHistoryMessage("first", "user", "First");
|
|
273
|
+
const second = createHistoryMessage("second", "assistant", "Second");
|
|
274
|
+
const history = {
|
|
275
|
+
load: vi.fn().mockResolvedValue({
|
|
276
|
+
headId: second.id,
|
|
277
|
+
messages: [
|
|
278
|
+
{ parentId: second.id, message: first },
|
|
279
|
+
{ parentId: first.id, message: second },
|
|
280
|
+
],
|
|
281
|
+
}),
|
|
282
|
+
append: vi.fn().mockResolvedValue(undefined),
|
|
283
|
+
};
|
|
284
|
+
const core = createCore({}, { history });
|
|
285
|
+
|
|
286
|
+
await expect(core.__internal_load()).resolves.toBeUndefined();
|
|
287
|
+
|
|
288
|
+
expect(
|
|
289
|
+
core.getMessageRepository().messages.map(({ message, parentId }) => ({
|
|
290
|
+
id: message.id,
|
|
291
|
+
parentId,
|
|
292
|
+
})),
|
|
293
|
+
).toEqual([
|
|
294
|
+
{ id: first.id, parentId: null },
|
|
295
|
+
{ id: second.id, parentId: first.id },
|
|
296
|
+
]);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("keeps hidden siblings when the visible branch changes", async () => {
|
|
300
|
+
const { user, firstAssistant, secondAssistant, history } =
|
|
301
|
+
createBranchedHistory();
|
|
302
|
+
const core = createCore({}, { history });
|
|
303
|
+
|
|
304
|
+
await core.__internal_load();
|
|
305
|
+
core.applyExternalMessages([user, firstAssistant]);
|
|
306
|
+
|
|
307
|
+
expect(core.getMessages().map((message) => message.id)).toEqual([
|
|
308
|
+
user.id,
|
|
309
|
+
firstAssistant.id,
|
|
310
|
+
]);
|
|
311
|
+
expect(core.getMessageRepository().headId).toBe(firstAssistant.id);
|
|
312
|
+
expect(
|
|
313
|
+
core.getMessageRepository().messages.map(({ message }) => message.id),
|
|
314
|
+
).toEqual([user.id, firstAssistant.id, secondAssistant.id]);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("replaces stored branches when external messages change parentage", async () => {
|
|
318
|
+
const { secondAssistant, history } = createBranchedHistory();
|
|
319
|
+
const core = createCore({}, { history });
|
|
320
|
+
|
|
321
|
+
await core.__internal_load();
|
|
322
|
+
core.applyExternalMessages([secondAssistant]);
|
|
323
|
+
|
|
324
|
+
expect(core.getMessages().map((message) => message.id)).toEqual([
|
|
325
|
+
secondAssistant.id,
|
|
326
|
+
]);
|
|
327
|
+
expect(
|
|
328
|
+
core.getMessageRepository().messages.map(({ message, parentId }) => ({
|
|
329
|
+
id: message.id,
|
|
330
|
+
parentId,
|
|
331
|
+
})),
|
|
332
|
+
).toEqual([{ id: secondAssistant.id, parentId: null }]);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("adds regenerated responses without dropping loaded siblings", async () => {
|
|
336
|
+
const { user, firstAssistant, secondAssistant, history } =
|
|
337
|
+
createBranchedHistory();
|
|
338
|
+
const core = createCore(
|
|
339
|
+
{
|
|
340
|
+
streamMessage: vi.fn().mockImplementation(async function* () {
|
|
341
|
+
yield statusUpdateEvent("completed", "Regenerated answer");
|
|
342
|
+
}),
|
|
343
|
+
},
|
|
344
|
+
{ history },
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
await core.__internal_load();
|
|
348
|
+
await core.reload(user.id);
|
|
349
|
+
|
|
350
|
+
const visibleMessages = core.getMessages();
|
|
351
|
+
const regenerated = visibleMessages[1]!;
|
|
352
|
+
expect(visibleMessages.map((message) => message.id)).toEqual([
|
|
353
|
+
user.id,
|
|
354
|
+
regenerated.id,
|
|
355
|
+
]);
|
|
356
|
+
expect(regenerated.content).toEqual([
|
|
357
|
+
{ type: "text", text: "Regenerated answer" },
|
|
358
|
+
]);
|
|
359
|
+
expect(
|
|
360
|
+
core.getMessageRepository().messages.map(({ message }) => message.id),
|
|
361
|
+
).toEqual([
|
|
362
|
+
user.id,
|
|
363
|
+
firstAssistant.id,
|
|
364
|
+
secondAssistant.id,
|
|
365
|
+
regenerated.id,
|
|
366
|
+
]);
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
122
370
|
// --- Edit & Reload ---
|
|
123
371
|
|
|
124
372
|
describe("edit", () => {
|
|
@@ -136,6 +384,80 @@ describe("A2AThreadRuntimeCore", () => {
|
|
|
136
384
|
expect(messages[0]!.role).toBe("user");
|
|
137
385
|
expect(messages[1]!.role).toBe("assistant");
|
|
138
386
|
});
|
|
387
|
+
|
|
388
|
+
it("keeps the replaced message subtree as a sibling branch", async () => {
|
|
389
|
+
const root = createHistoryMessage("root", "user", "First question");
|
|
390
|
+
const parent = createHistoryMessage(
|
|
391
|
+
"parent",
|
|
392
|
+
"assistant",
|
|
393
|
+
"First answer",
|
|
394
|
+
);
|
|
395
|
+
const source = createHistoryMessage(
|
|
396
|
+
"source",
|
|
397
|
+
"user",
|
|
398
|
+
"Original follow-up",
|
|
399
|
+
);
|
|
400
|
+
const child = createHistoryMessage(
|
|
401
|
+
"child",
|
|
402
|
+
"assistant",
|
|
403
|
+
"Original response",
|
|
404
|
+
);
|
|
405
|
+
const sibling = createHistoryMessage(
|
|
406
|
+
"sibling",
|
|
407
|
+
"user",
|
|
408
|
+
"Alternate follow-up",
|
|
409
|
+
);
|
|
410
|
+
const history = {
|
|
411
|
+
load: vi.fn().mockResolvedValue({
|
|
412
|
+
headId: child.id,
|
|
413
|
+
messages: [
|
|
414
|
+
{ parentId: null, message: root },
|
|
415
|
+
{ parentId: root.id, message: parent },
|
|
416
|
+
{ parentId: parent.id, message: source },
|
|
417
|
+
{ parentId: source.id, message: child },
|
|
418
|
+
{ parentId: parent.id, message: sibling },
|
|
419
|
+
],
|
|
420
|
+
}),
|
|
421
|
+
append: vi.fn().mockResolvedValue(undefined),
|
|
422
|
+
};
|
|
423
|
+
const core = createCore({}, { history });
|
|
424
|
+
|
|
425
|
+
await core.__internal_load();
|
|
426
|
+
await core.edit({
|
|
427
|
+
...createUserAppendMessage("Edited follow-up"),
|
|
428
|
+
parentId: parent.id,
|
|
429
|
+
sourceId: source.id,
|
|
430
|
+
startRun: false,
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
const edited = core.getMessages().at(-1)!;
|
|
434
|
+
expect(core.getMessages().map((message) => message.id)).toEqual([
|
|
435
|
+
root.id,
|
|
436
|
+
parent.id,
|
|
437
|
+
edited.id,
|
|
438
|
+
]);
|
|
439
|
+
expect(
|
|
440
|
+
core.getMessageRepository().messages.map(({ message, parentId }) => ({
|
|
441
|
+
id: message.id,
|
|
442
|
+
parentId,
|
|
443
|
+
})),
|
|
444
|
+
).toEqual([
|
|
445
|
+
{ id: root.id, parentId: null },
|
|
446
|
+
{ id: parent.id, parentId: root.id },
|
|
447
|
+
{ id: source.id, parentId: parent.id },
|
|
448
|
+
{ id: child.id, parentId: source.id },
|
|
449
|
+
{ id: sibling.id, parentId: parent.id },
|
|
450
|
+
{ id: edited.id, parentId: parent.id },
|
|
451
|
+
]);
|
|
452
|
+
|
|
453
|
+
core.applyExternalMessages([root, parent, source, child]);
|
|
454
|
+
expect(core.getMessages().map((message) => message.id)).toEqual([
|
|
455
|
+
root.id,
|
|
456
|
+
parent.id,
|
|
457
|
+
source.id,
|
|
458
|
+
child.id,
|
|
459
|
+
]);
|
|
460
|
+
});
|
|
139
461
|
});
|
|
140
462
|
|
|
141
463
|
describe("reload", () => {
|
|
@@ -690,3 +1012,139 @@ describe("A2AThreadRuntimeCore", () => {
|
|
|
690
1012
|
});
|
|
691
1013
|
});
|
|
692
1014
|
});
|
|
1015
|
+
|
|
1016
|
+
describe("outbound message conversion", () => {
|
|
1017
|
+
function createCoreWithStream() {
|
|
1018
|
+
const streamMessage = vi.fn().mockImplementation(async function* () {});
|
|
1019
|
+
const core = new A2AThreadRuntimeCore({
|
|
1020
|
+
client: createMockClient({ streamMessage }),
|
|
1021
|
+
notifyUpdate: vi.fn() as unknown as () => void,
|
|
1022
|
+
});
|
|
1023
|
+
return { core, streamMessage };
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
it("forwards attachment content to the wire with the attachment MIME type", async () => {
|
|
1027
|
+
const { core, streamMessage } = createCoreWithStream();
|
|
1028
|
+
|
|
1029
|
+
await core.append({
|
|
1030
|
+
parentId: null,
|
|
1031
|
+
role: "user",
|
|
1032
|
+
content: [{ type: "text", text: "See attached" }],
|
|
1033
|
+
attachments: [
|
|
1034
|
+
{
|
|
1035
|
+
id: "att-1",
|
|
1036
|
+
type: "document",
|
|
1037
|
+
name: "doc.pdf",
|
|
1038
|
+
contentType: "application/pdf",
|
|
1039
|
+
status: { type: "complete" },
|
|
1040
|
+
content: [
|
|
1041
|
+
{
|
|
1042
|
+
type: "file",
|
|
1043
|
+
data: "https://files.com/doc.pdf",
|
|
1044
|
+
mimeType: "",
|
|
1045
|
+
filename: "doc.pdf",
|
|
1046
|
+
},
|
|
1047
|
+
],
|
|
1048
|
+
},
|
|
1049
|
+
],
|
|
1050
|
+
} as unknown as AppendMessage);
|
|
1051
|
+
|
|
1052
|
+
const sent = streamMessage.mock.calls[0]![0] as A2AMessage;
|
|
1053
|
+
expect(sent.parts).toEqual([
|
|
1054
|
+
{ text: "See attached" },
|
|
1055
|
+
{
|
|
1056
|
+
url: "https://files.com/doc.pdf",
|
|
1057
|
+
mediaType: "application/pdf",
|
|
1058
|
+
filename: "doc.pdf",
|
|
1059
|
+
},
|
|
1060
|
+
]);
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
it("forwards file parts in message content to the wire", async () => {
|
|
1064
|
+
const { core, streamMessage } = createCoreWithStream();
|
|
1065
|
+
|
|
1066
|
+
await core.append({
|
|
1067
|
+
parentId: null,
|
|
1068
|
+
role: "user",
|
|
1069
|
+
content: [
|
|
1070
|
+
{
|
|
1071
|
+
type: "file",
|
|
1072
|
+
data: "data:text/csv;base64,ZmlsZQ==",
|
|
1073
|
+
mimeType: "text/csv",
|
|
1074
|
+
},
|
|
1075
|
+
],
|
|
1076
|
+
} as unknown as AppendMessage);
|
|
1077
|
+
|
|
1078
|
+
const sent = streamMessage.mock.calls[0]![0] as A2AMessage;
|
|
1079
|
+
expect(sent.parts).toEqual([{ raw: "ZmlsZQ==", mediaType: "text/csv" }]);
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
it("does not throw for user messages missing attachments at runtime", async () => {
|
|
1083
|
+
const { core, streamMessage } = createCoreWithStream();
|
|
1084
|
+
const message = {
|
|
1085
|
+
id: "u1",
|
|
1086
|
+
role: "user",
|
|
1087
|
+
createdAt: new Date(),
|
|
1088
|
+
content: [{ type: "text", text: "Loaded" }],
|
|
1089
|
+
status: { type: "complete", reason: "stop" },
|
|
1090
|
+
} as unknown as ThreadMessage;
|
|
1091
|
+
|
|
1092
|
+
core.applyExternalMessages([message]);
|
|
1093
|
+
await core.reload("u1");
|
|
1094
|
+
|
|
1095
|
+
const sent = streamMessage.mock.calls[0]![0] as A2AMessage;
|
|
1096
|
+
expect(sent.parts).toEqual([{ text: "Loaded" }]);
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
it("forwards data URL image attachments as raw bytes", async () => {
|
|
1100
|
+
const { core, streamMessage } = createCoreWithStream();
|
|
1101
|
+
|
|
1102
|
+
await core.append({
|
|
1103
|
+
parentId: null,
|
|
1104
|
+
role: "user",
|
|
1105
|
+
content: [{ type: "text", text: "See image" }],
|
|
1106
|
+
attachments: [
|
|
1107
|
+
{
|
|
1108
|
+
id: "att-2",
|
|
1109
|
+
type: "image",
|
|
1110
|
+
name: "a.png",
|
|
1111
|
+
contentType: "image/png",
|
|
1112
|
+
status: { type: "complete" },
|
|
1113
|
+
content: [{ type: "image", image: "data:image/png;base64,aGVsbG8=" }],
|
|
1114
|
+
},
|
|
1115
|
+
],
|
|
1116
|
+
} as unknown as AppendMessage);
|
|
1117
|
+
|
|
1118
|
+
const sent = streamMessage.mock.calls[0]![0] as A2AMessage;
|
|
1119
|
+
expect(sent.parts).toEqual([
|
|
1120
|
+
{ text: "See image" },
|
|
1121
|
+
{ raw: "aGVsbG8=", mediaType: "image/png" },
|
|
1122
|
+
]);
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
it("does not throw for attachments missing content at runtime", async () => {
|
|
1126
|
+
const { core, streamMessage } = createCoreWithStream();
|
|
1127
|
+
const message = {
|
|
1128
|
+
id: "u2",
|
|
1129
|
+
role: "user",
|
|
1130
|
+
createdAt: new Date(),
|
|
1131
|
+
content: [{ type: "text", text: "Loaded" }],
|
|
1132
|
+
attachments: [
|
|
1133
|
+
{
|
|
1134
|
+
id: "att-3",
|
|
1135
|
+
type: "file",
|
|
1136
|
+
name: "x.txt",
|
|
1137
|
+
contentType: "text/plain",
|
|
1138
|
+
status: { type: "complete" },
|
|
1139
|
+
},
|
|
1140
|
+
],
|
|
1141
|
+
status: { type: "complete", reason: "stop" },
|
|
1142
|
+
} as unknown as ThreadMessage;
|
|
1143
|
+
|
|
1144
|
+
core.applyExternalMessages([message]);
|
|
1145
|
+
await core.reload("u2");
|
|
1146
|
+
|
|
1147
|
+
const sent = streamMessage.mock.calls[0]![0] as A2AMessage;
|
|
1148
|
+
expect(sent.parts).toEqual([{ text: "Loaded" }]);
|
|
1149
|
+
});
|
|
1150
|
+
});
|