@threadplane/ag-ui 0.0.56 → 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,22 +511,64 @@ 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 };
468
540
  }
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
- });
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
+ };
478
552
  }
479
- const { toolCalls: _dropped, ...rest } = m;
480
- return { ...rest, toolCallIds: ids };
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;
560
+ }
561
+ return { ...snapshotMessage, delivery };
481
562
  });
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
+ }
482
572
  // Re-apply per-message citations from the already-received STATE. A
483
573
  // MESSAGES_SNAPSHOT replaces the streamed messages wholesale — and the
484
574
  // final snapshot message id (str(AIMessage.id), e.g. "resp-…") differs
@@ -504,7 +594,14 @@ function reduceEvent(event, store) {
504
594
  // (e.g. ChatApprovalCardComponent) receive a plain object, not a string.
505
595
  const parsedValue = typeof e.value === 'string' ? safeParseJson(e.value) : e.value;
506
596
  if (e.name === 'on_interrupt') {
597
+ const run = currentRunForEvent(event, store);
598
+ if (store.deliveryRun && !run)
599
+ return;
507
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
+ }
508
605
  return;
509
606
  }
510
607
  // Surface every other custom event on the customEvents signal so the
@@ -523,13 +620,17 @@ function reduceEvent(event, store) {
523
620
  const e = event;
524
621
  const map = new Map(store.activities());
525
622
  const existing = map.get(e.messageId);
526
- if (existing && existing.activityType === e.activityType && !e.replace) {
527
- 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 }));
528
628
  }
529
629
  else {
530
630
  map.set(e.messageId, {
531
631
  messageId: e.messageId,
532
632
  activityType: e.activityType,
633
+ generation: store.allocateDeliveryGeneration(`activity:${e.messageId}`),
533
634
  content: signal(e.content ?? {}),
534
635
  });
535
636
  }
@@ -565,6 +666,68 @@ function reduceEvent(event, store) {
565
666
  function randomId$1() {
566
667
  return Math.random().toString(36).slice(2);
567
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
+ }
568
731
  function messageIdFrom(event) {
569
732
  return event.messageId ?? 'unknown';
570
733
  }
@@ -624,21 +787,59 @@ function safeStringify(v) {
624
787
  * have no backend result, and haven't been resolved client-side yet — but ONLY
625
788
  * when the run is not in progress (isLoading===false). The backend ends the run
626
789
  * without emitting TOOL_CALL_RESULT for client tools, so result stays undefined.
627
- * - 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
628
791
  * the local ToolCall in the store (so the transcript freezes: the mounted
629
792
  * ask component re-renders with its emitted value as props and can branch to
630
- * a frozen state), adds a ToolMessage via source.addMessage, then re-runs
631
- * 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.
632
800
  *
633
801
  * Call catalogAsAgUiTools() to get the current catalog as AG-UI Tool[] for
634
- * threading into runAgent().
802
+ * attaching to each adapter-owned run.
635
803
  */
636
- function createClientToolsCapability(source, store) {
804
+ function createClientToolsCapability(source, store, continueRun) {
637
805
  const catalog = signal([], ...(ngDevMode ? [{ debugName: "catalog" }] : []));
638
806
  const resolvedIds = signal(new Set(), ...(ngDevMode ? [{ debugName: "resolvedIds" }] : []));
639
807
  function catalogAsAgUiTools() {
640
808
  return catalog().map(toAgUiTool);
641
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
+ }
642
843
  const clientTools = {
643
844
  setCatalog(specs) {
644
845
  catalog.set([...specs]);
@@ -646,45 +847,25 @@ function createClientToolsCapability(source, store) {
646
847
  pending: computed(() => {
647
848
  // Client tools are only actionable after the run ends (backend signals it
648
849
  // by ending the run WITHOUT emitting TOOL_CALL_RESULT for client tools).
649
- if (store.isLoading())
650
- return [];
651
- const names = new Set(catalog().map((s) => s.name));
652
- const done = resolvedIds();
653
- 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
+ });
654
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
+ },
655
866
  resolve(id, result) {
656
- // Mark as resolved first so pending() drops it immediately.
657
- resolvedIds.update((s) => new Set(s).add(id));
658
- // Write the outcome onto the LOCAL ToolCall in the store. The client tool
659
- // DID produce a result client-side, so this is semantically correct — and
660
- // it freezes the transcript card: toToolViewSpec spreads `{...args,
661
- // ...result, status}` into the mounted ask component, so the component
662
- // re-renders with its own emitted value as props and can branch to a
663
- // resolved/frozen state. The backend ToolMessage never reaches this local
664
- // ToolCall, so without this write the card stays interactive forever.
665
- const ok = result.ok;
666
- const value = result.value;
667
- const error = result.error;
668
- store.toolCalls.update((calls) => calls.map((tc) => tc.id === id
669
- ? {
670
- ...tc,
671
- result: ok ? value : { error },
672
- ...(ok ? {} : { error, status: 'error' }),
673
- }
674
- : tc));
675
- // Cast rather than rely on discriminant narrowing: consumer apps that
676
- // compile this source with `strictNullChecks: false` don't narrow the
677
- // ClientToolResult union in a ternary.
678
- const content = ok
679
- ? safeStringify(value)
680
- : `Error: ${error}`;
681
- source.addMessage({
682
- id: `tool-${id}`,
683
- role: 'tool',
684
- toolCallId: id,
685
- content,
686
- });
687
- void source.runAgent({ tools: catalogAsAgUiTools() });
867
+ settleResult(id, result);
868
+ void continueRun();
688
869
  },
689
870
  catalogAsAgUiTools,
690
871
  };
@@ -737,6 +918,8 @@ function agentRuntimeTelemetryErrorClass(error) {
737
918
  * ```
738
919
  */
739
920
  function toAgent(source, options = {}) {
921
+ let generationSequence = 0;
922
+ const allocateDeliveryGeneration = (scope) => `${scope}-${++generationSequence}-${Math.random().toString(36).slice(2, 10)}`;
740
923
  const store = {
741
924
  messages: signal([]),
742
925
  status: signal('idle'),
@@ -748,55 +931,39 @@ function toAgent(source, options = {}) {
748
931
  events$: new Subject(),
749
932
  customEvents: signal([]),
750
933
  activities: signal(new Map()),
934
+ deliveryRun: null,
935
+ allocateDeliveryGeneration,
751
936
  };
752
937
  const telemetryProperties = { transport: 'ag-ui', surface: 'to_agent' };
753
938
  let activeRun = null;
754
- // Set by stop(); lets run-failure handlers distinguish a user-initiated
755
- // abort (graceful cancel) from a genuine stream failure.
756
- let abortRequested = false;
757
- // Set to true the first time settleIfAborted() handles an abort error for the
758
- // current run. The AG-UI client can surface the same abort via both the event
759
- // stream (RUN_ERROR event) AND onRunFailed — abortSettled lets the second
760
- // delivery see through as a no-op rather than re-writing store state or
761
- // triggering a real error path. Both flags are reset together at the top of
762
- // submit() so the next run starts clean.
763
- let abortSettled = false;
939
+ const runsByProtocolId = new Map();
764
940
  // Tracks the last AgentSubmitInput so retry() can re-run it without
765
941
  // duplicating the user message. Set at the top of submit()'s message path.
766
942
  let lastInput;
767
- /** Settles the store as idle for stop()-induced failures; returns true if handled. */
768
- function settleIfAborted(error) {
769
- // If we already settled this abort (duplicate delivery — e.g. RUN_ERROR
770
- // event THEN onRunFailed), defensively re-apply the idle settle so any
771
- // state written between the two deliveries (e.g. RUN_STARTED from a new
772
- // run that started before flags were reset) is corrected. Telemetry is
773
- // not re-emitted — the guard returns true to suppress further processing.
774
- if (abortSettled && isAbortError(error)) {
775
- store.status.set('idle');
776
- store.isLoading.set(false);
777
- return true;
778
- }
779
- if (!abortRequested || !isAbortError(error))
780
- return false;
781
- abortRequested = false;
782
- abortSettled = true;
783
- store.status.set('idle');
784
- store.isLoading.set(false);
785
- // Not a failure: leave store.error null and close out telemetry as a
786
- // normal finish so the aborted run doesn't count as errored.
787
- const run = activeRun;
788
- if (run) {
789
- finishRunTelemetry(run);
790
- // Mark errored so any subsequent finishRunTelemetry/failRunTelemetry
791
- // call on the same run object (e.g. from submit's try block resolving
792
- // after the abort) is a no-op — telemetry fires at most once per run.
793
- 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);
794
964
  }
795
- return true;
965
+ return activeRun;
796
966
  }
797
- // Build the client-tools capability. catalogAsAgUiTools() is used below to
798
- // thread the catalog into every runAgent() call.
799
- const clientToolsCap = createClientToolsCapability(source, store);
800
967
  /** Forward a neutral-contract state patch onto the AG-UI run input.
801
968
  * Mirrors the canonical demo's `input.state` mechanism: the patch is
802
969
  * merged into the source agent's client state (carried on
@@ -809,9 +976,27 @@ function toAgent(source, options = {}) {
809
976
  store.state.update((prev) => ({ ...prev, ...patch }));
810
977
  };
811
978
  captureAgentRuntimeTelemetry(options.telemetry, 'tplane:runtime_instance_created', telemetryProperties);
812
- function startRunTelemetry(requestType) {
813
- 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
+ };
814
998
  activeRun = run;
999
+ store.deliveryRun = run;
815
1000
  captureAgentRuntimeTelemetry(options.telemetry, 'tplane:runtime_request_created', {
816
1001
  ...telemetryProperties,
817
1002
  requestType,
@@ -820,76 +1005,115 @@ function toAgent(source, options = {}) {
820
1005
  return run;
821
1006
  }
822
1007
  function finishRunTelemetry(run) {
823
- if (run.errored)
1008
+ if (run.telemetrySettled)
824
1009
  return;
1010
+ run.telemetrySettled = true;
825
1011
  captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_ended', {
826
1012
  ...telemetryProperties,
827
1013
  durationMs: Date.now() - run.startedAt,
828
1014
  });
829
- if (activeRun === run)
830
- activeRun = null;
831
1015
  }
832
1016
  function failRunTelemetry(error, run = activeRun) {
833
- if (!run || run.errored)
1017
+ if (!run || run.telemetrySettled)
834
1018
  return;
835
- run.errored = true;
1019
+ run.telemetrySettled = true;
836
1020
  captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_errored', {
837
1021
  ...telemetryProperties,
838
1022
  durationMs: Date.now() - run.startedAt,
839
1023
  errorClass: agentRuntimeTelemetryErrorClass(error),
840
1024
  });
841
- if (activeRun === run)
842
- activeRun = null;
843
1025
  }
844
- /**
845
- * Fires the current message list against the source agent (no append).
846
- * Both submit() and retry() share this path; submit() appends the user
847
- * message first, retry() skips the append and calls this directly.
848
- */
849
- async function runCurrentMessages() {
850
- 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);
851
1050
  const tools = clientToolsCap.catalogAsAgUiTools();
1051
+ const runParameters = parameters === undefined && tools.length === 0
1052
+ ? undefined
1053
+ : { ...parameters, ...(tools.length > 0 ? { tools } : {}) };
852
1054
  try {
853
- await source.runAgent(tools.length > 0 ? { tools } : undefined);
854
- finishRunTelemetry(run);
1055
+ await source.runAgent(runParameters);
1056
+ settleTransportClose(run);
855
1057
  }
856
1058
  catch (err) {
857
- if (!settleIfAborted(err)) {
858
- store.status.set('error');
859
- store.isLoading.set(false);
860
- store.error.set(toAgentError(err));
861
- failRunTelemetry(err, run);
862
- }
1059
+ if (run.outcome === 'aborted' && isAbortError(err))
1060
+ return;
1061
+ failRun(run, err);
863
1062
  }
864
1063
  }
1064
+ const clientToolsCap = createClientToolsCapability(source, store, () => executeRun('client-tool-continuation', undefined, true));
865
1065
  // Tap all events from the source agent via the AgentSubscriber API.
866
1066
  // This subscription lives for the lifetime of `source`.
867
1067
  source.subscribe({
868
- onEvent({ event }) {
869
- // The AG-UI client surfaces a user-initiated abort both as a
870
- // RUN_ERROR event (here) and via onRunFailed; guard the event path too
871
- // so the reducer never marks a deliberate stop as an error.
872
- if (event.type === 'RUN_ERROR') {
873
- const message = event.message ?? '';
874
- if (settleIfAborted(new Error(message)))
875
- 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;
876
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;
1085
+ }
1086
+ if (event.type === 'RUN_ERROR' && run.outcome === 'aborted')
1087
+ return;
877
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
+ }
878
1095
  },
879
- onRunFailed({ error }) {
880
- 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)
881
1105
  return;
882
1106
  store.status.set('error');
883
1107
  store.isLoading.set(false);
884
1108
  store.error.set(toAgentError(error));
885
- failRunTelemetry(error);
886
1109
  },
887
1110
  });
888
1111
  // Stable Subagent wrappers per messageId so chat-subagents (tracks by
889
1112
  // toolCallId) doesn't churn as activity content streams.
890
1113
  const subagentWrappers = new Map();
891
1114
  function subagentFor(id, entry) {
892
- let w = subagentWrappers.get(id);
1115
+ const cached = subagentWrappers.get(id);
1116
+ let w = cached?.generation === entry.generation ? cached.wrapper : undefined;
893
1117
  if (!w) {
894
1118
  w = {
895
1119
  toolCallId: entry.content()['toolCallId'] ?? id,
@@ -897,17 +1121,27 @@ function toAgent(source, options = {}) {
897
1121
  status: computed(() => entry.content()['status'] ?? 'running'),
898
1122
  messages: computed(() => {
899
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);
900
1130
  const raw = c['messages'];
901
1131
  if (Array.isArray(raw)) {
902
- return raw.map((m, i) => ({
903
- id: m['id'] ?? `${id}-${i}`,
904
- role: 'assistant',
905
- content: typeof m['content'] === 'string' ? m['content'] : m['content'] ?? '',
906
- ...(Array.isArray(m['toolCallIds']) ? { toolCallIds: m['toolCallIds'] } : {}),
907
- ...(typeof m['reasoning'] === 'string' ? { reasoning: m['reasoning'] } : {}),
908
- }));
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
+ });
909
1143
  }
910
- return [{ id, role: 'assistant', content: String(c['text'] ?? '') }];
1144
+ return [{ id, role: 'assistant', content: String(c['text'] ?? ''), delivery: assistantDelivery }];
911
1145
  }),
912
1146
  toolCalls: computed(() => {
913
1147
  const raw = entry.content()['toolCalls'];
@@ -915,7 +1149,7 @@ function toAgent(source, options = {}) {
915
1149
  }),
916
1150
  state: computed(() => entry.content()['state'] ?? {}),
917
1151
  };
918
- subagentWrappers.set(id, w);
1152
+ subagentWrappers.set(id, { generation: entry.generation, wrapper: w });
919
1153
  }
920
1154
  return w;
921
1155
  }
@@ -946,33 +1180,13 @@ function toAgent(source, options = {}) {
946
1180
  }),
947
1181
  clientTools: clientToolsCap,
948
1182
  submit: async (input, _opts) => {
949
- // Reset both abort flags so a new submit starts clean and genuine
950
- // failures after a previous stop are never swallowed.
951
- abortRequested = false;
952
- abortSettled = false;
953
1183
  if (input.resume !== undefined) {
954
1184
  // Resume path: clear the pending interrupt and replay the run with the
955
1185
  // resume payload forwarded to the LangGraph backend via AG-UI's
956
1186
  // forwardedProps.command.resume mechanism.
957
1187
  applyStatePatch(input.state);
958
1188
  store.interrupt.set(undefined);
959
- const run = startRunTelemetry('resume');
960
- const tools = clientToolsCap.catalogAsAgUiTools();
961
- try {
962
- await source.runAgent({
963
- forwardedProps: { command: { resume: input.resume } },
964
- ...(tools.length > 0 ? { tools } : {}),
965
- });
966
- finishRunTelemetry(run);
967
- }
968
- catch (err) {
969
- if (!settleIfAborted(err)) {
970
- store.status.set('error');
971
- store.isLoading.set(false);
972
- store.error.set(toAgentError(err));
973
- failRunTelemetry(err, run);
974
- }
975
- }
1189
+ await executeRun('resume', { forwardedProps: { command: { resume: input.resume } } }, true);
976
1190
  return;
977
1191
  }
978
1192
  applyStatePatch(input.state);
@@ -987,7 +1201,7 @@ function toAgent(source, options = {}) {
987
1201
  // Record the input so retry() can re-run it without re-appending the
988
1202
  // user message (the message is already in the list by this point).
989
1203
  lastInput = input;
990
- await runCurrentMessages();
1204
+ await executeRun('submit');
991
1205
  },
992
1206
  retry: async () => {
993
1207
  if (store.isLoading())
@@ -998,22 +1212,23 @@ function toAgent(source, options = {}) {
998
1212
  // Re-run the same message list against the source without appending a
999
1213
  // duplicate user message — the message is already in store.messages and
1000
1214
  // source's internal list from the original submit().
1001
- await runCurrentMessages();
1215
+ await executeRun('retry', undefined, true);
1002
1216
  },
1003
1217
  stop: async () => {
1004
- 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
+ }
1005
1226
  source.abortRun();
1006
1227
  },
1007
1228
  regenerate: async (assistantMessageIndex) => {
1008
1229
  if (store.isLoading()) {
1009
1230
  throw new Error('Cannot regenerate while agent is loading another response');
1010
1231
  }
1011
- // Reset abort flags so a regenerate starts clean, exactly like submit().
1012
- // Without this, flags left over from a prior stop() would cause the
1013
- // duplicate-delivery guard in settleIfAborted() to silently swallow the
1014
- // abort error without settling, wedging the store in streaming/running.
1015
- abortRequested = false;
1016
- abortSettled = false;
1017
1232
  const msgs = store.messages();
1018
1233
  const target = msgs[assistantMessageIndex];
1019
1234
  if (!target || target.role !== 'assistant') {
@@ -1039,20 +1254,7 @@ function toAgent(source, options = {}) {
1039
1254
  // agent's internal message list without appending — the trailing user
1040
1255
  // message in `trimmed` becomes the active prompt for the next run.
1041
1256
  source.setMessages(trimmed);
1042
- const run = startRunTelemetry('regenerate');
1043
- const regenTools = clientToolsCap.catalogAsAgUiTools();
1044
- try {
1045
- await source.runAgent(regenTools.length > 0 ? { tools: regenTools } : undefined);
1046
- finishRunTelemetry(run);
1047
- }
1048
- catch (err) {
1049
- if (!settleIfAborted(err)) {
1050
- store.status.set('error');
1051
- store.isLoading.set(false);
1052
- store.error.set(toAgentError(err));
1053
- failRunTelemetry(err, run);
1054
- }
1055
- }
1257
+ await executeRun('regenerate');
1056
1258
  },
1057
1259
  };
1058
1260
  }
@@ -1062,11 +1264,16 @@ function buildUserMessage(input) {
1062
1264
  const content = typeof input.message === 'string'
1063
1265
  ? input.message
1064
1266
  : input.message.map((b) => b.type === 'text' ? b.text : JSON.stringify(b)).join('');
1065
- return { id: randomId(), role: 'user', content };
1267
+ const id = randomId();
1268
+ return { id, role: 'user', content, delivery: staticDelivery(id) };
1066
1269
  }
1067
1270
  function randomId() {
1068
1271
  return Math.random().toString(36).slice(2);
1069
1272
  }
1273
+ function getTailAssistantMessageId(messages) {
1274
+ const tail = messages[messages.length - 1];
1275
+ return tail?.role === 'assistant' ? tail.id : undefined;
1276
+ }
1070
1277
 
1071
1278
  // SPDX-License-Identifier: MIT
1072
1279
  /**
@@ -1125,6 +1332,8 @@ class FakeAgent extends AbstractAgent {
1125
1332
  reasoningTokens;
1126
1333
  /** Milliseconds between successive token emissions. */
1127
1334
  delayMs;
1335
+ /** Optional deterministic event branches for tests that need exact streams. */
1336
+ script;
1128
1337
  constructor(opts = {}) {
1129
1338
  super();
1130
1339
  this.tokens = opts.tokens ?? [
@@ -1133,11 +1342,14 @@ class FakeAgent extends AbstractAgent {
1133
1342
  ];
1134
1343
  this.reasoningTokens = opts.reasoningTokens ?? [];
1135
1344
  this.delayMs = opts.delayMs ?? 60;
1345
+ this.script = opts.script ?? [];
1136
1346
  }
1137
1347
  run(input) {
1348
+ const scripted = this.scriptedSequence(input);
1349
+ if (scripted)
1350
+ return this.emitSequence(scripted, 30);
1138
1351
  const tokens = this.tokens;
1139
1352
  const reasoningTokens = this.reasoningTokens;
1140
- const delayMs = this.delayMs;
1141
1353
  const messageId = `fake-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1142
1354
  const sequence = [
1143
1355
  { type: EventType.RUN_STARTED, threadId: input.threadId, runId: input.runId },
@@ -1155,6 +1367,22 @@ class FakeAgent extends AbstractAgent {
1155
1367
  }
1156
1368
  sequence.push({ type: EventType.TEXT_MESSAGE_END, messageId });
1157
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;
1158
1386
  return new Observable((observer) => {
1159
1387
  let cancelled = false;
1160
1388
  let timer;
@@ -1171,7 +1399,7 @@ class FakeAgent extends AbstractAgent {
1171
1399
  // Steady cadence except a tiny initial delay before RUN_STARTED.
1172
1400
  timer = setTimeout(emitNext, delayMs);
1173
1401
  };
1174
- timer = setTimeout(emitNext, 30);
1402
+ timer = setTimeout(emitNext, initialDelayMs);
1175
1403
  return () => {
1176
1404
  cancelled = true;
1177
1405
  if (timer !== undefined)
@@ -1180,6 +1408,26 @@ class FakeAgent extends AbstractAgent {
1180
1408
  });
1181
1409
  }
1182
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
+ }
1183
1431
 
1184
1432
  /**
1185
1433
  * Registers an in-process FakeAgent under AGENT.