@threadplane/ag-ui 0.0.56 → 0.0.58

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 };
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
  });
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,16 @@ function agentRuntimeTelemetryErrorClass(error) {
737
918
  * ```
738
919
  */
739
920
  function toAgent(source, options = {}) {
921
+ // Advertise A2UI capabilities via the AG-UI shared state so every
922
+ // RunAgentInput.state carries them (transport metadata, A2UI v0.9).
923
+ if (options.a2uiClientCapabilities) {
924
+ source.state = {
925
+ ...(source.state ?? {}),
926
+ a2ui_client_capabilities: options.a2uiClientCapabilities,
927
+ };
928
+ }
929
+ let generationSequence = 0;
930
+ const allocateDeliveryGeneration = (scope) => `${scope}-${++generationSequence}-${Math.random().toString(36).slice(2, 10)}`;
740
931
  const store = {
741
932
  messages: signal([]),
742
933
  status: signal('idle'),
@@ -748,55 +939,39 @@ function toAgent(source, options = {}) {
748
939
  events$: new Subject(),
749
940
  customEvents: signal([]),
750
941
  activities: signal(new Map()),
942
+ deliveryRun: null,
943
+ allocateDeliveryGeneration,
751
944
  };
752
945
  const telemetryProperties = { transport: 'ag-ui', surface: 'to_agent' };
753
946
  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;
947
+ const runsByProtocolId = new Map();
764
948
  // Tracks the last AgentSubmitInput so retry() can re-run it without
765
949
  // duplicating the user message. Set at the top of submit()'s message path.
766
950
  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;
951
+ function resolveCallbackRun(protocolRunId) {
952
+ if (!protocolRunId)
953
+ return activeRun;
954
+ const known = runsByProtocolId.get(protocolRunId);
955
+ if (known)
956
+ return known;
957
+ if (!activeRun || activeRun.protocolRunId)
958
+ return null;
959
+ activeRun.protocolRunId = protocolRunId;
960
+ runsByProtocolId.set(protocolRunId, activeRun);
961
+ while (runsByProtocolId.size > 16) {
962
+ const oldestId = runsByProtocolId.keys().next().value;
963
+ if (!oldestId)
964
+ break;
965
+ if (runsByProtocolId.get(oldestId) === activeRun) {
966
+ const current = runsByProtocolId.get(oldestId);
967
+ runsByProtocolId.delete(oldestId);
968
+ runsByProtocolId.set(oldestId, current);
969
+ continue;
970
+ }
971
+ runsByProtocolId.delete(oldestId);
794
972
  }
795
- return true;
973
+ return activeRun;
796
974
  }
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
975
  /** Forward a neutral-contract state patch onto the AG-UI run input.
801
976
  * Mirrors the canonical demo's `input.state` mechanism: the patch is
802
977
  * merged into the source agent's client state (carried on
@@ -809,9 +984,27 @@ function toAgent(source, options = {}) {
809
984
  store.state.update((prev) => ({ ...prev, ...patch }));
810
985
  };
811
986
  captureAgentRuntimeTelemetry(options.telemetry, 'tplane:runtime_instance_created', telemetryProperties);
812
- function startRunTelemetry(requestType) {
813
- const run = { startedAt: Date.now(), errored: false };
987
+ function beginRun(requestType, allowBaselineTail = false) {
988
+ if (activeRun && activeRun.outcome === undefined) {
989
+ const supersededRun = activeRun;
990
+ finalizeDeliveryRun(store, supersededRun, 'interrupted');
991
+ const interruption = new Error('Run superseded by a newer request');
992
+ interruption.name = 'InterruptedError';
993
+ failRunTelemetry(interruption, supersededRun);
994
+ }
995
+ const run = {
996
+ generation: allocateDeliveryGeneration('run'),
997
+ baselineMessageIds: new Set(store.messages().map(message => message.id)),
998
+ ownedMessageIds: new Set(),
999
+ snapshotReplacementIds: new Set(),
1000
+ eligibleBaselineAssistantId: allowBaselineTail
1001
+ ? getTailAssistantMessageId(store.messages())
1002
+ : undefined,
1003
+ startedAt: Date.now(),
1004
+ telemetrySettled: false,
1005
+ };
814
1006
  activeRun = run;
1007
+ store.deliveryRun = run;
815
1008
  captureAgentRuntimeTelemetry(options.telemetry, 'tplane:runtime_request_created', {
816
1009
  ...telemetryProperties,
817
1010
  requestType,
@@ -820,76 +1013,115 @@ function toAgent(source, options = {}) {
820
1013
  return run;
821
1014
  }
822
1015
  function finishRunTelemetry(run) {
823
- if (run.errored)
1016
+ if (run.telemetrySettled)
824
1017
  return;
1018
+ run.telemetrySettled = true;
825
1019
  captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_ended', {
826
1020
  ...telemetryProperties,
827
1021
  durationMs: Date.now() - run.startedAt,
828
1022
  });
829
- if (activeRun === run)
830
- activeRun = null;
831
1023
  }
832
1024
  function failRunTelemetry(error, run = activeRun) {
833
- if (!run || run.errored)
1025
+ if (!run || run.telemetrySettled)
834
1026
  return;
835
- run.errored = true;
1027
+ run.telemetrySettled = true;
836
1028
  captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_errored', {
837
1029
  ...telemetryProperties,
838
1030
  durationMs: Date.now() - run.startedAt,
839
1031
  errorClass: agentRuntimeTelemetryErrorClass(error),
840
1032
  });
841
- if (activeRun === run)
842
- activeRun = null;
843
1033
  }
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');
1034
+ function failRun(run, error) {
1035
+ if (run.outcome !== undefined)
1036
+ return;
1037
+ finalizeDeliveryRun(store, run, 'error');
1038
+ if (activeRun === run) {
1039
+ store.status.set('error');
1040
+ store.isLoading.set(false);
1041
+ store.error.set(toAgentError(error));
1042
+ }
1043
+ failRunTelemetry(error, run);
1044
+ }
1045
+ function settleTransportClose(run) {
1046
+ if (run.outcome === undefined) {
1047
+ finalizeDeliveryRun(store, run, run.ownedMessageIds.size > 0 ? 'interrupted' : 'success');
1048
+ if (activeRun === run) {
1049
+ store.status.set('idle');
1050
+ store.isLoading.set(false);
1051
+ store.error.set(undefined);
1052
+ }
1053
+ }
1054
+ finishRunTelemetry(run);
1055
+ }
1056
+ async function executeRun(requestType, parameters, allowBaselineTail = false) {
1057
+ const run = beginRun(requestType, allowBaselineTail);
851
1058
  const tools = clientToolsCap.catalogAsAgUiTools();
1059
+ const runParameters = parameters === undefined && tools.length === 0
1060
+ ? undefined
1061
+ : { ...parameters, ...(tools.length > 0 ? { tools } : {}) };
852
1062
  try {
853
- await source.runAgent(tools.length > 0 ? { tools } : undefined);
854
- finishRunTelemetry(run);
1063
+ await source.runAgent(runParameters);
1064
+ settleTransportClose(run);
855
1065
  }
856
1066
  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
- }
1067
+ if (run.outcome === 'aborted' && isAbortError(err))
1068
+ return;
1069
+ failRun(run, err);
863
1070
  }
864
1071
  }
1072
+ const clientToolsCap = createClientToolsCapability(source, store, () => executeRun('client-tool-continuation', undefined, true));
865
1073
  // Tap all events from the source agent via the AgentSubscriber API.
866
1074
  // This subscription lives for the lifetime of `source`.
867
1075
  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;
1076
+ onRunInitialized({ input }) {
1077
+ resolveCallbackRun(input.runId);
1078
+ },
1079
+ onEvent({ event, input }) {
1080
+ const callbackRunId = input?.runId ?? event.runId;
1081
+ const run = resolveCallbackRun(callbackRunId);
1082
+ if (!run) {
1083
+ if (!callbackRunId)
1084
+ reduceEvent(event, store);
1085
+ return;
876
1086
  }
1087
+ if (run !== activeRun) {
1088
+ if (event.type === 'RUN_FINISHED')
1089
+ finalizeDeliveryRun(store, run, 'success');
1090
+ else if (event.type === 'RUN_ERROR')
1091
+ finalizeDeliveryRun(store, run, 'error');
1092
+ return;
1093
+ }
1094
+ if (event.type === 'RUN_ERROR' && run.outcome === 'aborted')
1095
+ return;
877
1096
  reduceEvent(event, store);
1097
+ if (run && event.type === 'RUN_FINISHED' && run.outcome === 'success') {
1098
+ finishRunTelemetry(run);
1099
+ }
1100
+ else if (run && event.type === 'RUN_ERROR' && run.outcome === 'error') {
1101
+ failRunTelemetry(event.message ?? event, run);
1102
+ }
878
1103
  },
879
- onRunFailed({ error }) {
880
- if (settleIfAborted(error))
1104
+ onRunFailed({ error, input }) {
1105
+ const run = resolveCallbackRun(input?.runId);
1106
+ if (run) {
1107
+ if (run.outcome === 'aborted' && isAbortError(error))
1108
+ return;
1109
+ failRun(run, error);
1110
+ return;
1111
+ }
1112
+ if (input?.runId)
881
1113
  return;
882
1114
  store.status.set('error');
883
1115
  store.isLoading.set(false);
884
1116
  store.error.set(toAgentError(error));
885
- failRunTelemetry(error);
886
1117
  },
887
1118
  });
888
1119
  // Stable Subagent wrappers per messageId so chat-subagents (tracks by
889
1120
  // toolCallId) doesn't churn as activity content streams.
890
1121
  const subagentWrappers = new Map();
891
1122
  function subagentFor(id, entry) {
892
- let w = subagentWrappers.get(id);
1123
+ const cached = subagentWrappers.get(id);
1124
+ let w = cached?.generation === entry.generation ? cached.wrapper : undefined;
893
1125
  if (!w) {
894
1126
  w = {
895
1127
  toolCallId: entry.content()['toolCallId'] ?? id,
@@ -897,17 +1129,27 @@ function toAgent(source, options = {}) {
897
1129
  status: computed(() => entry.content()['status'] ?? 'running'),
898
1130
  messages: computed(() => {
899
1131
  const c = entry.content();
1132
+ const status = c['status'] ?? 'running';
1133
+ const assistantDelivery = status === 'error'
1134
+ ? completeDelivery(entry.generation, 'error')
1135
+ : status === 'complete'
1136
+ ? completeDelivery(entry.generation, 'success')
1137
+ : streamingDelivery(entry.generation);
900
1138
  const raw = c['messages'];
901
1139
  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
- }));
1140
+ return raw.map((m, i) => {
1141
+ const messageId = m['id'] ?? `${id}-${i}`;
1142
+ return {
1143
+ id: messageId,
1144
+ role: 'assistant',
1145
+ content: typeof m['content'] === 'string' ? m['content'] : m['content'] ?? '',
1146
+ delivery: m['role'] === 'assistant' ? assistantDelivery : staticDelivery(messageId),
1147
+ ...(Array.isArray(m['toolCallIds']) ? { toolCallIds: m['toolCallIds'] } : {}),
1148
+ ...(typeof m['reasoning'] === 'string' ? { reasoning: m['reasoning'] } : {}),
1149
+ };
1150
+ });
909
1151
  }
910
- return [{ id, role: 'assistant', content: String(c['text'] ?? '') }];
1152
+ return [{ id, role: 'assistant', content: String(c['text'] ?? ''), delivery: assistantDelivery }];
911
1153
  }),
912
1154
  toolCalls: computed(() => {
913
1155
  const raw = entry.content()['toolCalls'];
@@ -915,7 +1157,7 @@ function toAgent(source, options = {}) {
915
1157
  }),
916
1158
  state: computed(() => entry.content()['state'] ?? {}),
917
1159
  };
918
- subagentWrappers.set(id, w);
1160
+ subagentWrappers.set(id, { generation: entry.generation, wrapper: w });
919
1161
  }
920
1162
  return w;
921
1163
  }
@@ -946,33 +1188,13 @@ function toAgent(source, options = {}) {
946
1188
  }),
947
1189
  clientTools: clientToolsCap,
948
1190
  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
1191
  if (input.resume !== undefined) {
954
1192
  // Resume path: clear the pending interrupt and replay the run with the
955
1193
  // resume payload forwarded to the LangGraph backend via AG-UI's
956
1194
  // forwardedProps.command.resume mechanism.
957
1195
  applyStatePatch(input.state);
958
1196
  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
- }
1197
+ await executeRun('resume', { forwardedProps: { command: { resume: input.resume } } }, true);
976
1198
  return;
977
1199
  }
978
1200
  applyStatePatch(input.state);
@@ -987,7 +1209,7 @@ function toAgent(source, options = {}) {
987
1209
  // Record the input so retry() can re-run it without re-appending the
988
1210
  // user message (the message is already in the list by this point).
989
1211
  lastInput = input;
990
- await runCurrentMessages();
1212
+ await executeRun('submit');
991
1213
  },
992
1214
  retry: async () => {
993
1215
  if (store.isLoading())
@@ -998,22 +1220,23 @@ function toAgent(source, options = {}) {
998
1220
  // Re-run the same message list against the source without appending a
999
1221
  // duplicate user message — the message is already in store.messages and
1000
1222
  // source's internal list from the original submit().
1001
- await runCurrentMessages();
1223
+ await executeRun('retry', undefined, true);
1002
1224
  },
1003
1225
  stop: async () => {
1004
- abortRequested = true;
1226
+ const run = activeRun;
1227
+ if (run && run.outcome === undefined) {
1228
+ finalizeDeliveryRun(store, run, 'aborted');
1229
+ store.status.set('idle');
1230
+ store.isLoading.set(false);
1231
+ store.error.set(undefined);
1232
+ finishRunTelemetry(run);
1233
+ }
1005
1234
  source.abortRun();
1006
1235
  },
1007
1236
  regenerate: async (assistantMessageIndex) => {
1008
1237
  if (store.isLoading()) {
1009
1238
  throw new Error('Cannot regenerate while agent is loading another response');
1010
1239
  }
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
1240
  const msgs = store.messages();
1018
1241
  const target = msgs[assistantMessageIndex];
1019
1242
  if (!target || target.role !== 'assistant') {
@@ -1039,20 +1262,7 @@ function toAgent(source, options = {}) {
1039
1262
  // agent's internal message list without appending — the trailing user
1040
1263
  // message in `trimmed` becomes the active prompt for the next run.
1041
1264
  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
- }
1265
+ await executeRun('regenerate');
1056
1266
  },
1057
1267
  };
1058
1268
  }
@@ -1062,11 +1272,16 @@ function buildUserMessage(input) {
1062
1272
  const content = typeof input.message === 'string'
1063
1273
  ? input.message
1064
1274
  : input.message.map((b) => b.type === 'text' ? b.text : JSON.stringify(b)).join('');
1065
- return { id: randomId(), role: 'user', content };
1275
+ const id = randomId();
1276
+ return { id, role: 'user', content, delivery: staticDelivery(id) };
1066
1277
  }
1067
1278
  function randomId() {
1068
1279
  return Math.random().toString(36).slice(2);
1069
1280
  }
1281
+ function getTailAssistantMessageId(messages) {
1282
+ const tail = messages[messages.length - 1];
1283
+ return tail?.role === 'assistant' ? tail.id : undefined;
1284
+ }
1070
1285
 
1071
1286
  // SPDX-License-Identifier: MIT
1072
1287
  /**
@@ -1125,6 +1340,8 @@ class FakeAgent extends AbstractAgent {
1125
1340
  reasoningTokens;
1126
1341
  /** Milliseconds between successive token emissions. */
1127
1342
  delayMs;
1343
+ /** Optional deterministic event branches for tests that need exact streams. */
1344
+ script;
1128
1345
  constructor(opts = {}) {
1129
1346
  super();
1130
1347
  this.tokens = opts.tokens ?? [
@@ -1133,11 +1350,14 @@ class FakeAgent extends AbstractAgent {
1133
1350
  ];
1134
1351
  this.reasoningTokens = opts.reasoningTokens ?? [];
1135
1352
  this.delayMs = opts.delayMs ?? 60;
1353
+ this.script = opts.script ?? [];
1136
1354
  }
1137
1355
  run(input) {
1356
+ const scripted = this.scriptedSequence(input);
1357
+ if (scripted)
1358
+ return this.emitSequence(scripted, 30);
1138
1359
  const tokens = this.tokens;
1139
1360
  const reasoningTokens = this.reasoningTokens;
1140
- const delayMs = this.delayMs;
1141
1361
  const messageId = `fake-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1142
1362
  const sequence = [
1143
1363
  { type: EventType.RUN_STARTED, threadId: input.threadId, runId: input.runId },
@@ -1155,6 +1375,22 @@ class FakeAgent extends AbstractAgent {
1155
1375
  }
1156
1376
  sequence.push({ type: EventType.TEXT_MESSAGE_END, messageId });
1157
1377
  sequence.push({ type: EventType.RUN_FINISHED, threadId: input.threadId, runId: input.runId });
1378
+ return this.emitSequence(sequence, 30);
1379
+ }
1380
+ scriptedSequence(input) {
1381
+ if (this.script.length === 0)
1382
+ return undefined;
1383
+ const branch = this.script.find((candidate) => matchesBranch(candidate.when, input));
1384
+ if (!branch)
1385
+ return undefined;
1386
+ return [
1387
+ { type: EventType.RUN_STARTED, threadId: input.threadId, runId: input.runId },
1388
+ ...branch.events,
1389
+ { type: EventType.RUN_FINISHED, threadId: input.threadId, runId: input.runId },
1390
+ ];
1391
+ }
1392
+ emitSequence(sequence, initialDelayMs) {
1393
+ const delayMs = this.delayMs;
1158
1394
  return new Observable((observer) => {
1159
1395
  let cancelled = false;
1160
1396
  let timer;
@@ -1171,7 +1407,7 @@ class FakeAgent extends AbstractAgent {
1171
1407
  // Steady cadence except a tiny initial delay before RUN_STARTED.
1172
1408
  timer = setTimeout(emitNext, delayMs);
1173
1409
  };
1174
- timer = setTimeout(emitNext, 30);
1410
+ timer = setTimeout(emitNext, initialDelayMs);
1175
1411
  return () => {
1176
1412
  cancelled = true;
1177
1413
  if (timer !== undefined)
@@ -1180,6 +1416,26 @@ class FakeAgent extends AbstractAgent {
1180
1416
  });
1181
1417
  }
1182
1418
  }
1419
+ function matchesBranch(when, input) {
1420
+ if (when === 'initial')
1421
+ return !hasToolMessages(input);
1422
+ return hasToolMessageFor(input, when.toolMessageFor);
1423
+ }
1424
+ function hasToolMessages(input) {
1425
+ return (input.messages ?? []).some((message) => isToolMessage(message));
1426
+ }
1427
+ function hasToolMessageFor(input, toolCallId) {
1428
+ return (input.messages ?? []).some((message) => {
1429
+ if (!isToolMessage(message))
1430
+ return false;
1431
+ const record = message;
1432
+ return record['toolCallId'] === toolCallId || record['tool_call_id'] === toolCallId;
1433
+ });
1434
+ }
1435
+ function isToolMessage(message) {
1436
+ const record = message;
1437
+ return record['role'] === 'tool' || record['type'] === 'tool';
1438
+ }
1183
1439
 
1184
1440
  /**
1185
1441
  * Registers an in-process FakeAgent under AGENT.