@assistant-ui/react-a2a 0.2.36 → 0.2.37

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.
@@ -1,9 +1,14 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ applyA2uiOperations,
4
+ convertSurfaceToUISpec,
5
+ } from "@assistant-ui/react-generative-ui/a2ui";
2
6
  import { A2AThreadRuntimeCore } from "./A2AThreadRuntimeCore";
3
7
  import type { A2AClient } from "./A2AClient";
4
8
  import type {
5
9
  A2AAgentCard,
6
10
  A2AMessage,
11
+ A2APart,
7
12
  A2AStreamEvent,
8
13
  A2ATask,
9
14
  } from "./types";
@@ -100,7 +105,11 @@ function createBranchedHistory() {
100
105
  return { user, firstAssistant, secondAssistant, history };
101
106
  }
102
107
 
103
- function statusUpdateEvent(state: string, text?: string): A2AStreamEvent {
108
+ function statusUpdateEvent(
109
+ state: string,
110
+ text?: string,
111
+ parts?: A2APart[],
112
+ ): A2AStreamEvent {
104
113
  return {
105
114
  type: "statusUpdate",
106
115
  event: {
@@ -108,13 +117,15 @@ function statusUpdateEvent(state: string, text?: string): A2AStreamEvent {
108
117
  contextId: "ctx-1",
109
118
  status: {
110
119
  state: state as any,
111
- ...(text && {
112
- message: {
113
- messageId: "s1",
114
- role: "agent" as const,
115
- parts: [{ text }],
116
- },
117
- }),
120
+ ...(text !== undefined || parts !== undefined
121
+ ? {
122
+ message: {
123
+ messageId: "s1",
124
+ role: "agent" as const,
125
+ parts: parts ?? [{ text }],
126
+ },
127
+ }
128
+ : {}),
118
129
  },
119
130
  },
120
131
  };
@@ -122,7 +133,7 @@ function statusUpdateEvent(state: string, text?: string): A2AStreamEvent {
122
133
 
123
134
  function artifactUpdateEvent(
124
135
  artifactId: string,
125
- parts: { text: string }[],
136
+ parts: A2APart[],
126
137
  opts: { append?: boolean; lastChunk?: boolean } = {},
127
138
  ): A2AStreamEvent {
128
139
  return {
@@ -137,6 +148,12 @@ function artifactUpdateEvent(
137
148
  };
138
149
  }
139
150
 
151
+ const toolInteraction = {
152
+ type: "action" as const,
153
+ occurredAt: 1_700_000_000_000,
154
+ payload: { value: "selected" },
155
+ };
156
+
140
157
  describe("A2AThreadRuntimeCore", () => {
141
158
  let notifyUpdate: ReturnType<typeof vi.fn>;
142
159
 
@@ -273,6 +290,35 @@ describe("A2AThreadRuntimeCore", () => {
273
290
  });
274
291
 
275
292
  describe("history loading", () => {
293
+ it("loads persisted history without waiting for agent-card discovery", async () => {
294
+ const restored = createHistoryMessage(
295
+ "restored",
296
+ "user",
297
+ "Saved history",
298
+ );
299
+ const history = {
300
+ load: vi.fn().mockResolvedValue({
301
+ headId: restored.id,
302
+ messages: [{ parentId: null, message: restored }],
303
+ }),
304
+ append: vi.fn().mockResolvedValue(undefined),
305
+ };
306
+ const getAgentCard = vi.fn(() => new Promise<A2AAgentCard>(() => {}));
307
+ const core = createCore({ getAgentCard }, { history });
308
+
309
+ const result = await Promise.race([
310
+ core.__internal_load().then(() => "loaded"),
311
+ new Promise<"timed out">((resolve) => {
312
+ setTimeout(() => resolve("timed out"), 0);
313
+ }),
314
+ ]);
315
+
316
+ expect(result).toBe("loaded");
317
+ expect(getAgentCard).toHaveBeenCalledOnce();
318
+ expect(core.getMessages()).toEqual([restored]);
319
+ expect(core.isLoading).toBe(false);
320
+ });
321
+
276
322
  it("keeps initial history loading across same-thread message resyncs", async () => {
277
323
  let resolve!: (repo: ExportedMessageRepository) => void;
278
324
  const pending = new Promise<ExportedMessageRepository>((res) => {
@@ -907,6 +953,476 @@ describe("A2AThreadRuntimeCore", () => {
907
953
  expect(wasRunningDuringStream).toBe(true);
908
954
  expect(core.isRunning()).toBe(false);
909
955
  });
956
+
957
+ it("rebuilds an A2UI surface across status updates with replayable operations", async () => {
958
+ const core = createCore({
959
+ streamMessage: vi.fn().mockImplementation(async function* () {
960
+ yield statusUpdateEvent("working", undefined, [
961
+ {
962
+ data: [
963
+ {
964
+ version: "v0.9",
965
+ createSurface: { surfaceId: "summary" },
966
+ },
967
+ ],
968
+ },
969
+ ]);
970
+ yield statusUpdateEvent("completed", undefined, [
971
+ {
972
+ data: [
973
+ {
974
+ version: "v0.9",
975
+ updateComponents: {
976
+ surfaceId: "summary",
977
+ components: [
978
+ {
979
+ id: "root",
980
+ component: "Text",
981
+ text: { path: "/summary" },
982
+ },
983
+ ],
984
+ },
985
+ },
986
+ {
987
+ version: "v0.9",
988
+ updateDataModel: {
989
+ surfaceId: "summary",
990
+ contents: { summary: "Ready" },
991
+ },
992
+ },
993
+ ],
994
+ },
995
+ ]);
996
+ }),
997
+ });
998
+
999
+ await core.append(createUserAppendMessage("Go"));
1000
+
1001
+ const assistant = core.getMessages()[1]!;
1002
+ expect(assistant.content).toHaveLength(1);
1003
+ const part = assistant.content[0]!;
1004
+ if (part.type !== "tool-call") throw new Error("expected A2UI tool call");
1005
+ expect(part).toMatchObject({
1006
+ toolCallId: "a2ui:summary",
1007
+ toolName: "present",
1008
+ result: {},
1009
+ });
1010
+
1011
+ const artifact = part.artifact as { a2ui: unknown };
1012
+ const replayed = applyA2uiOperations(new Map(), artifact.a2ui);
1013
+ const surface = replayed.state.get("summary");
1014
+ expect(replayed.warnings).toEqual([]);
1015
+ expect(surface).toBeDefined();
1016
+ expect(convertSurfaceToUISpec(surface!).spec).toEqual(part.args);
1017
+ });
1018
+
1019
+ it("keeps a recorded A2UI interaction through later stream updates and writes it when settled", async () => {
1020
+ let release!: () => void;
1021
+ const continueStream = new Promise<void>((resolve) => {
1022
+ release = resolve;
1023
+ });
1024
+ const history = {
1025
+ load: vi.fn().mockResolvedValue({ messages: [] }),
1026
+ append: vi.fn().mockResolvedValue(undefined),
1027
+ };
1028
+ const core = createCore(
1029
+ {
1030
+ streamMessage: vi.fn().mockImplementation(async function* () {
1031
+ yield statusUpdateEvent("working", undefined, [
1032
+ {
1033
+ data: [
1034
+ {
1035
+ version: "v0.9",
1036
+ createSurface: { surfaceId: "summary" },
1037
+ },
1038
+ {
1039
+ version: "v0.9",
1040
+ updateComponents: {
1041
+ surfaceId: "summary",
1042
+ components: [
1043
+ {
1044
+ id: "root",
1045
+ component: "Text",
1046
+ text: "Ready",
1047
+ },
1048
+ ],
1049
+ },
1050
+ },
1051
+ ],
1052
+ },
1053
+ ]);
1054
+ await continueStream;
1055
+ yield statusUpdateEvent("working", "Status update");
1056
+ yield artifactUpdateEvent("a1", [
1057
+ {
1058
+ data: [
1059
+ {
1060
+ version: "v0.9",
1061
+ updateDataModel: {
1062
+ surfaceId: "summary",
1063
+ contents: { summary: "Updated" },
1064
+ },
1065
+ },
1066
+ ],
1067
+ },
1068
+ ]);
1069
+ yield statusUpdateEvent("completed", "Done");
1070
+ }),
1071
+ },
1072
+ { history },
1073
+ );
1074
+
1075
+ const run = core.append(createUserAppendMessage("Go"));
1076
+ await vi.waitFor(() => {
1077
+ const part = core.getMessages()[1]?.content[0];
1078
+ expect(part).toMatchObject({
1079
+ type: "tool-call",
1080
+ toolCallId: "a2ui:summary",
1081
+ });
1082
+ });
1083
+
1084
+ const assistantId = core.getMessages()[1]!.id;
1085
+ await core.recordToolInteraction({
1086
+ messageId: assistantId,
1087
+ toolCallId: "a2ui:summary",
1088
+ interaction: toolInteraction,
1089
+ });
1090
+ expect(core.getMessages()[1]!.content[0]).toMatchObject({
1091
+ unstable_interactions: { entries: [toolInteraction] },
1092
+ });
1093
+
1094
+ release();
1095
+ await run;
1096
+
1097
+ await vi.waitFor(() => expect(history.append).toHaveBeenCalledTimes(2));
1098
+ const persisted = history.append.mock.calls[1]![0].message;
1099
+ const part = persisted.content.find(
1100
+ (content: { type: string; toolCallId?: string }) =>
1101
+ content.type === "tool-call" && content.toolCallId === "a2ui:summary",
1102
+ );
1103
+ expect(part).toMatchObject({
1104
+ unstable_interactions: { entries: [toolInteraction] },
1105
+ });
1106
+ });
1107
+
1108
+ it("rebuilds A2UI state from a task snapshot in full-state order", async () => {
1109
+ const core = createCore({
1110
+ streamMessage: vi.fn().mockImplementation(async function* () {
1111
+ yield statusUpdateEvent("working", undefined, [
1112
+ {
1113
+ data: [
1114
+ {
1115
+ version: "v0.9",
1116
+ createSurface: { surfaceId: "obsolete" },
1117
+ },
1118
+ {
1119
+ version: "v0.9",
1120
+ updateComponents: {
1121
+ surfaceId: "obsolete",
1122
+ components: [
1123
+ { id: "root", component: "Text", text: "Obsolete" },
1124
+ ],
1125
+ },
1126
+ },
1127
+ ],
1128
+ },
1129
+ ]);
1130
+ yield {
1131
+ type: "artifactUpdate",
1132
+ event: {
1133
+ taskId: "t1",
1134
+ contextId: "ctx-1",
1135
+ artifact: {
1136
+ artifactId: "a1",
1137
+ parts: [
1138
+ {
1139
+ data: [
1140
+ {
1141
+ version: "v0.9",
1142
+ updateDataModel: {
1143
+ surfaceId: "obsolete",
1144
+ contents: { summary: "Stale artifact event" },
1145
+ },
1146
+ },
1147
+ ],
1148
+ },
1149
+ ],
1150
+ },
1151
+ },
1152
+ } satisfies A2AStreamEvent;
1153
+ yield {
1154
+ type: "task",
1155
+ task: {
1156
+ id: "t1",
1157
+ contextId: "ctx-1",
1158
+ status: {
1159
+ state: "completed",
1160
+ message: {
1161
+ messageId: "s1",
1162
+ role: "agent",
1163
+ parts: [
1164
+ {
1165
+ data: [
1166
+ {
1167
+ version: "v0.9",
1168
+ updateDataModel: {
1169
+ surfaceId: "summary",
1170
+ path: "/summary",
1171
+ contents: "From task status",
1172
+ },
1173
+ },
1174
+ ],
1175
+ },
1176
+ ],
1177
+ },
1178
+ },
1179
+ history: [
1180
+ {
1181
+ messageId: "h1",
1182
+ role: "agent",
1183
+ parts: [
1184
+ {
1185
+ data: [
1186
+ {
1187
+ version: "v0.9",
1188
+ createSurface: { surfaceId: "summary" },
1189
+ },
1190
+ {
1191
+ version: "v0.9",
1192
+ updateComponents: {
1193
+ surfaceId: "summary",
1194
+ components: [
1195
+ {
1196
+ id: "root",
1197
+ component: "Text",
1198
+ text: { path: "/summary" },
1199
+ },
1200
+ ],
1201
+ },
1202
+ },
1203
+ ],
1204
+ },
1205
+ ],
1206
+ },
1207
+ ],
1208
+ artifacts: [
1209
+ {
1210
+ artifactId: "a1",
1211
+ parts: [
1212
+ {
1213
+ data: [
1214
+ {
1215
+ version: "v0.9",
1216
+ updateDataModel: {
1217
+ surfaceId: "summary",
1218
+ path: "/summary",
1219
+ contents: "From task artifact",
1220
+ },
1221
+ },
1222
+ ],
1223
+ },
1224
+ ],
1225
+ },
1226
+ ],
1227
+ },
1228
+ } satisfies A2AStreamEvent;
1229
+ }),
1230
+ });
1231
+
1232
+ await core.append(createUserAppendMessage("Go"));
1233
+
1234
+ expect(core.getMessages()[1]!.content).toHaveLength(1);
1235
+ const part = core.getMessages()[1]!.content[0]!;
1236
+ if (part.type !== "tool-call") throw new Error("expected A2UI tool call");
1237
+ const replayed = applyA2uiOperations(
1238
+ new Map(),
1239
+ (part.artifact as { a2ui: unknown }).a2ui,
1240
+ );
1241
+ expect(replayed.state.get("summary")?.dataModel).toEqual({
1242
+ summary: "From task status",
1243
+ });
1244
+ expect(replayed.state.has("obsolete")).toBe(false);
1245
+ });
1246
+
1247
+ it("keeps an A2UI surface when a partial task snapshot repeats an artifact", async () => {
1248
+ const core = createCore({
1249
+ streamMessage: vi.fn().mockImplementation(async function* () {
1250
+ yield artifactUpdateEvent("a1", [
1251
+ {
1252
+ data: [
1253
+ {
1254
+ version: "v0.9",
1255
+ createSurface: { surfaceId: "summary" },
1256
+ },
1257
+ ],
1258
+ },
1259
+ ]);
1260
+ yield statusUpdateEvent("working", undefined, [
1261
+ {
1262
+ data: [
1263
+ {
1264
+ version: "v0.9",
1265
+ updateComponents: {
1266
+ surfaceId: "summary",
1267
+ components: [
1268
+ { id: "root", component: "Text", text: "Ready" },
1269
+ ],
1270
+ },
1271
+ },
1272
+ {
1273
+ version: "v0.9",
1274
+ updateDataModel: {
1275
+ surfaceId: "summary",
1276
+ contents: { summary: "Ready" },
1277
+ },
1278
+ },
1279
+ ],
1280
+ },
1281
+ ]);
1282
+ yield {
1283
+ type: "task",
1284
+ task: {
1285
+ id: "t1",
1286
+ contextId: "ctx-1",
1287
+ status: { state: "completed" },
1288
+ artifacts: [
1289
+ {
1290
+ artifactId: "a1",
1291
+ parts: [
1292
+ {
1293
+ data: [
1294
+ {
1295
+ version: "v0.9",
1296
+ createSurface: { surfaceId: "summary" },
1297
+ },
1298
+ ],
1299
+ },
1300
+ ],
1301
+ },
1302
+ ],
1303
+ },
1304
+ } satisfies A2AStreamEvent;
1305
+ }),
1306
+ });
1307
+
1308
+ await core.append(createUserAppendMessage("Go"));
1309
+
1310
+ expect(core.getMessages()[1]!.content).toHaveLength(1);
1311
+ const part = core.getMessages()[1]!.content[0]!;
1312
+ if (part.type !== "tool-call") throw new Error("expected A2UI tool call");
1313
+ const replayed = applyA2uiOperations(
1314
+ new Map(),
1315
+ (part.artifact as { a2ui: unknown }).a2ui,
1316
+ );
1317
+ expect(replayed.state.get("summary")?.dataModel).toEqual({
1318
+ summary: "Ready",
1319
+ });
1320
+ });
1321
+
1322
+ it("does not replay A2UI history already applied when a partial task snapshot carries history", async () => {
1323
+ const created = {
1324
+ messageId: "agent-1",
1325
+ role: "agent" as const,
1326
+ parts: [
1327
+ {
1328
+ data: [
1329
+ { version: "v0.9", createSurface: { surfaceId: "summary" } },
1330
+ ],
1331
+ },
1332
+ ],
1333
+ };
1334
+ const core = createCore({
1335
+ streamMessage: vi.fn().mockImplementation(async function* () {
1336
+ yield { type: "message", message: created } satisfies A2AStreamEvent;
1337
+ yield statusUpdateEvent("working", undefined, [
1338
+ {
1339
+ data: [
1340
+ {
1341
+ version: "v0.9",
1342
+ updateComponents: {
1343
+ surfaceId: "summary",
1344
+ components: [
1345
+ { id: "root", component: "Text", text: "Ready" },
1346
+ ],
1347
+ },
1348
+ },
1349
+ {
1350
+ version: "v0.9",
1351
+ updateDataModel: {
1352
+ surfaceId: "summary",
1353
+ contents: { summary: "Ready" },
1354
+ },
1355
+ },
1356
+ ],
1357
+ },
1358
+ ]);
1359
+ yield {
1360
+ type: "task",
1361
+ task: {
1362
+ id: "t1",
1363
+ contextId: "ctx-1",
1364
+ status: { state: "completed" },
1365
+ history: [created],
1366
+ },
1367
+ } satisfies A2AStreamEvent;
1368
+ }),
1369
+ });
1370
+
1371
+ await core.append(createUserAppendMessage("Go"));
1372
+
1373
+ const part = core
1374
+ .getMessages()[1]!
1375
+ .content.find((candidate) => candidate.type === "tool-call");
1376
+ if (part?.type !== "tool-call")
1377
+ throw new Error("expected A2UI tool call");
1378
+ const replayed = applyA2uiOperations(
1379
+ new Map(),
1380
+ (part.artifact as { a2ui: unknown }).a2ui,
1381
+ );
1382
+ expect(replayed.state.get("summary")?.dataModel).toEqual({
1383
+ summary: "Ready",
1384
+ });
1385
+ });
1386
+
1387
+ it("removes a deleted A2UI surface", async () => {
1388
+ const core = createCore({
1389
+ streamMessage: vi.fn().mockImplementation(async function* () {
1390
+ yield statusUpdateEvent("working", undefined, [
1391
+ {
1392
+ data: [
1393
+ {
1394
+ version: "v0.9",
1395
+ createSurface: { surfaceId: "summary" },
1396
+ },
1397
+ {
1398
+ version: "v0.9",
1399
+ updateComponents: {
1400
+ surfaceId: "summary",
1401
+ components: [
1402
+ { id: "root", component: "Text", text: "Ready" },
1403
+ ],
1404
+ },
1405
+ },
1406
+ ],
1407
+ },
1408
+ ]);
1409
+ yield statusUpdateEvent("completed", undefined, [
1410
+ {
1411
+ data: [
1412
+ {
1413
+ version: "v0.9",
1414
+ deleteSurface: { surfaceId: "summary" },
1415
+ },
1416
+ ],
1417
+ },
1418
+ ]);
1419
+ }),
1420
+ });
1421
+
1422
+ await core.append(createUserAppendMessage("Go"));
1423
+
1424
+ expect(core.getMessages()[1]!.content).toEqual([]);
1425
+ });
910
1426
  });
911
1427
 
912
1428
  // --- Sync (non-streaming) fallback ---
@@ -1176,6 +1692,62 @@ describe("A2AThreadRuntimeCore", () => {
1176
1692
  expect(artifacts[0]!.parts[1]!.text).toBe("part2");
1177
1693
  });
1178
1694
 
1695
+ it("stores merged task artifacts on the assistant message", async () => {
1696
+ const core = createCore({
1697
+ streamMessage: vi.fn().mockImplementation(async function* () {
1698
+ yield artifactUpdateEvent("a1", [{ text: "part1" }]);
1699
+ yield artifactUpdateEvent("a1", [{ text: "part2" }], {
1700
+ append: true,
1701
+ });
1702
+ yield statusUpdateEvent("completed", "Done");
1703
+ }),
1704
+ });
1705
+
1706
+ await core.append(createUserAppendMessage("Go"));
1707
+
1708
+ expect(core.getMessages()[1]!.metadata.custom.a2a).toEqual({
1709
+ artifacts: [
1710
+ {
1711
+ artifactId: "a1",
1712
+ name: "a1",
1713
+ parts: [{ text: "part1" }, { text: "part2" }],
1714
+ },
1715
+ ],
1716
+ });
1717
+ });
1718
+
1719
+ it("stores artifact files on the assistant message without their inline bytes", async () => {
1720
+ const core = createCore({
1721
+ streamMessage: vi.fn().mockImplementation(async function* () {
1722
+ yield artifactUpdateEvent("a1", [
1723
+ {
1724
+ raw: "JVBERi0xLjQK",
1725
+ mediaType: "application/pdf",
1726
+ filename: "report.pdf",
1727
+ },
1728
+ { url: "https://example.com/chart.png", mediaType: "image/png" },
1729
+ ]);
1730
+ yield statusUpdateEvent("completed", "Done");
1731
+ }),
1732
+ });
1733
+
1734
+ await core.append(createUserAppendMessage("Go"));
1735
+
1736
+ expect(core.getArtifacts()[0]!.parts[0]!.raw).toBe("JVBERi0xLjQK");
1737
+ expect(core.getMessages()[1]!.metadata.custom.a2a).toEqual({
1738
+ artifacts: [
1739
+ {
1740
+ artifactId: "a1",
1741
+ name: "a1",
1742
+ parts: [
1743
+ { mediaType: "application/pdf", filename: "report.pdf" },
1744
+ { url: "https://example.com/chart.png", mediaType: "image/png" },
1745
+ ],
1746
+ },
1747
+ ],
1748
+ });
1749
+ });
1750
+
1179
1751
  it("replaces artifact when append=false", async () => {
1180
1752
  const core = createCore({
1181
1753
  streamMessage: vi.fn().mockImplementation(async function* () {
@@ -1331,6 +1903,312 @@ describe("A2AThreadRuntimeCore", () => {
1331
1903
  const secondCallMsg = streamMessage.mock.calls[1]![0] as A2AMessage;
1332
1904
  expect(secondCallMsg.taskId).toBe("t1");
1333
1905
  });
1906
+
1907
+ it("persists a paused assistant message and updates it when it settles", async () => {
1908
+ let release!: () => void;
1909
+ const settled = new Promise<void>((resolve) => {
1910
+ release = resolve;
1911
+ });
1912
+ const stored = new Map<string, ThreadMessage>();
1913
+ const history = {
1914
+ load: vi.fn().mockResolvedValue({ messages: [] }),
1915
+ append: vi.fn(async (item: { message: ThreadMessage }) => {
1916
+ stored.set(item.message.id, item.message);
1917
+ }),
1918
+ update: vi.fn(async (item: { message: ThreadMessage }) => {
1919
+ stored.set(item.message.id, item.message);
1920
+ }),
1921
+ };
1922
+ const core = createCore(
1923
+ {
1924
+ streamMessage: vi.fn().mockImplementation(async function* () {
1925
+ yield statusUpdateEvent("input_required", "What should I do?");
1926
+ await settled;
1927
+ yield statusUpdateEvent("completed", "Done");
1928
+ }),
1929
+ },
1930
+ { history },
1931
+ );
1932
+
1933
+ const run = core.append(createUserAppendMessage("Start"));
1934
+
1935
+ await vi.waitFor(() => expect(history.append).toHaveBeenCalledTimes(2));
1936
+ const paused = history.append.mock.calls[1]![0].message;
1937
+ expect(paused.status).toEqual({
1938
+ type: "requires-action",
1939
+ reason: "interrupt",
1940
+ });
1941
+ expect(paused.content).toEqual([
1942
+ { type: "text", text: "What should I do?" },
1943
+ ]);
1944
+
1945
+ release();
1946
+ await run;
1947
+
1948
+ await vi.waitFor(() => expect(history.update).toHaveBeenCalledOnce());
1949
+ expect(stored.get(paused.id)).toMatchObject({
1950
+ content: [{ type: "text", text: "Done" }],
1951
+ status: { type: "complete", reason: "stop" },
1952
+ });
1953
+ });
1954
+
1955
+ it("appends only the settled assistant message without history updates", async () => {
1956
+ let release!: () => void;
1957
+ const settled = new Promise<void>((resolve) => {
1958
+ release = resolve;
1959
+ });
1960
+ const stored = new Map<string, ThreadMessage>();
1961
+ const history = {
1962
+ load: vi.fn().mockResolvedValue({ messages: [] }),
1963
+ append: vi.fn(async (item: { message: ThreadMessage }) => {
1964
+ stored.set(item.message.id, item.message);
1965
+ }),
1966
+ };
1967
+ const core = createCore(
1968
+ {
1969
+ streamMessage: vi.fn().mockImplementation(async function* () {
1970
+ yield statusUpdateEvent("input_required", "What should I do?");
1971
+ await settled;
1972
+ yield statusUpdateEvent("completed", "Done");
1973
+ }),
1974
+ },
1975
+ { history },
1976
+ );
1977
+
1978
+ const run = core.append(createUserAppendMessage("Start"));
1979
+ await vi.waitFor(() => expect(history.append).toHaveBeenCalledOnce());
1980
+ release();
1981
+ await run;
1982
+ await vi.waitFor(() => expect(history.append).toHaveBeenCalledTimes(2));
1983
+ const assistant = core.getMessages()[1]!;
1984
+ expect(stored.get(assistant.id)).toMatchObject({
1985
+ content: [{ type: "text", text: "Done" }],
1986
+ status: { type: "complete", reason: "stop" },
1987
+ });
1988
+ });
1989
+
1990
+ it("retries a rejected final history update", async () => {
1991
+ let releaseSettlement!: () => void;
1992
+ const settlement = new Promise<void>((resolve) => {
1993
+ releaseSettlement = resolve;
1994
+ });
1995
+ let releaseRetry!: () => void;
1996
+ const retry = new Promise<void>((resolve) => {
1997
+ releaseRetry = resolve;
1998
+ });
1999
+ const error = new Error("history update failed");
2000
+ const stored = new Map<string, ThreadMessage>();
2001
+ const consoleError = vi
2002
+ .spyOn(console, "error")
2003
+ .mockImplementation(() => {});
2004
+ const history = {
2005
+ load: vi.fn().mockResolvedValue({ messages: [] }),
2006
+ append: vi.fn(async (item: { message: ThreadMessage }) => {
2007
+ stored.set(item.message.id, item.message);
2008
+ }),
2009
+ update: vi
2010
+ .fn()
2011
+ .mockRejectedValueOnce(error)
2012
+ .mockImplementation(async (item: { message: ThreadMessage }) => {
2013
+ stored.set(item.message.id, item.message);
2014
+ }),
2015
+ };
2016
+ const core = createCore(
2017
+ {
2018
+ streamMessage: vi.fn().mockImplementation(async function* () {
2019
+ yield statusUpdateEvent("input_required", "What should I do?");
2020
+ await settlement;
2021
+ yield statusUpdateEvent("completed", "Done");
2022
+ await retry;
2023
+ yield statusUpdateEvent("completed", "Done");
2024
+ }),
2025
+ },
2026
+ { history },
2027
+ );
2028
+
2029
+ const run = core.append(createUserAppendMessage("Start"));
2030
+ await vi.waitFor(() => expect(history.append).toHaveBeenCalledTimes(2));
2031
+ releaseSettlement();
2032
+ await vi.waitFor(() => {
2033
+ expect(consoleError).toHaveBeenCalledWith(
2034
+ "[react-a2a] failed to update history entry",
2035
+ error,
2036
+ );
2037
+ });
2038
+ releaseRetry();
2039
+ await run;
2040
+
2041
+ await vi.waitFor(() => expect(history.update).toHaveBeenCalledTimes(2));
2042
+ const assistant = core.getMessages()[1]!;
2043
+ expect(stored.get(assistant.id)).toMatchObject({
2044
+ content: [{ type: "text", text: "Done" }],
2045
+ status: { type: "complete", reason: "stop" },
2046
+ });
2047
+ });
2048
+
2049
+ it("retries a rejected final history append", async () => {
2050
+ let releaseSettlement!: () => void;
2051
+ const settlement = new Promise<void>((resolve) => {
2052
+ releaseSettlement = resolve;
2053
+ });
2054
+ let releaseRetry!: () => void;
2055
+ const retry = new Promise<void>((resolve) => {
2056
+ releaseRetry = resolve;
2057
+ });
2058
+ const error = new Error("history append failed");
2059
+ const stored = new Map<string, ThreadMessage>();
2060
+ const consoleError = vi
2061
+ .spyOn(console, "error")
2062
+ .mockImplementation(() => {});
2063
+ let assistantAppendAttempts = 0;
2064
+ const history = {
2065
+ load: vi.fn().mockResolvedValue({ messages: [] }),
2066
+ append: vi.fn(async (item: { message: ThreadMessage }) => {
2067
+ if (
2068
+ item.message.role === "assistant" &&
2069
+ assistantAppendAttempts++ === 0
2070
+ ) {
2071
+ throw error;
2072
+ }
2073
+ stored.set(item.message.id, item.message);
2074
+ }),
2075
+ };
2076
+ const core = createCore(
2077
+ {
2078
+ streamMessage: vi.fn().mockImplementation(async function* () {
2079
+ yield statusUpdateEvent("input_required", "What should I do?");
2080
+ await settlement;
2081
+ yield statusUpdateEvent("completed", "Done");
2082
+ await retry;
2083
+ yield statusUpdateEvent("completed", "Done");
2084
+ }),
2085
+ },
2086
+ { history },
2087
+ );
2088
+
2089
+ const run = core.append(createUserAppendMessage("Start"));
2090
+ await vi.waitFor(() => expect(history.append).toHaveBeenCalledOnce());
2091
+ releaseSettlement();
2092
+ await vi.waitFor(() => {
2093
+ expect(consoleError).toHaveBeenCalledWith(
2094
+ "[react-a2a] failed to append history entry",
2095
+ error,
2096
+ );
2097
+ });
2098
+ releaseRetry();
2099
+ await run;
2100
+
2101
+ await vi.waitFor(() => expect(history.append).toHaveBeenCalledTimes(3));
2102
+ const assistant = core.getMessages()[1]!;
2103
+ expect(stored.get(assistant.id)).toMatchObject({
2104
+ content: [{ type: "text", text: "Done" }],
2105
+ status: { type: "complete", reason: "stop" },
2106
+ });
2107
+ });
2108
+ });
2109
+
2110
+ describe("tool interactions", () => {
2111
+ it("updates a settled assistant message in history", async () => {
2112
+ const history = {
2113
+ load: vi.fn().mockResolvedValue({ messages: [] }),
2114
+ append: vi.fn().mockResolvedValue(undefined),
2115
+ update: vi.fn().mockResolvedValue(undefined),
2116
+ };
2117
+ const core = createCore(
2118
+ {
2119
+ streamMessage: vi.fn().mockImplementation(async function* () {
2120
+ yield statusUpdateEvent("completed", undefined, [
2121
+ {
2122
+ data: [
2123
+ {
2124
+ version: "v0.9",
2125
+ createSurface: { surfaceId: "summary" },
2126
+ },
2127
+ {
2128
+ version: "v0.9",
2129
+ updateComponents: {
2130
+ surfaceId: "summary",
2131
+ components: [
2132
+ {
2133
+ id: "root",
2134
+ component: "Text",
2135
+ text: "Ready",
2136
+ },
2137
+ ],
2138
+ },
2139
+ },
2140
+ ],
2141
+ },
2142
+ ]);
2143
+ }),
2144
+ },
2145
+ { history },
2146
+ );
2147
+
2148
+ await core.append(createUserAppendMessage("Go"));
2149
+ await vi.waitFor(() => expect(history.append).toHaveBeenCalledTimes(2));
2150
+
2151
+ await core.recordToolInteraction({
2152
+ messageId: core.getMessages()[1]!.id,
2153
+ toolCallId: "a2ui:summary",
2154
+ interaction: toolInteraction,
2155
+ });
2156
+
2157
+ await vi.waitFor(() => expect(history.update).toHaveBeenCalledOnce());
2158
+ expect(history.update.mock.calls[0]![0].parentId).toBe(
2159
+ core.getMessages()[0]!.id,
2160
+ );
2161
+ expect(history.update.mock.calls[0]![0].message.content).toContainEqual(
2162
+ expect.objectContaining({
2163
+ toolCallId: "a2ui:summary",
2164
+ unstable_interactions: { entries: [toolInteraction] },
2165
+ }),
2166
+ );
2167
+ });
2168
+
2169
+ it("rejects an unknown message or tool call", async () => {
2170
+ const core = createCore();
2171
+ core.applyExternalMessages([
2172
+ {
2173
+ id: "assistant",
2174
+ role: "assistant",
2175
+ createdAt: new Date(),
2176
+ status: { type: "complete", reason: "stop" },
2177
+ content: [
2178
+ {
2179
+ type: "tool-call",
2180
+ toolCallId: "known",
2181
+ toolName: "present",
2182
+ args: {},
2183
+ argsText: "{}",
2184
+ result: {},
2185
+ },
2186
+ ],
2187
+ metadata: {
2188
+ unstable_state: null,
2189
+ unstable_annotations: [],
2190
+ unstable_data: [],
2191
+ steps: [],
2192
+ custom: {},
2193
+ },
2194
+ } as ThreadMessage,
2195
+ ]);
2196
+
2197
+ await expect(
2198
+ core.recordToolInteraction({
2199
+ messageId: "missing",
2200
+ toolCallId: "known",
2201
+ interaction: toolInteraction,
2202
+ }),
2203
+ ).rejects.toThrow("non-existing message");
2204
+ await expect(
2205
+ core.recordToolInteraction({
2206
+ messageId: "assistant",
2207
+ toolCallId: "missing",
2208
+ interaction: toolInteraction,
2209
+ }),
2210
+ ).rejects.toThrow("non-existing tool call");
2211
+ });
1334
2212
  });
1335
2213
 
1336
2214
  // --- Task snapshot ---