@threadplane/ag-ui 0.0.55 → 0.0.57

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,6 +1,6 @@
1
1
  import { signal, computed, InjectionToken, inject } from '@angular/core';
2
2
  import { Subject, Observable } from 'rxjs';
3
- import { toAgentError, isAbortError } from '@threadplane/chat';
3
+ import { completeDelivery, staticDelivery, streamingDelivery, toAgentError, selectPendingClientToolCalls, isAbortError } from '@threadplane/chat';
4
4
  import { HttpAgent, AbstractAgent, EventType } from '@ag-ui/client';
5
5
 
6
6
  // SPDX-License-Identifier: MIT
@@ -263,12 +263,19 @@ function normalizeCitation(entry, fallbackIndex) {
263
263
  }
264
264
  return undefined;
265
265
  };
266
+ const publishedAt = e['publishedAt'];
267
+ const normalizedPublishedAt = typeof publishedAt === 'string' || typeof publishedAt === 'number' || publishedAt instanceof Date
268
+ ? publishedAt
269
+ : undefined;
266
270
  return {
267
271
  id: str('id') ?? str('refId') ?? `c${fallbackIndex}`,
268
272
  index: typeof e['index'] === 'number' ? e['index'] : fallbackIndex,
269
273
  title: firstStr('title', 'name'),
270
274
  url: firstStr('url', 'href', 'source'),
271
275
  snippet: firstStr('snippet', 'content', 'excerpt'),
276
+ sourceType: str('sourceType'),
277
+ iconUrl: str('iconUrl'),
278
+ publishedAt: normalizedPublishedAt,
272
279
  extra: typeof e['extra'] === 'object' && e['extra'] !== null
273
280
  ? e['extra']
274
281
  : undefined,
@@ -280,6 +287,16 @@ function normalizeCitation(entry, fallbackIndex) {
280
287
  // Discriminator strings (e.g. 'RUN_STARTED') match EventType enum members
281
288
  // verbatim; the switch cases below use the string literals directly so this
282
289
  // file has no runtime dependency on the EventType enum import.
290
+ /** Finalize one run without touching messages owned by another generation. */
291
+ function finalizeDeliveryRun(store, run, outcome) {
292
+ if (run.outcome !== undefined)
293
+ return false;
294
+ run.outcome = outcome;
295
+ store.messages.update(messages => messages.map(message => message.delivery.generation === run.generation
296
+ ? { ...message, delivery: completeDelivery(run.generation, outcome) }
297
+ : message));
298
+ return true;
299
+ }
283
300
  /**
284
301
  * Per-message reasoning timing. Populated by REASONING_MESSAGE_START /
285
302
  * REASONING_MESSAGE_END handlers. The map lives on the module — same
@@ -306,6 +323,9 @@ function resolveReasoningDurationMs(messageId) {
306
323
  function reduceEvent(event, store) {
307
324
  switch (event.type) {
308
325
  case 'RUN_STARTED': {
326
+ const run = store.deliveryRun;
327
+ if (!run || run.outcome !== undefined || !bindRunId(event, run))
328
+ return;
309
329
  store.status.set('running');
310
330
  store.isLoading.set(true);
311
331
  store.error.set(undefined);
@@ -315,11 +335,17 @@ function reduceEvent(event, store) {
315
335
  return;
316
336
  }
317
337
  case 'RUN_FINISHED': {
338
+ const run = currentRunForEvent(event, store);
339
+ if (!run || !finalizeDeliveryRun(store, run, 'success'))
340
+ return;
318
341
  store.status.set('idle');
319
342
  store.isLoading.set(false);
320
343
  return;
321
344
  }
322
345
  case 'RUN_ERROR': {
346
+ const run = currentRunForEvent(event, store);
347
+ if (!run || !finalizeDeliveryRun(store, run, 'error'))
348
+ return;
323
349
  store.status.set('error');
324
350
  store.isLoading.set(false);
325
351
  const runErrorMsg = event.message;
@@ -328,28 +354,37 @@ function reduceEvent(event, store) {
328
354
  }
329
355
  case 'TEXT_MESSAGE_START': {
330
356
  const id = messageIdFrom(event);
357
+ const delivery = ownAssistantMessage(store, id);
358
+ if (!delivery)
359
+ return;
331
360
  store.messages.update((prev) => prev.some((m) => m.id === id)
332
- ? prev.map((m) => m.id === id ? { ...m, content: m.content ?? '' } : m)
333
- : [...prev, { id, role: 'assistant', content: '' }]);
361
+ ? prev.map((m) => m.id === id ? { ...m, content: m.content ?? '', delivery } : m)
362
+ : [...prev, { id, role: 'assistant', content: '', delivery }]);
334
363
  return;
335
364
  }
336
365
  case 'REASONING_MESSAGE_START': {
337
366
  const id = messageIdFrom(event);
367
+ const delivery = ownAssistantMessage(store, id);
368
+ if (!delivery)
369
+ return;
338
370
  reasoningTimingMap.set(id, { startedAt: Date.now() });
339
371
  // Initialize an assistant slot with empty reasoning if it doesn't already exist.
340
372
  store.messages.update((prev) => prev.some((m) => m.id === id)
341
373
  ? prev.map((m) => m.id === id
342
- ? { ...m, reasoning: m.reasoning ?? '' }
374
+ ? { ...m, reasoning: m.reasoning ?? '', delivery }
343
375
  : m)
344
- : [...prev, { id, role: 'assistant', content: '', reasoning: '' }]);
376
+ : [...prev, { id, role: 'assistant', content: '', reasoning: '', delivery }]);
345
377
  return;
346
378
  }
347
379
  case 'REASONING_MESSAGE_CONTENT':
348
380
  case 'REASONING_MESSAGE_CHUNK': {
349
381
  const id = messageIdFrom(event);
382
+ const delivery = ownAssistantMessage(store, id);
383
+ if (!delivery)
384
+ return;
350
385
  const delta = event.delta ?? '';
351
386
  store.messages.update((prev) => prev.map((m) => m.id === id
352
- ? { ...m, reasoning: (m.reasoning ?? '') + delta }
387
+ ? { ...m, reasoning: (m.reasoning ?? '') + delta, delivery }
353
388
  : m));
354
389
  return;
355
390
  }
@@ -368,8 +403,11 @@ function reduceEvent(event, store) {
368
403
  }
369
404
  case 'TEXT_MESSAGE_CONTENT': {
370
405
  const id = messageIdFrom(event);
406
+ const delivery = ownAssistantMessage(store, id);
407
+ if (!delivery)
408
+ return;
371
409
  const delta = event.delta ?? '';
372
- store.messages.update((prev) => prev.map((m) => m.id === id ? { ...m, content: m.content + delta } : m));
410
+ store.messages.update((prev) => prev.map((m) => m.id === id ? { ...m, content: m.content + delta, delivery } : m));
373
411
  return;
374
412
  }
375
413
  case 'TEXT_MESSAGE_END': {
@@ -390,14 +428,17 @@ function reduceEvent(event, store) {
390
428
  // tool-call-only turn emits no TEXT_MESSAGE_START), create a slot.
391
429
  const parentId = e.parentMessageId;
392
430
  if (parentId) {
431
+ const delivery = ownAssistantMessage(store, parentId);
432
+ if (!delivery)
433
+ return;
393
434
  store.messages.update((prev) => {
394
435
  const existing = prev.find((m) => m.id === parentId);
395
436
  if (existing) {
396
437
  return prev.map((m) => m.id === parentId
397
- ? { ...m, toolCallIds: [...(m.toolCallIds ?? []), e.toolCallId] }
438
+ ? { ...m, toolCallIds: [...(m.toolCallIds ?? []), e.toolCallId], delivery }
398
439
  : m);
399
440
  }
400
- return [...prev, { id: parentId, role: 'assistant', content: '', toolCallIds: [e.toolCallId] }];
441
+ return [...prev, { id: parentId, role: 'assistant', content: '', toolCallIds: [e.toolCallId], delivery }];
401
442
  });
402
443
  }
403
444
  return;
@@ -456,6 +497,13 @@ function reduceEvent(event, store) {
456
497
  case 'MESSAGES_SNAPSHOT': {
457
498
  const e = event;
458
499
  const raw = e.messages ?? [];
500
+ const run = store.deliveryRun?.outcome === undefined ? store.deliveryRun : null;
501
+ const canonicalAssistantId = resolveCanonicalAssistantId(raw, run);
502
+ const activeCanonicalAssistantId = canonicalAssistantId
503
+ && ownAssistantMessage(store, canonicalAssistantId)
504
+ ? canonicalAssistantId
505
+ : undefined;
506
+ const previousById = new Map(store.messages().map(message => [message.id, message]));
459
507
  // AG-UI AssistantMessage carries `toolCalls` (ToolCall objects) on the
460
508
  // snapshot wire. Bridge them to `toolCallIds` so that the chat lib's
461
509
  // per-message tool-call resolution (resolveMessageToolCalls) can scope
@@ -463,23 +511,71 @@ function reduceEvent(event, store) {
463
511
  // so the data is visible to <chat-tool-views>.
464
512
  const snapshotToolCalls = [];
465
513
  const messages = raw.map((m) => {
514
+ const previous = previousById.get(m.id);
515
+ const completedMessage = m.id !== activeCanonicalAssistantId
516
+ && previous?.delivery.phase === 'complete'
517
+ && (!run
518
+ || run.ownedMessageIds.has(m.id)
519
+ || run.snapshotReplacementIds.has(m.id))
520
+ ? previous
521
+ : undefined;
522
+ let delivery = previous?.delivery ?? staticDelivery(m.id);
523
+ let snapshotMessage;
466
524
  if (m.role !== 'assistant' || !m.toolCalls || m.toolCalls.length === 0) {
467
- return m;
525
+ snapshotMessage = m;
526
+ }
527
+ else {
528
+ const ids = [];
529
+ for (const tc of m.toolCalls) {
530
+ ids.push(tc.id);
531
+ snapshotToolCalls.push({
532
+ id: tc.id,
533
+ name: tc.function.name,
534
+ args: safeParseArgs(tc.function.arguments),
535
+ status: 'complete',
536
+ });
537
+ }
538
+ const { toolCalls: _dropped, ...rest } = m;
539
+ snapshotMessage = { ...rest, toolCallIds: ids };
540
+ }
541
+ if (completedMessage) {
542
+ const snapshotChanged = completedMessage.content !== snapshotMessage.content
543
+ || !sameStringArray(completedMessage.toolCallIds, snapshotMessage.toolCallIds);
544
+ if (!snapshotChanged)
545
+ return completedMessage;
546
+ run?.snapshotReplacementIds.add(m.id);
547
+ return {
548
+ ...completedMessage,
549
+ ...snapshotMessage,
550
+ delivery: completeDelivery(store.allocateDeliveryGeneration(`snapshot:${m.id}`), 'success'),
551
+ };
468
552
  }
469
- const ids = [];
470
- for (const tc of m.toolCalls) {
471
- ids.push(tc.id);
472
- snapshotToolCalls.push({
473
- id: tc.id,
474
- name: tc.function.name,
475
- args: safeParseArgs(tc.function.arguments),
476
- status: 'complete',
477
- });
553
+ if (run && (run.ownedMessageIds.has(m.id) || m.id === canonicalAssistantId)) {
554
+ delivery = delivery.generation === run.generation && delivery.phase === 'complete'
555
+ ? delivery
556
+ : streamingDelivery(run.generation);
557
+ run.ownedMessageIds.add(m.id);
558
+ if (m.id === activeCanonicalAssistantId)
559
+ run.currentAssistantMessageId = m.id;
478
560
  }
479
- const { toolCalls: _dropped, ...rest } = m;
480
- return { ...rest, toolCallIds: ids };
561
+ return { ...snapshotMessage, delivery };
481
562
  });
482
- store.messages.set(messages);
563
+ const currentAssistant = run?.currentAssistantMessageId
564
+ ? previousById.get(run.currentAssistantMessageId)
565
+ : undefined;
566
+ if (run
567
+ && currentAssistant?.delivery.generation === run.generation
568
+ && currentAssistant.delivery.phase === 'streaming'
569
+ && !messages.some(message => message.id === currentAssistant.id)) {
570
+ messages.push(currentAssistant);
571
+ }
572
+ // Re-apply per-message citations from the already-received STATE. A
573
+ // MESSAGES_SNAPSHOT replaces the streamed messages wholesale — and the
574
+ // final snapshot message id (str(AIMessage.id), e.g. "resp-…") differs
575
+ // from the streaming chunk id the earlier STATE_SNAPSHOT bridged against,
576
+ // so without re-bridging here the citations (keyed by the final id) would
577
+ // be dropped on the message swap.
578
+ store.messages.set(bridgeCitationsState({ state: store.state() }, messages));
483
579
  if (snapshotToolCalls.length > 0) {
484
580
  store.toolCalls.update((prev) => {
485
581
  // Merge: keep existing entries (they may carry richer state from
@@ -498,7 +594,14 @@ function reduceEvent(event, store) {
498
594
  // (e.g. ChatApprovalCardComponent) receive a plain object, not a string.
499
595
  const parsedValue = typeof e.value === 'string' ? safeParseJson(e.value) : e.value;
500
596
  if (e.name === 'on_interrupt') {
597
+ const run = currentRunForEvent(event, store);
598
+ if (store.deliveryRun && !run)
599
+ return;
501
600
  store.interrupt.set({ id: randomId$1(), value: parsedValue, resumable: true });
601
+ if (run && finalizeDeliveryRun(store, run, 'paused')) {
602
+ store.status.set('idle');
603
+ store.isLoading.set(false);
604
+ }
502
605
  return;
503
606
  }
504
607
  // Surface every other custom event on the customEvents signal so the
@@ -517,13 +620,17 @@ function reduceEvent(event, store) {
517
620
  const e = event;
518
621
  const map = new Map(store.activities());
519
622
  const existing = map.get(e.messageId);
520
- if (existing && existing.activityType === e.activityType && !e.replace) {
521
- existing.content.update((c) => ({ ...c, ...e.content }));
623
+ if (existing && existing.activityType === e.activityType) {
624
+ if (e.replace)
625
+ existing.content.set(e.content ?? {});
626
+ else
627
+ existing.content.update((c) => ({ ...c, ...e.content }));
522
628
  }
523
629
  else {
524
630
  map.set(e.messageId, {
525
631
  messageId: e.messageId,
526
632
  activityType: e.activityType,
633
+ generation: store.allocateDeliveryGeneration(`activity:${e.messageId}`),
527
634
  content: signal(e.content ?? {}),
528
635
  });
529
636
  }
@@ -559,6 +666,68 @@ function reduceEvent(event, store) {
559
666
  function randomId$1() {
560
667
  return Math.random().toString(36).slice(2);
561
668
  }
669
+ function eventRunId(event) {
670
+ const runId = event.runId;
671
+ return typeof runId === 'string' ? runId : undefined;
672
+ }
673
+ function bindRunId(event, run) {
674
+ const runId = eventRunId(event);
675
+ if (!runId)
676
+ return true;
677
+ if (run.protocolRunId && run.protocolRunId !== runId)
678
+ return false;
679
+ run.protocolRunId = runId;
680
+ return true;
681
+ }
682
+ function currentRunForEvent(event, store) {
683
+ const run = store.deliveryRun;
684
+ if (!run || !bindRunId(event, run))
685
+ return null;
686
+ return run;
687
+ }
688
+ function ownAssistantMessage(store, id) {
689
+ const run = store.deliveryRun;
690
+ if (!run || run.outcome !== undefined)
691
+ return undefined;
692
+ const currentId = run.currentAssistantMessageId;
693
+ if (currentId && currentId !== id) {
694
+ if (run.ownedMessageIds.has(id))
695
+ return undefined;
696
+ store.messages.update(messages => messages.map(message => message.id === currentId
697
+ && message.delivery.generation === run.generation
698
+ && message.delivery.phase === 'streaming'
699
+ ? { ...message, delivery: completeDelivery(run.generation, 'success') }
700
+ : message));
701
+ }
702
+ run.ownedMessageIds.add(id);
703
+ run.currentAssistantMessageId = id;
704
+ return streamingDelivery(run.generation);
705
+ }
706
+ function sameStringArray(left, right) {
707
+ if (left === right)
708
+ return true;
709
+ if (!left || !right || left.length !== right.length)
710
+ return false;
711
+ return left.every((value, index) => value === right[index]);
712
+ }
713
+ function resolveCanonicalAssistantId(messages, run) {
714
+ if (!run)
715
+ return undefined;
716
+ if (run.currentAssistantMessageId
717
+ && messages.some(message => message.id === run.currentAssistantMessageId)) {
718
+ return run.currentAssistantMessageId;
719
+ }
720
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
721
+ const message = messages[index];
722
+ if (message.role === 'assistant' && !run.baselineMessageIds.has(message.id)) {
723
+ return message.id;
724
+ }
725
+ }
726
+ return run.eligibleBaselineAssistantId
727
+ && messages.some(message => message.role === 'assistant' && message.id === run.eligibleBaselineAssistantId)
728
+ ? run.eligibleBaselineAssistantId
729
+ : undefined;
730
+ }
562
731
  function messageIdFrom(event) {
563
732
  return event.messageId ?? 'unknown';
564
733
  }
@@ -618,21 +787,59 @@ function safeStringify(v) {
618
787
  * have no backend result, and haven't been resolved client-side yet — but ONLY
619
788
  * when the run is not in progress (isLoading===false). The backend ends the run
620
789
  * without emitting TOOL_CALL_RESULT for client tools, so result stays undefined.
621
- * - resolve(id, result): marks the call as resolved, writes the outcome onto
790
+ * - settle(id, result): marks the call as resolved, writes the outcome onto
622
791
  * the local ToolCall in the store (so the transcript freezes: the mounted
623
792
  * ask component re-renders with its emitted value as props and can branch to
624
- * a frozen state), adds a ToolMessage via source.addMessage, then re-runs
625
- * the agent with the catalog tools attached.
793
+ * a frozen state), and adds a ToolMessage via source.addMessage without
794
+ * starting a run.
795
+ * - flush(): no-op. settle() already made the result durable by adding it to
796
+ * the source's message list, so there is nothing left to write.
797
+ * - resolve(id, result): settles the result, then requests a continuation
798
+ * through the adapter-owned run gateway. Any ToolMessages previously
799
+ * settled into the source are flushed by that single run.
626
800
  *
627
801
  * Call catalogAsAgUiTools() to get the current catalog as AG-UI Tool[] for
628
- * threading into runAgent().
802
+ * attaching to each adapter-owned run.
629
803
  */
630
- function createClientToolsCapability(source, store) {
804
+ function createClientToolsCapability(source, store, continueRun) {
631
805
  const catalog = signal([], ...(ngDevMode ? [{ debugName: "catalog" }] : []));
632
806
  const resolvedIds = signal(new Set(), ...(ngDevMode ? [{ debugName: "resolvedIds" }] : []));
633
807
  function catalogAsAgUiTools() {
634
808
  return catalog().map(toAgUiTool);
635
809
  }
810
+ function settleResult(id, result) {
811
+ // Mark as resolved first so pending() drops it immediately.
812
+ resolvedIds.update((s) => new Set(s).add(id));
813
+ // Write the outcome onto the LOCAL ToolCall in the store. The client tool
814
+ // DID produce a result client-side, so this is semantically correct — and
815
+ // it freezes the transcript card: toToolViewSpec spreads `{...args,
816
+ // ...result, status}` into the mounted ask component, so the component
817
+ // re-renders with its own emitted value as props and can branch to a
818
+ // resolved/frozen state. The backend ToolMessage never reaches this local
819
+ // ToolCall, so without this write the card stays interactive forever.
820
+ const ok = result.ok;
821
+ const value = result.value;
822
+ const error = result.error;
823
+ store.toolCalls.update((calls) => calls.map((tc) => tc.id === id
824
+ ? {
825
+ ...tc,
826
+ result: ok ? value : { error },
827
+ ...(ok ? {} : { error, status: 'error' }),
828
+ }
829
+ : tc));
830
+ // Cast rather than rely on discriminant narrowing: consumer apps that
831
+ // compile this source with `strictNullChecks: false` don't narrow the
832
+ // ClientToolResult union in a ternary.
833
+ const content = ok
834
+ ? safeStringify(value)
835
+ : `Error: ${error}`;
836
+ source.addMessage({
837
+ id: `tool-${id}`,
838
+ role: 'tool',
839
+ toolCallId: id,
840
+ content,
841
+ });
842
+ }
636
843
  const clientTools = {
637
844
  setCatalog(specs) {
638
845
  catalog.set([...specs]);
@@ -640,45 +847,25 @@ function createClientToolsCapability(source, store) {
640
847
  pending: computed(() => {
641
848
  // Client tools are only actionable after the run ends (backend signals it
642
849
  // by ending the run WITHOUT emitting TOOL_CALL_RESULT for client tools).
643
- if (store.isLoading())
644
- return [];
645
- const names = new Set(catalog().map((s) => s.name));
646
- const done = resolvedIds();
647
- return store.toolCalls().filter((tc) => names.has(tc.name) && tc.result === undefined && !done.has(tc.id));
850
+ return selectPendingClientToolCalls({
851
+ isLoading: store.isLoading(),
852
+ toolCalls: store.toolCalls(),
853
+ catalogNames: new Set(catalog().map((s) => s.name)),
854
+ resolvedIds: resolvedIds(),
855
+ });
648
856
  }),
857
+ settle(id, result) {
858
+ settleResult(id, result);
859
+ },
860
+ // AG-UI's settle() already calls source.addMessage(), which places the
861
+ // ToolMessage in the outgoing message list. Nothing further is needed to
862
+ // make it durable — the next run carries it.
863
+ flush() {
864
+ /* no-op: settle() is already durable */
865
+ },
649
866
  resolve(id, result) {
650
- // Mark as resolved first so pending() drops it immediately.
651
- resolvedIds.update((s) => new Set(s).add(id));
652
- // Write the outcome onto the LOCAL ToolCall in the store. The client tool
653
- // DID produce a result client-side, so this is semantically correct — and
654
- // it freezes the transcript card: toToolViewSpec spreads `{...args,
655
- // ...result, status}` into the mounted ask component, so the component
656
- // re-renders with its own emitted value as props and can branch to a
657
- // resolved/frozen state. The backend ToolMessage never reaches this local
658
- // ToolCall, so without this write the card stays interactive forever.
659
- const ok = result.ok;
660
- const value = result.value;
661
- const error = result.error;
662
- store.toolCalls.update((calls) => calls.map((tc) => tc.id === id
663
- ? {
664
- ...tc,
665
- result: ok ? value : { error },
666
- ...(ok ? {} : { error, status: 'error' }),
667
- }
668
- : tc));
669
- // Cast rather than rely on discriminant narrowing: consumer apps that
670
- // compile this source with `strictNullChecks: false` don't narrow the
671
- // ClientToolResult union in a ternary.
672
- const content = ok
673
- ? safeStringify(value)
674
- : `Error: ${error}`;
675
- source.addMessage({
676
- id: `tool-${id}`,
677
- role: 'tool',
678
- toolCallId: id,
679
- content,
680
- });
681
- void source.runAgent({ tools: catalogAsAgUiTools() });
867
+ settleResult(id, result);
868
+ void continueRun();
682
869
  },
683
870
  catalogAsAgUiTools,
684
871
  };
@@ -721,8 +908,18 @@ function agentRuntimeTelemetryErrorClass(error) {
721
908
  * of toAgent() should treat the returned object's lifecycle as tied to the
722
909
  * agent instance they constructed. The subscriber registered via
723
910
  * source.subscribe() will fire for the lifetime of source.
911
+ *
912
+ * @example
913
+ * ```ts
914
+ * import { HttpAgent } from '@ag-ui/client';
915
+ * import { toAgent } from '@threadplane/ag-ui';
916
+ *
917
+ * const agent = toAgent(new HttpAgent({ url: '/api/agent' }));
918
+ * ```
724
919
  */
725
920
  function toAgent(source, options = {}) {
921
+ let generationSequence = 0;
922
+ const allocateDeliveryGeneration = (scope) => `${scope}-${++generationSequence}-${Math.random().toString(36).slice(2, 10)}`;
726
923
  const store = {
727
924
  messages: signal([]),
728
925
  status: signal('idle'),
@@ -734,55 +931,39 @@ function toAgent(source, options = {}) {
734
931
  events$: new Subject(),
735
932
  customEvents: signal([]),
736
933
  activities: signal(new Map()),
934
+ deliveryRun: null,
935
+ allocateDeliveryGeneration,
737
936
  };
738
937
  const telemetryProperties = { transport: 'ag-ui', surface: 'to_agent' };
739
938
  let activeRun = null;
740
- // Set by stop(); lets run-failure handlers distinguish a user-initiated
741
- // abort (graceful cancel) from a genuine stream failure.
742
- let abortRequested = false;
743
- // Set to true the first time settleIfAborted() handles an abort error for the
744
- // current run. The AG-UI client can surface the same abort via both the event
745
- // stream (RUN_ERROR event) AND onRunFailed — abortSettled lets the second
746
- // delivery see through as a no-op rather than re-writing store state or
747
- // triggering a real error path. Both flags are reset together at the top of
748
- // submit() so the next run starts clean.
749
- let abortSettled = false;
939
+ const runsByProtocolId = new Map();
750
940
  // Tracks the last AgentSubmitInput so retry() can re-run it without
751
941
  // duplicating the user message. Set at the top of submit()'s message path.
752
942
  let lastInput;
753
- /** Settles the store as idle for stop()-induced failures; returns true if handled. */
754
- function settleIfAborted(error) {
755
- // If we already settled this abort (duplicate delivery — e.g. RUN_ERROR
756
- // event THEN onRunFailed), defensively re-apply the idle settle so any
757
- // state written between the two deliveries (e.g. RUN_STARTED from a new
758
- // run that started before flags were reset) is corrected. Telemetry is
759
- // not re-emitted — the guard returns true to suppress further processing.
760
- if (abortSettled && isAbortError(error)) {
761
- store.status.set('idle');
762
- store.isLoading.set(false);
763
- return true;
764
- }
765
- if (!abortRequested || !isAbortError(error))
766
- return false;
767
- abortRequested = false;
768
- abortSettled = true;
769
- store.status.set('idle');
770
- store.isLoading.set(false);
771
- // Not a failure: leave store.error null and close out telemetry as a
772
- // normal finish so the aborted run doesn't count as errored.
773
- const run = activeRun;
774
- if (run) {
775
- finishRunTelemetry(run);
776
- // Mark errored so any subsequent finishRunTelemetry/failRunTelemetry
777
- // call on the same run object (e.g. from submit's try block resolving
778
- // after the abort) is a no-op — telemetry fires at most once per run.
779
- run.errored = true;
943
+ function resolveCallbackRun(protocolRunId) {
944
+ if (!protocolRunId)
945
+ return activeRun;
946
+ const known = runsByProtocolId.get(protocolRunId);
947
+ if (known)
948
+ return known;
949
+ if (!activeRun || activeRun.protocolRunId)
950
+ return null;
951
+ activeRun.protocolRunId = protocolRunId;
952
+ runsByProtocolId.set(protocolRunId, activeRun);
953
+ while (runsByProtocolId.size > 16) {
954
+ const oldestId = runsByProtocolId.keys().next().value;
955
+ if (!oldestId)
956
+ break;
957
+ if (runsByProtocolId.get(oldestId) === activeRun) {
958
+ const current = runsByProtocolId.get(oldestId);
959
+ runsByProtocolId.delete(oldestId);
960
+ runsByProtocolId.set(oldestId, current);
961
+ continue;
962
+ }
963
+ runsByProtocolId.delete(oldestId);
780
964
  }
781
- return true;
965
+ return activeRun;
782
966
  }
783
- // Build the client-tools capability. catalogAsAgUiTools() is used below to
784
- // thread the catalog into every runAgent() call.
785
- const clientToolsCap = createClientToolsCapability(source, store);
786
967
  /** Forward a neutral-contract state patch onto the AG-UI run input.
787
968
  * Mirrors the canonical demo's `input.state` mechanism: the patch is
788
969
  * merged into the source agent's client state (carried on
@@ -795,9 +976,27 @@ function toAgent(source, options = {}) {
795
976
  store.state.update((prev) => ({ ...prev, ...patch }));
796
977
  };
797
978
  captureAgentRuntimeTelemetry(options.telemetry, 'tplane:runtime_instance_created', telemetryProperties);
798
- function startRunTelemetry(requestType) {
799
- const run = { startedAt: Date.now(), errored: false };
979
+ function beginRun(requestType, allowBaselineTail = false) {
980
+ if (activeRun && activeRun.outcome === undefined) {
981
+ const supersededRun = activeRun;
982
+ finalizeDeliveryRun(store, supersededRun, 'interrupted');
983
+ const interruption = new Error('Run superseded by a newer request');
984
+ interruption.name = 'InterruptedError';
985
+ failRunTelemetry(interruption, supersededRun);
986
+ }
987
+ const run = {
988
+ generation: allocateDeliveryGeneration('run'),
989
+ baselineMessageIds: new Set(store.messages().map(message => message.id)),
990
+ ownedMessageIds: new Set(),
991
+ snapshotReplacementIds: new Set(),
992
+ eligibleBaselineAssistantId: allowBaselineTail
993
+ ? getTailAssistantMessageId(store.messages())
994
+ : undefined,
995
+ startedAt: Date.now(),
996
+ telemetrySettled: false,
997
+ };
800
998
  activeRun = run;
999
+ store.deliveryRun = run;
801
1000
  captureAgentRuntimeTelemetry(options.telemetry, 'tplane:runtime_request_created', {
802
1001
  ...telemetryProperties,
803
1002
  requestType,
@@ -806,76 +1005,115 @@ function toAgent(source, options = {}) {
806
1005
  return run;
807
1006
  }
808
1007
  function finishRunTelemetry(run) {
809
- if (run.errored)
1008
+ if (run.telemetrySettled)
810
1009
  return;
1010
+ run.telemetrySettled = true;
811
1011
  captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_ended', {
812
1012
  ...telemetryProperties,
813
1013
  durationMs: Date.now() - run.startedAt,
814
1014
  });
815
- if (activeRun === run)
816
- activeRun = null;
817
1015
  }
818
1016
  function failRunTelemetry(error, run = activeRun) {
819
- if (!run || run.errored)
1017
+ if (!run || run.telemetrySettled)
820
1018
  return;
821
- run.errored = true;
1019
+ run.telemetrySettled = true;
822
1020
  captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_errored', {
823
1021
  ...telemetryProperties,
824
1022
  durationMs: Date.now() - run.startedAt,
825
1023
  errorClass: agentRuntimeTelemetryErrorClass(error),
826
1024
  });
827
- if (activeRun === run)
828
- activeRun = null;
829
1025
  }
830
- /**
831
- * Fires the current message list against the source agent (no append).
832
- * Both submit() and retry() share this path; submit() appends the user
833
- * message first, retry() skips the append and calls this directly.
834
- */
835
- async function runCurrentMessages() {
836
- const run = startRunTelemetry('submit');
1026
+ function failRun(run, error) {
1027
+ if (run.outcome !== undefined)
1028
+ return;
1029
+ finalizeDeliveryRun(store, run, 'error');
1030
+ if (activeRun === run) {
1031
+ store.status.set('error');
1032
+ store.isLoading.set(false);
1033
+ store.error.set(toAgentError(error));
1034
+ }
1035
+ failRunTelemetry(error, run);
1036
+ }
1037
+ function settleTransportClose(run) {
1038
+ if (run.outcome === undefined) {
1039
+ finalizeDeliveryRun(store, run, run.ownedMessageIds.size > 0 ? 'interrupted' : 'success');
1040
+ if (activeRun === run) {
1041
+ store.status.set('idle');
1042
+ store.isLoading.set(false);
1043
+ store.error.set(undefined);
1044
+ }
1045
+ }
1046
+ finishRunTelemetry(run);
1047
+ }
1048
+ async function executeRun(requestType, parameters, allowBaselineTail = false) {
1049
+ const run = beginRun(requestType, allowBaselineTail);
837
1050
  const tools = clientToolsCap.catalogAsAgUiTools();
1051
+ const runParameters = parameters === undefined && tools.length === 0
1052
+ ? undefined
1053
+ : { ...parameters, ...(tools.length > 0 ? { tools } : {}) };
838
1054
  try {
839
- await source.runAgent(tools.length > 0 ? { tools } : undefined);
840
- finishRunTelemetry(run);
1055
+ await source.runAgent(runParameters);
1056
+ settleTransportClose(run);
841
1057
  }
842
1058
  catch (err) {
843
- if (!settleIfAborted(err)) {
844
- store.status.set('error');
845
- store.isLoading.set(false);
846
- store.error.set(toAgentError(err));
847
- failRunTelemetry(err, run);
848
- }
1059
+ if (run.outcome === 'aborted' && isAbortError(err))
1060
+ return;
1061
+ failRun(run, err);
849
1062
  }
850
1063
  }
1064
+ const clientToolsCap = createClientToolsCapability(source, store, () => executeRun('client-tool-continuation', undefined, true));
851
1065
  // Tap all events from the source agent via the AgentSubscriber API.
852
1066
  // This subscription lives for the lifetime of `source`.
853
1067
  source.subscribe({
854
- onEvent({ event }) {
855
- // The AG-UI client surfaces a user-initiated abort both as a
856
- // RUN_ERROR event (here) and via onRunFailed; guard the event path too
857
- // so the reducer never marks a deliberate stop as an error.
858
- if (event.type === 'RUN_ERROR') {
859
- const message = event.message ?? '';
860
- if (settleIfAborted(new Error(message)))
861
- return;
1068
+ onRunInitialized({ input }) {
1069
+ resolveCallbackRun(input.runId);
1070
+ },
1071
+ onEvent({ event, input }) {
1072
+ const callbackRunId = input?.runId ?? event.runId;
1073
+ const run = resolveCallbackRun(callbackRunId);
1074
+ if (!run) {
1075
+ if (!callbackRunId)
1076
+ reduceEvent(event, store);
1077
+ return;
1078
+ }
1079
+ if (run !== activeRun) {
1080
+ if (event.type === 'RUN_FINISHED')
1081
+ finalizeDeliveryRun(store, run, 'success');
1082
+ else if (event.type === 'RUN_ERROR')
1083
+ finalizeDeliveryRun(store, run, 'error');
1084
+ return;
862
1085
  }
1086
+ if (event.type === 'RUN_ERROR' && run.outcome === 'aborted')
1087
+ return;
863
1088
  reduceEvent(event, store);
1089
+ if (run && event.type === 'RUN_FINISHED' && run.outcome === 'success') {
1090
+ finishRunTelemetry(run);
1091
+ }
1092
+ else if (run && event.type === 'RUN_ERROR' && run.outcome === 'error') {
1093
+ failRunTelemetry(event.message ?? event, run);
1094
+ }
864
1095
  },
865
- onRunFailed({ error }) {
866
- if (settleIfAborted(error))
1096
+ onRunFailed({ error, input }) {
1097
+ const run = resolveCallbackRun(input?.runId);
1098
+ if (run) {
1099
+ if (run.outcome === 'aborted' && isAbortError(error))
1100
+ return;
1101
+ failRun(run, error);
1102
+ return;
1103
+ }
1104
+ if (input?.runId)
867
1105
  return;
868
1106
  store.status.set('error');
869
1107
  store.isLoading.set(false);
870
1108
  store.error.set(toAgentError(error));
871
- failRunTelemetry(error);
872
1109
  },
873
1110
  });
874
1111
  // Stable Subagent wrappers per messageId so chat-subagents (tracks by
875
1112
  // toolCallId) doesn't churn as activity content streams.
876
1113
  const subagentWrappers = new Map();
877
1114
  function subagentFor(id, entry) {
878
- let w = subagentWrappers.get(id);
1115
+ const cached = subagentWrappers.get(id);
1116
+ let w = cached?.generation === entry.generation ? cached.wrapper : undefined;
879
1117
  if (!w) {
880
1118
  w = {
881
1119
  toolCallId: entry.content()['toolCallId'] ?? id,
@@ -883,17 +1121,27 @@ function toAgent(source, options = {}) {
883
1121
  status: computed(() => entry.content()['status'] ?? 'running'),
884
1122
  messages: computed(() => {
885
1123
  const c = entry.content();
1124
+ const status = c['status'] ?? 'running';
1125
+ const assistantDelivery = status === 'error'
1126
+ ? completeDelivery(entry.generation, 'error')
1127
+ : status === 'complete'
1128
+ ? completeDelivery(entry.generation, 'success')
1129
+ : streamingDelivery(entry.generation);
886
1130
  const raw = c['messages'];
887
1131
  if (Array.isArray(raw)) {
888
- return raw.map((m, i) => ({
889
- id: m['id'] ?? `${id}-${i}`,
890
- role: 'assistant',
891
- content: typeof m['content'] === 'string' ? m['content'] : m['content'] ?? '',
892
- ...(Array.isArray(m['toolCallIds']) ? { toolCallIds: m['toolCallIds'] } : {}),
893
- ...(typeof m['reasoning'] === 'string' ? { reasoning: m['reasoning'] } : {}),
894
- }));
1132
+ return raw.map((m, i) => {
1133
+ const messageId = m['id'] ?? `${id}-${i}`;
1134
+ return {
1135
+ id: messageId,
1136
+ role: 'assistant',
1137
+ content: typeof m['content'] === 'string' ? m['content'] : m['content'] ?? '',
1138
+ delivery: m['role'] === 'assistant' ? assistantDelivery : staticDelivery(messageId),
1139
+ ...(Array.isArray(m['toolCallIds']) ? { toolCallIds: m['toolCallIds'] } : {}),
1140
+ ...(typeof m['reasoning'] === 'string' ? { reasoning: m['reasoning'] } : {}),
1141
+ };
1142
+ });
895
1143
  }
896
- return [{ id, role: 'assistant', content: String(c['text'] ?? '') }];
1144
+ return [{ id, role: 'assistant', content: String(c['text'] ?? ''), delivery: assistantDelivery }];
897
1145
  }),
898
1146
  toolCalls: computed(() => {
899
1147
  const raw = entry.content()['toolCalls'];
@@ -901,7 +1149,7 @@ function toAgent(source, options = {}) {
901
1149
  }),
902
1150
  state: computed(() => entry.content()['state'] ?? {}),
903
1151
  };
904
- subagentWrappers.set(id, w);
1152
+ subagentWrappers.set(id, { generation: entry.generation, wrapper: w });
905
1153
  }
906
1154
  return w;
907
1155
  }
@@ -932,33 +1180,13 @@ function toAgent(source, options = {}) {
932
1180
  }),
933
1181
  clientTools: clientToolsCap,
934
1182
  submit: async (input, _opts) => {
935
- // Reset both abort flags so a new submit starts clean and genuine
936
- // failures after a previous stop are never swallowed.
937
- abortRequested = false;
938
- abortSettled = false;
939
1183
  if (input.resume !== undefined) {
940
1184
  // Resume path: clear the pending interrupt and replay the run with the
941
1185
  // resume payload forwarded to the LangGraph backend via AG-UI's
942
1186
  // forwardedProps.command.resume mechanism.
943
1187
  applyStatePatch(input.state);
944
1188
  store.interrupt.set(undefined);
945
- const run = startRunTelemetry('resume');
946
- const tools = clientToolsCap.catalogAsAgUiTools();
947
- try {
948
- await source.runAgent({
949
- forwardedProps: { command: { resume: input.resume } },
950
- ...(tools.length > 0 ? { tools } : {}),
951
- });
952
- finishRunTelemetry(run);
953
- }
954
- catch (err) {
955
- if (!settleIfAborted(err)) {
956
- store.status.set('error');
957
- store.isLoading.set(false);
958
- store.error.set(toAgentError(err));
959
- failRunTelemetry(err, run);
960
- }
961
- }
1189
+ await executeRun('resume', { forwardedProps: { command: { resume: input.resume } } }, true);
962
1190
  return;
963
1191
  }
964
1192
  applyStatePatch(input.state);
@@ -973,7 +1201,7 @@ function toAgent(source, options = {}) {
973
1201
  // Record the input so retry() can re-run it without re-appending the
974
1202
  // user message (the message is already in the list by this point).
975
1203
  lastInput = input;
976
- await runCurrentMessages();
1204
+ await executeRun('submit');
977
1205
  },
978
1206
  retry: async () => {
979
1207
  if (store.isLoading())
@@ -984,22 +1212,23 @@ function toAgent(source, options = {}) {
984
1212
  // Re-run the same message list against the source without appending a
985
1213
  // duplicate user message — the message is already in store.messages and
986
1214
  // source's internal list from the original submit().
987
- await runCurrentMessages();
1215
+ await executeRun('retry', undefined, true);
988
1216
  },
989
1217
  stop: async () => {
990
- abortRequested = true;
1218
+ const run = activeRun;
1219
+ if (run && run.outcome === undefined) {
1220
+ finalizeDeliveryRun(store, run, 'aborted');
1221
+ store.status.set('idle');
1222
+ store.isLoading.set(false);
1223
+ store.error.set(undefined);
1224
+ finishRunTelemetry(run);
1225
+ }
991
1226
  source.abortRun();
992
1227
  },
993
1228
  regenerate: async (assistantMessageIndex) => {
994
1229
  if (store.isLoading()) {
995
1230
  throw new Error('Cannot regenerate while agent is loading another response');
996
1231
  }
997
- // Reset abort flags so a regenerate starts clean, exactly like submit().
998
- // Without this, flags left over from a prior stop() would cause the
999
- // duplicate-delivery guard in settleIfAborted() to silently swallow the
1000
- // abort error without settling, wedging the store in streaming/running.
1001
- abortRequested = false;
1002
- abortSettled = false;
1003
1232
  const msgs = store.messages();
1004
1233
  const target = msgs[assistantMessageIndex];
1005
1234
  if (!target || target.role !== 'assistant') {
@@ -1025,20 +1254,7 @@ function toAgent(source, options = {}) {
1025
1254
  // agent's internal message list without appending — the trailing user
1026
1255
  // message in `trimmed` becomes the active prompt for the next run.
1027
1256
  source.setMessages(trimmed);
1028
- const run = startRunTelemetry('regenerate');
1029
- const regenTools = clientToolsCap.catalogAsAgUiTools();
1030
- try {
1031
- await source.runAgent(regenTools.length > 0 ? { tools: regenTools } : undefined);
1032
- finishRunTelemetry(run);
1033
- }
1034
- catch (err) {
1035
- if (!settleIfAborted(err)) {
1036
- store.status.set('error');
1037
- store.isLoading.set(false);
1038
- store.error.set(toAgentError(err));
1039
- failRunTelemetry(err, run);
1040
- }
1041
- }
1257
+ await executeRun('regenerate');
1042
1258
  },
1043
1259
  };
1044
1260
  }
@@ -1048,11 +1264,16 @@ function buildUserMessage(input) {
1048
1264
  const content = typeof input.message === 'string'
1049
1265
  ? input.message
1050
1266
  : input.message.map((b) => b.type === 'text' ? b.text : JSON.stringify(b)).join('');
1051
- return { id: randomId(), role: 'user', content };
1267
+ const id = randomId();
1268
+ return { id, role: 'user', content, delivery: staticDelivery(id) };
1052
1269
  }
1053
1270
  function randomId() {
1054
1271
  return Math.random().toString(36).slice(2);
1055
1272
  }
1273
+ function getTailAssistantMessageId(messages) {
1274
+ const tail = messages[messages.length - 1];
1275
+ return tail?.role === 'assistant' ? tail.id : undefined;
1276
+ }
1056
1277
 
1057
1278
  // SPDX-License-Identifier: MIT
1058
1279
  /**
@@ -1111,6 +1332,8 @@ class FakeAgent extends AbstractAgent {
1111
1332
  reasoningTokens;
1112
1333
  /** Milliseconds between successive token emissions. */
1113
1334
  delayMs;
1335
+ /** Optional deterministic event branches for tests that need exact streams. */
1336
+ script;
1114
1337
  constructor(opts = {}) {
1115
1338
  super();
1116
1339
  this.tokens = opts.tokens ?? [
@@ -1119,11 +1342,14 @@ class FakeAgent extends AbstractAgent {
1119
1342
  ];
1120
1343
  this.reasoningTokens = opts.reasoningTokens ?? [];
1121
1344
  this.delayMs = opts.delayMs ?? 60;
1345
+ this.script = opts.script ?? [];
1122
1346
  }
1123
1347
  run(input) {
1348
+ const scripted = this.scriptedSequence(input);
1349
+ if (scripted)
1350
+ return this.emitSequence(scripted, 30);
1124
1351
  const tokens = this.tokens;
1125
1352
  const reasoningTokens = this.reasoningTokens;
1126
- const delayMs = this.delayMs;
1127
1353
  const messageId = `fake-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1128
1354
  const sequence = [
1129
1355
  { type: EventType.RUN_STARTED, threadId: input.threadId, runId: input.runId },
@@ -1141,6 +1367,22 @@ class FakeAgent extends AbstractAgent {
1141
1367
  }
1142
1368
  sequence.push({ type: EventType.TEXT_MESSAGE_END, messageId });
1143
1369
  sequence.push({ type: EventType.RUN_FINISHED, threadId: input.threadId, runId: input.runId });
1370
+ return this.emitSequence(sequence, 30);
1371
+ }
1372
+ scriptedSequence(input) {
1373
+ if (this.script.length === 0)
1374
+ return undefined;
1375
+ const branch = this.script.find((candidate) => matchesBranch(candidate.when, input));
1376
+ if (!branch)
1377
+ return undefined;
1378
+ return [
1379
+ { type: EventType.RUN_STARTED, threadId: input.threadId, runId: input.runId },
1380
+ ...branch.events,
1381
+ { type: EventType.RUN_FINISHED, threadId: input.threadId, runId: input.runId },
1382
+ ];
1383
+ }
1384
+ emitSequence(sequence, initialDelayMs) {
1385
+ const delayMs = this.delayMs;
1144
1386
  return new Observable((observer) => {
1145
1387
  let cancelled = false;
1146
1388
  let timer;
@@ -1157,7 +1399,7 @@ class FakeAgent extends AbstractAgent {
1157
1399
  // Steady cadence except a tiny initial delay before RUN_STARTED.
1158
1400
  timer = setTimeout(emitNext, delayMs);
1159
1401
  };
1160
- timer = setTimeout(emitNext, 30);
1402
+ timer = setTimeout(emitNext, initialDelayMs);
1161
1403
  return () => {
1162
1404
  cancelled = true;
1163
1405
  if (timer !== undefined)
@@ -1166,6 +1408,26 @@ class FakeAgent extends AbstractAgent {
1166
1408
  });
1167
1409
  }
1168
1410
  }
1411
+ function matchesBranch(when, input) {
1412
+ if (when === 'initial')
1413
+ return !hasToolMessages(input);
1414
+ return hasToolMessageFor(input, when.toolMessageFor);
1415
+ }
1416
+ function hasToolMessages(input) {
1417
+ return (input.messages ?? []).some((message) => isToolMessage(message));
1418
+ }
1419
+ function hasToolMessageFor(input, toolCallId) {
1420
+ return (input.messages ?? []).some((message) => {
1421
+ if (!isToolMessage(message))
1422
+ return false;
1423
+ const record = message;
1424
+ return record['toolCallId'] === toolCallId || record['tool_call_id'] === toolCallId;
1425
+ });
1426
+ }
1427
+ function isToolMessage(message) {
1428
+ const record = message;
1429
+ return record['role'] === 'tool' || record['type'] === 'tool';
1430
+ }
1169
1431
 
1170
1432
  /**
1171
1433
  * Registers an in-process FakeAgent under AGENT.
@@ -1176,7 +1438,7 @@ class FakeAgent extends AbstractAgent {
1176
1438
  * @example
1177
1439
  * ```ts
1178
1440
  * TestBed.configureTestingModule({
1179
- * providers: [provideFakeAgent({ responses: ['Hello from the fake agent'] })],
1441
+ * providers: [provideFakeAgent({ tokens: ['Hello from the fake agent'] })],
1180
1442
  * });
1181
1443
  * ```
1182
1444
  */