@assistant-ui/react-a2a 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +47 -1
  2. package/dist/A2AClient.d.ts +43 -0
  3. package/dist/A2AClient.d.ts.map +1 -0
  4. package/dist/A2AClient.js +358 -0
  5. package/dist/A2AClient.js.map +1 -0
  6. package/dist/A2AThreadRuntimeCore.d.ts +75 -0
  7. package/dist/A2AThreadRuntimeCore.d.ts.map +1 -0
  8. package/dist/A2AThreadRuntimeCore.js +483 -0
  9. package/dist/A2AThreadRuntimeCore.js.map +1 -0
  10. package/dist/conversions.d.ts +14 -0
  11. package/dist/conversions.d.ts.map +1 -0
  12. package/dist/conversions.js +92 -0
  13. package/dist/conversions.js.map +1 -0
  14. package/dist/index.d.ts +7 -6
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +7 -6
  17. package/dist/index.js.map +1 -1
  18. package/dist/types.d.ts +228 -84
  19. package/dist/types.d.ts.map +1 -1
  20. package/dist/types.js +4 -9
  21. package/dist/types.js.map +1 -1
  22. package/dist/useA2ARuntime.d.ts +35 -48
  23. package/dist/useA2ARuntime.d.ts.map +1 -1
  24. package/dist/useA2ARuntime.js +126 -172
  25. package/dist/useA2ARuntime.js.map +1 -1
  26. package/package.json +9 -9
  27. package/src/A2AClient.test.ts +773 -0
  28. package/src/A2AClient.ts +519 -0
  29. package/src/A2AThreadRuntimeCore.test.ts +692 -0
  30. package/src/A2AThreadRuntimeCore.ts +633 -0
  31. package/src/conversions.test.ts +276 -0
  32. package/src/conversions.ts +115 -0
  33. package/src/index.ts +66 -6
  34. package/src/types.ts +276 -95
  35. package/src/useA2ARuntime.ts +204 -296
  36. package/dist/A2AMessageAccumulator.d.ts +0 -16
  37. package/dist/A2AMessageAccumulator.d.ts.map +0 -1
  38. package/dist/A2AMessageAccumulator.js +0 -29
  39. package/dist/A2AMessageAccumulator.js.map +0 -1
  40. package/dist/appendA2AChunk.d.ts +0 -3
  41. package/dist/appendA2AChunk.d.ts.map +0 -1
  42. package/dist/appendA2AChunk.js +0 -110
  43. package/dist/appendA2AChunk.js.map +0 -1
  44. package/dist/convertA2AMessages.d.ts +0 -64
  45. package/dist/convertA2AMessages.d.ts.map +0 -1
  46. package/dist/convertA2AMessages.js +0 -90
  47. package/dist/convertA2AMessages.js.map +0 -1
  48. package/dist/testUtils.d.ts +0 -4
  49. package/dist/testUtils.d.ts.map +0 -1
  50. package/dist/testUtils.js +0 -6
  51. package/dist/testUtils.js.map +0 -1
  52. package/dist/useA2AMessages.d.ts +0 -25
  53. package/dist/useA2AMessages.d.ts.map +0 -1
  54. package/dist/useA2AMessages.js +0 -122
  55. package/dist/useA2AMessages.js.map +0 -1
  56. package/src/A2AMessageAccumulator.ts +0 -48
  57. package/src/appendA2AChunk.ts +0 -121
  58. package/src/convertA2AMessages.ts +0 -108
  59. package/src/testUtils.ts +0 -11
  60. package/src/useA2AMessages.ts +0 -180
@@ -0,0 +1,692 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { A2AThreadRuntimeCore } from "./A2AThreadRuntimeCore";
3
+ import type { A2AClient } from "./A2AClient";
4
+ import type { A2AMessage, A2AStreamEvent, A2ATask } from "./types";
5
+ import type { AppendMessage, ThreadMessage } from "@assistant-ui/core";
6
+
7
+ // --- Mock client factory ---
8
+
9
+ function createMockClient(overrides: Partial<A2AClient> = {}): A2AClient {
10
+ return {
11
+ getAgentCard: vi.fn().mockRejectedValue(new Error("not found")),
12
+ sendMessage: vi.fn().mockResolvedValue({
13
+ id: "t1",
14
+ status: { state: "completed" },
15
+ } satisfies A2ATask),
16
+ streamMessage: vi.fn().mockImplementation(async function* () {
17
+ // default: empty stream
18
+ }),
19
+ getTask: vi.fn(),
20
+ listTasks: vi.fn(),
21
+ cancelTask: vi.fn().mockResolvedValue({
22
+ id: "t1",
23
+ status: { state: "canceled" },
24
+ }),
25
+ subscribeToTask: vi.fn(),
26
+ getExtendedAgentCard: vi.fn(),
27
+ createTaskPushNotificationConfig: vi.fn(),
28
+ getTaskPushNotificationConfig: vi.fn(),
29
+ listTaskPushNotificationConfigs: vi.fn(),
30
+ deleteTaskPushNotificationConfig: vi.fn(),
31
+ ...overrides,
32
+ } as unknown as A2AClient;
33
+ }
34
+
35
+ function createUserAppendMessage(text: string): AppendMessage {
36
+ return {
37
+ parentId: null,
38
+ role: "user",
39
+ content: [{ type: "text", text }],
40
+ };
41
+ }
42
+
43
+ function statusUpdateEvent(state: string, text?: string): A2AStreamEvent {
44
+ return {
45
+ type: "statusUpdate",
46
+ event: {
47
+ taskId: "t1",
48
+ contextId: "ctx-1",
49
+ status: {
50
+ state: state as any,
51
+ ...(text && {
52
+ message: {
53
+ messageId: "s1",
54
+ role: "agent" as const,
55
+ parts: [{ text }],
56
+ },
57
+ }),
58
+ },
59
+ },
60
+ };
61
+ }
62
+
63
+ function artifactUpdateEvent(
64
+ artifactId: string,
65
+ parts: { text: string }[],
66
+ opts: { append?: boolean; lastChunk?: boolean } = {},
67
+ ): A2AStreamEvent {
68
+ return {
69
+ type: "artifactUpdate",
70
+ event: {
71
+ taskId: "t1",
72
+ contextId: "ctx-1",
73
+ artifact: { artifactId, name: artifactId, parts },
74
+ append: opts.append,
75
+ lastChunk: opts.lastChunk,
76
+ },
77
+ };
78
+ }
79
+
80
+ describe("A2AThreadRuntimeCore", () => {
81
+ let notifyUpdate: ReturnType<typeof vi.fn>;
82
+
83
+ beforeEach(() => {
84
+ notifyUpdate = vi.fn();
85
+ });
86
+
87
+ function createCore(
88
+ clientOverrides: Partial<A2AClient> = {},
89
+ coreOverrides: Record<string, unknown> = {},
90
+ ) {
91
+ return new A2AThreadRuntimeCore({
92
+ client: createMockClient(clientOverrides),
93
+ notifyUpdate,
94
+ ...coreOverrides,
95
+ });
96
+ }
97
+
98
+ // --- Basic state ---
99
+
100
+ describe("initial state", () => {
101
+ it("starts with no messages", () => {
102
+ const core = createCore();
103
+ expect(core.getMessages()).toEqual([]);
104
+ });
105
+
106
+ it("starts not running", () => {
107
+ const core = createCore();
108
+ expect(core.isRunning()).toBe(false);
109
+ });
110
+
111
+ it("starts with no task", () => {
112
+ const core = createCore();
113
+ expect(core.getTask()).toBeUndefined();
114
+ });
115
+
116
+ it("starts with no artifacts", () => {
117
+ const core = createCore();
118
+ expect(core.getArtifacts()).toEqual([]);
119
+ });
120
+ });
121
+
122
+ // --- Edit & Reload ---
123
+
124
+ describe("edit", () => {
125
+ it("delegates to append", async () => {
126
+ const core = createCore({
127
+ streamMessage: vi.fn().mockImplementation(async function* () {
128
+ yield statusUpdateEvent("completed", "Edited response");
129
+ }),
130
+ });
131
+
132
+ await core.edit(createUserAppendMessage("Edited"));
133
+
134
+ const messages = core.getMessages();
135
+ expect(messages).toHaveLength(2);
136
+ expect(messages[0]!.role).toBe("user");
137
+ expect(messages[1]!.role).toBe("assistant");
138
+ });
139
+ });
140
+
141
+ describe("reload", () => {
142
+ it("resets to parent and re-runs", async () => {
143
+ let runCount = 0;
144
+ const core = createCore({
145
+ streamMessage: vi.fn().mockImplementation(async function* () {
146
+ runCount++;
147
+ yield statusUpdateEvent("completed", `Run ${runCount}`);
148
+ }),
149
+ });
150
+
151
+ await core.append(createUserAppendMessage("Hello"));
152
+ expect(core.getMessages()).toHaveLength(2);
153
+
154
+ const userId = core.getMessages()[0]!.id;
155
+ await core.reload(userId);
156
+
157
+ // After reload: user message + new assistant message
158
+ expect(core.getMessages()).toHaveLength(2);
159
+ const assistant = core.getMessages()[1]!;
160
+ expect(assistant.content).toEqual([{ type: "text", text: "Run 2" }]);
161
+ });
162
+ });
163
+
164
+ // --- Streaming run ---
165
+
166
+ describe("streaming run", () => {
167
+ it("processes status update events into messages", async () => {
168
+ const events: A2AStreamEvent[] = [
169
+ statusUpdateEvent("working", "Thinking..."),
170
+ statusUpdateEvent("completed", "Done!"),
171
+ ];
172
+
173
+ const core = createCore({
174
+ streamMessage: vi.fn().mockImplementation(async function* () {
175
+ for (const e of events) yield e;
176
+ }),
177
+ });
178
+
179
+ await core.append(createUserAppendMessage("Hello"));
180
+
181
+ const messages = core.getMessages();
182
+ expect(messages).toHaveLength(2); // user + assistant
183
+ expect(messages[0]!.role).toBe("user");
184
+ expect(messages[1]!.role).toBe("assistant");
185
+
186
+ const assistant = messages[1]!;
187
+ // Final content should be "Done!"
188
+ expect(assistant.content).toEqual([{ type: "text", text: "Done!" }]);
189
+ expect(assistant.status).toEqual({
190
+ type: "complete",
191
+ reason: "stop",
192
+ });
193
+ });
194
+
195
+ it("tracks task state from status updates", async () => {
196
+ const core = createCore({
197
+ streamMessage: vi.fn().mockImplementation(async function* () {
198
+ yield statusUpdateEvent("working", "...");
199
+ yield statusUpdateEvent("completed", "Done");
200
+ }),
201
+ });
202
+
203
+ await core.append(createUserAppendMessage("Go"));
204
+
205
+ const task = core.getTask();
206
+ expect(task).toBeDefined();
207
+ expect(task!.id).toBe("t1");
208
+ expect(task!.status.state).toBe("completed");
209
+ });
210
+
211
+ it("tracks context ID from events", async () => {
212
+ const core = createCore({
213
+ streamMessage: vi.fn().mockImplementation(async function* () {
214
+ yield statusUpdateEvent("completed", "Done");
215
+ }),
216
+ });
217
+
218
+ await core.append(createUserAppendMessage("Go"));
219
+
220
+ const task = core.getTask();
221
+ expect(task!.contextId).toBe("ctx-1");
222
+ });
223
+
224
+ it("is running during stream and not after", async () => {
225
+ let wasRunningDuringStream = false;
226
+ const core = createCore({
227
+ streamMessage: vi.fn().mockImplementation(async function* () {
228
+ // Capture isRunning state mid-stream
229
+ wasRunningDuringStream = core.isRunning();
230
+ yield statusUpdateEvent("completed", "Done");
231
+ }),
232
+ });
233
+
234
+ await core.append(createUserAppendMessage("Go"));
235
+
236
+ expect(wasRunningDuringStream).toBe(true);
237
+ expect(core.isRunning()).toBe(false);
238
+ });
239
+ });
240
+
241
+ // --- Sync (non-streaming) fallback ---
242
+
243
+ describe("sync fallback", () => {
244
+ it("uses sendMessage when streaming is false in agent card", async () => {
245
+ const sendMessage = vi.fn().mockResolvedValue({
246
+ id: "t1",
247
+ status: {
248
+ state: "completed",
249
+ message: {
250
+ messageId: "s1",
251
+ role: "agent",
252
+ parts: [{ text: "Sync response" }],
253
+ },
254
+ },
255
+ } satisfies A2ATask);
256
+
257
+ const streamMessage = vi.fn();
258
+
259
+ const core = createCore({ sendMessage, streamMessage });
260
+ // Simulate agent card with streaming: false
261
+ (core as any).agentCardValue = {
262
+ capabilities: { streaming: false },
263
+ };
264
+
265
+ await core.append(createUserAppendMessage("Hello"));
266
+
267
+ expect(sendMessage).toHaveBeenCalledTimes(1);
268
+ expect(streamMessage).not.toHaveBeenCalled();
269
+
270
+ const messages = core.getMessages();
271
+ const assistant = messages[1]!;
272
+ expect(assistant.content).toEqual([
273
+ { type: "text", text: "Sync response" },
274
+ ]);
275
+ });
276
+
277
+ it("handles Message-only response (stateless agent)", async () => {
278
+ const sendMessage = vi.fn().mockResolvedValue({
279
+ messageId: "m2",
280
+ role: "agent",
281
+ parts: [{ text: "Quick answer" }],
282
+ } satisfies A2AMessage);
283
+
284
+ const core = createCore({ sendMessage });
285
+ (core as any).agentCardValue = {
286
+ capabilities: { streaming: false },
287
+ };
288
+
289
+ await core.append(createUserAppendMessage("Hello"));
290
+
291
+ const messages = core.getMessages();
292
+ expect(messages[1]!.content).toEqual([
293
+ { type: "text", text: "Quick answer" },
294
+ ]);
295
+ expect(messages[1]!.status).toEqual({
296
+ type: "complete",
297
+ reason: "stop",
298
+ });
299
+ });
300
+ });
301
+
302
+ // --- Artifact handling ---
303
+
304
+ describe("artifacts", () => {
305
+ it("accumulates artifacts from artifact update events", async () => {
306
+ const core = createCore({
307
+ streamMessage: vi.fn().mockImplementation(async function* () {
308
+ yield artifactUpdateEvent("a1", [{ text: "code1" }]);
309
+ yield artifactUpdateEvent("a2", [{ text: "code2" }]);
310
+ yield statusUpdateEvent("completed", "Done");
311
+ }),
312
+ });
313
+
314
+ await core.append(createUserAppendMessage("Go"));
315
+
316
+ const artifacts = core.getArtifacts();
317
+ expect(artifacts).toHaveLength(2);
318
+ expect(artifacts[0]!.artifactId).toBe("a1");
319
+ expect(artifacts[1]!.artifactId).toBe("a2");
320
+ });
321
+
322
+ it("appends parts to existing artifact when append=true", async () => {
323
+ const core = createCore({
324
+ streamMessage: vi.fn().mockImplementation(async function* () {
325
+ yield artifactUpdateEvent("a1", [{ text: "part1" }]);
326
+ yield artifactUpdateEvent("a1", [{ text: "part2" }], {
327
+ append: true,
328
+ });
329
+ yield statusUpdateEvent("completed", "Done");
330
+ }),
331
+ });
332
+
333
+ await core.append(createUserAppendMessage("Go"));
334
+
335
+ const artifacts = core.getArtifacts();
336
+ expect(artifacts).toHaveLength(1);
337
+ expect(artifacts[0]!.parts).toHaveLength(2);
338
+ expect(artifacts[0]!.parts[0]!.text).toBe("part1");
339
+ expect(artifacts[0]!.parts[1]!.text).toBe("part2");
340
+ });
341
+
342
+ it("replaces artifact when append=false", async () => {
343
+ const core = createCore({
344
+ streamMessage: vi.fn().mockImplementation(async function* () {
345
+ yield artifactUpdateEvent("a1", [{ text: "old" }]);
346
+ yield artifactUpdateEvent("a1", [{ text: "new" }]);
347
+ yield statusUpdateEvent("completed", "Done");
348
+ }),
349
+ });
350
+
351
+ await core.append(createUserAppendMessage("Go"));
352
+
353
+ const artifacts = core.getArtifacts();
354
+ expect(artifacts).toHaveLength(1);
355
+ expect(artifacts[0]!.parts).toHaveLength(1);
356
+ expect(artifacts[0]!.parts[0]!.text).toBe("new");
357
+ });
358
+
359
+ it("calls onArtifactComplete when lastChunk=true", async () => {
360
+ const onArtifactComplete = vi.fn();
361
+
362
+ const core = new A2AThreadRuntimeCore({
363
+ client: createMockClient({
364
+ streamMessage: vi.fn().mockImplementation(async function* () {
365
+ yield artifactUpdateEvent("a1", [{ text: "code" }], {
366
+ lastChunk: true,
367
+ });
368
+ yield statusUpdateEvent("completed", "Done");
369
+ }),
370
+ }),
371
+ onArtifactComplete,
372
+ notifyUpdate,
373
+ });
374
+
375
+ await core.append(createUserAppendMessage("Go"));
376
+
377
+ expect(onArtifactComplete).toHaveBeenCalledTimes(1);
378
+ expect(onArtifactComplete.mock.calls[0]![0].artifactId).toBe("a1");
379
+ });
380
+
381
+ it("resets artifacts on new run", async () => {
382
+ let runCount = 0;
383
+
384
+ const core = createCore({
385
+ streamMessage: vi.fn().mockImplementation(async function* () {
386
+ runCount++;
387
+ if (runCount === 1) {
388
+ yield artifactUpdateEvent("a1", [{ text: "first" }]);
389
+ yield statusUpdateEvent("completed", "Done");
390
+ } else {
391
+ yield statusUpdateEvent("completed", "Done");
392
+ }
393
+ }),
394
+ });
395
+
396
+ await core.append(createUserAppendMessage("First"));
397
+ expect(core.getArtifacts()).toHaveLength(1);
398
+
399
+ await core.append(createUserAppendMessage("Second"));
400
+ expect(core.getArtifacts()).toHaveLength(0);
401
+ });
402
+ });
403
+
404
+ // --- Task lifecycle ---
405
+
406
+ describe("task lifecycle", () => {
407
+ it("clears task after terminal state for new message", async () => {
408
+ let runCount = 0;
409
+
410
+ const streamMessage = vi.fn().mockImplementation(async function* () {
411
+ runCount++;
412
+ yield statusUpdateEvent("completed", `Run ${runCount}`);
413
+ });
414
+
415
+ const core = createCore({ streamMessage });
416
+
417
+ await core.append(createUserAppendMessage("First"));
418
+ expect(core.getTask()!.status.state).toBe("completed");
419
+
420
+ await core.append(createUserAppendMessage("Second"));
421
+
422
+ // Verify the second call didn't include the old taskId
423
+ const secondCallMsg = streamMessage.mock.calls[1]![0] as A2AMessage;
424
+ expect(secondCallMsg.taskId).toBeUndefined();
425
+ });
426
+
427
+ it("keeps taskId for non-terminal states (input_required)", async () => {
428
+ let runCount = 0;
429
+
430
+ const streamMessage = vi.fn().mockImplementation(async function* () {
431
+ runCount++;
432
+ if (runCount === 1) {
433
+ yield statusUpdateEvent("input_required", "Need more info");
434
+ } else {
435
+ yield statusUpdateEvent("completed", "Done");
436
+ }
437
+ });
438
+
439
+ const core = createCore({ streamMessage });
440
+
441
+ await core.append(createUserAppendMessage("Start"));
442
+ expect(core.getTask()!.status.state).toBe("input_required");
443
+
444
+ await core.append(createUserAppendMessage("More info"));
445
+
446
+ // Second call should include the taskId
447
+ const secondCallMsg = streamMessage.mock.calls[1]![0] as A2AMessage;
448
+ expect(secondCallMsg.taskId).toBe("t1");
449
+ });
450
+ });
451
+
452
+ // --- Task snapshot ---
453
+
454
+ describe("task snapshot", () => {
455
+ it("handles full task snapshot from stream", async () => {
456
+ const taskSnapshot: A2ATask = {
457
+ id: "t1",
458
+ contextId: "ctx-1",
459
+ status: {
460
+ state: "completed",
461
+ message: {
462
+ messageId: "s1",
463
+ role: "agent",
464
+ parts: [{ text: "Full snapshot" }],
465
+ },
466
+ },
467
+ artifacts: [
468
+ {
469
+ artifactId: "a1",
470
+ parts: [{ text: "artifact content" }],
471
+ },
472
+ ],
473
+ };
474
+
475
+ const core = createCore({
476
+ streamMessage: vi.fn().mockImplementation(async function* () {
477
+ yield { type: "task", task: taskSnapshot } as A2AStreamEvent;
478
+ }),
479
+ });
480
+
481
+ await core.append(createUserAppendMessage("Go"));
482
+
483
+ expect(core.getTask()).toEqual(taskSnapshot);
484
+ expect(core.getArtifacts()).toHaveLength(1);
485
+
486
+ const assistant = core.getMessages()[1]!;
487
+ expect(assistant.content).toEqual([
488
+ { type: "text", text: "Full snapshot" },
489
+ ]);
490
+ });
491
+ });
492
+
493
+ // --- Message event ---
494
+
495
+ describe("message event", () => {
496
+ it("handles standalone agent message event", async () => {
497
+ const core = createCore({
498
+ streamMessage: vi.fn().mockImplementation(async function* () {
499
+ yield {
500
+ type: "message",
501
+ message: {
502
+ messageId: "m2",
503
+ role: "agent",
504
+ parts: [{ text: "Direct message" }],
505
+ },
506
+ } as A2AStreamEvent;
507
+ }),
508
+ });
509
+
510
+ await core.append(createUserAppendMessage("Go"));
511
+
512
+ const assistant = core.getMessages()[1]!;
513
+ expect(assistant.content).toEqual([
514
+ { type: "text", text: "Direct message" },
515
+ ]);
516
+ });
517
+
518
+ it("ignores user-role message events", async () => {
519
+ const core = createCore({
520
+ streamMessage: vi.fn().mockImplementation(async function* () {
521
+ yield {
522
+ type: "message",
523
+ message: {
524
+ messageId: "m2",
525
+ role: "user",
526
+ parts: [{ text: "Echo" }],
527
+ },
528
+ } as A2AStreamEvent;
529
+ }),
530
+ });
531
+
532
+ await core.append(createUserAppendMessage("Go"));
533
+
534
+ const assistant = core.getMessages()[1]!;
535
+ expect(assistant.content).toEqual([]); // No content from user message
536
+ });
537
+ });
538
+
539
+ // --- Cancel ---
540
+
541
+ describe("cancel", () => {
542
+ it("updates task from server cancel response", async () => {
543
+ const cancelTask = vi.fn().mockResolvedValue({
544
+ id: "t1",
545
+ status: { state: "canceled" },
546
+ });
547
+
548
+ const core = createCore({ cancelTask });
549
+
550
+ // Manually set task state to simulate a running task
551
+ (core as any).currentTask = {
552
+ id: "t1",
553
+ status: { state: "working" },
554
+ };
555
+ (core as any).abortController = new AbortController();
556
+
557
+ await core.cancel();
558
+
559
+ expect(cancelTask).toHaveBeenCalledWith("t1");
560
+ expect(core.getTask()!.status.state).toBe("canceled");
561
+ });
562
+
563
+ it("does nothing when no abort controller", async () => {
564
+ const cancelTask = vi.fn();
565
+ const core = createCore({ cancelTask });
566
+
567
+ await core.cancel();
568
+
569
+ expect(cancelTask).not.toHaveBeenCalled();
570
+ });
571
+ });
572
+
573
+ // --- Error handling ---
574
+
575
+ describe("error handling", () => {
576
+ it("sets error status and re-throws on stream failure", async () => {
577
+ const onError = vi.fn();
578
+
579
+ const core = new A2AThreadRuntimeCore({
580
+ client: createMockClient({
581
+ streamMessage: vi.fn().mockImplementation(() => ({
582
+ async next() {
583
+ throw new Error("Network error");
584
+ },
585
+ [Symbol.asyncIterator]() {
586
+ return this;
587
+ },
588
+ })),
589
+ }),
590
+ onError,
591
+ notifyUpdate,
592
+ });
593
+
594
+ await expect(core.append(createUserAppendMessage("Go"))).rejects.toThrow(
595
+ "Network error",
596
+ );
597
+
598
+ expect(onError).toHaveBeenCalledTimes(1);
599
+ expect(onError.mock.calls[0]![0].message).toBe("Network error");
600
+
601
+ const assistant = core.getMessages()[1]!;
602
+ expect(assistant.status).toEqual({
603
+ type: "incomplete",
604
+ reason: "error",
605
+ });
606
+ });
607
+
608
+ it("marks complete when stream ends without terminal status", async () => {
609
+ const core = createCore({
610
+ streamMessage: vi.fn().mockImplementation(async function* () {
611
+ // Stream ends without any events
612
+ }),
613
+ });
614
+
615
+ await core.append(createUserAppendMessage("Go"));
616
+
617
+ const assistant = core.getMessages()[1]!;
618
+ expect(assistant.status).toEqual({
619
+ type: "complete",
620
+ reason: "stop",
621
+ });
622
+ });
623
+ });
624
+
625
+ // --- Concurrent run protection ---
626
+
627
+ describe("concurrent runs", () => {
628
+ it("aborts previous run when new message is sent", async () => {
629
+ let streamCount = 0;
630
+ const abortedSignals: boolean[] = [];
631
+
632
+ const core = createCore({
633
+ streamMessage: vi.fn().mockImplementation(async function* (
634
+ _msg: any,
635
+ _cfg: any,
636
+ _meta: any,
637
+ signal: AbortSignal,
638
+ ) {
639
+ streamCount++;
640
+ abortedSignals.push(signal.aborted);
641
+
642
+ if (streamCount === 1) {
643
+ // First stream: hang until aborted
644
+ await new Promise((resolve) => {
645
+ signal.addEventListener("abort", resolve, { once: true });
646
+ });
647
+ return;
648
+ }
649
+
650
+ yield statusUpdateEvent("completed", "Second run done");
651
+ }),
652
+ });
653
+
654
+ // Start first run (don't await)
655
+ const first = core.append(createUserAppendMessage("First"));
656
+
657
+ // Small delay to let stream start
658
+ await new Promise((r) => setTimeout(r, 10));
659
+
660
+ // Start second run - should abort first
661
+ await core.append(createUserAppendMessage("Second"));
662
+
663
+ await first;
664
+
665
+ // Second run should have completed
666
+ expect(core.isRunning()).toBe(false);
667
+ });
668
+ });
669
+
670
+ // --- applyExternalMessages ---
671
+
672
+ describe("applyExternalMessages", () => {
673
+ it("replaces all messages", () => {
674
+ const core = createCore();
675
+
676
+ const msgs: ThreadMessage[] = [
677
+ {
678
+ id: "ext-1",
679
+ role: "user",
680
+ createdAt: new Date(),
681
+ content: [{ type: "text", text: "External" }],
682
+ status: { type: "complete", reason: "stop" },
683
+ } as ThreadMessage,
684
+ ];
685
+
686
+ core.applyExternalMessages(msgs);
687
+
688
+ expect(core.getMessages()).toHaveLength(1);
689
+ expect(core.getMessages()[0]!.id).toBe("ext-1");
690
+ });
691
+ });
692
+ });