@threadplane/langgraph 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.
@@ -3,9 +3,9 @@ import { signal, Injectable, InjectionToken, computed, inject, DestroyRef, isSig
3
3
  import { toObservable, toSignal } from '@angular/core/rxjs-interop';
4
4
  import { takeUntil, Subject, BehaviorSubject, of, throttleTime, asyncScheduler } from 'rxjs';
5
5
  import { takeUntil as takeUntil$1 } from 'rxjs/operators';
6
+ import { staticDelivery, completeDelivery, streamingDelivery, toAgentError, isAbortError, AgentError, AGENT_ERROR_MESSAGES, selectPendingClientToolCalls, mockAgent } from '@threadplane/chat';
6
7
  import { Client } from '@langchain/langgraph-sdk';
7
8
  import { getToolCallsWithResults } from '@langchain/langgraph-sdk/utils';
8
- import { toAgentError, isAbortError, AgentError, AGENT_ERROR_MESSAGES, mockAgent } from '@threadplane/chat';
9
9
 
10
10
  // SPDX-License-Identifier: MIT
11
11
  /**
@@ -226,6 +226,11 @@ function isRecord$2(value) {
226
226
  }
227
227
 
228
228
  const DEFAULT_SUBAGENT_TOOL_NAMES = ['task'];
229
+ let subagentGenerationSequence = 0;
230
+ function createSubagentGeneration() {
231
+ subagentGenerationSequence += 1;
232
+ return `subagent-${subagentGenerationSequence}-${Math.random().toString(36).slice(2, 10)}`;
233
+ }
229
234
  /**
230
235
  * Lightweight Angular adapter for LangGraph subagent stream state.
231
236
  *
@@ -272,6 +277,7 @@ class SubagentTracker {
272
277
  const existing = this.subagents.get(id);
273
278
  this.subagents.set(id, {
274
279
  id,
280
+ generation: existing?.generation ?? createSubagentGeneration(),
275
281
  status: existing?.status ?? 'pending',
276
282
  toolCall: {
277
283
  id,
@@ -404,6 +410,22 @@ class SubagentTracker {
404
410
  });
405
411
  this.onSubagentChange?.();
406
412
  }
413
+ getMessageDelivery(toolCallId, message) {
414
+ const id = getMessageId(message) ?? toolCallId;
415
+ const raw = message;
416
+ const type = typeof message._getType === 'function' ? message._getType() : raw['type'];
417
+ if (type !== 'ai' && type !== 'assistant' && type !== 'AIMessage' && type !== 'AIMessageChunk') {
418
+ return staticDelivery(id);
419
+ }
420
+ const subagent = this.subagents.get(toolCallId);
421
+ if (!subagent)
422
+ return staticDelivery(id);
423
+ if (subagent.status === 'error')
424
+ return completeDelivery(subagent.generation, 'error');
425
+ if (subagent.status === 'complete')
426
+ return completeDelivery(subagent.generation, 'success');
427
+ return streamingDelivery(subagent.generation);
428
+ }
407
429
  retryPendingMatches() {
408
430
  for (const [namespaceId, description] of this.pendingMatches) {
409
431
  if (this.matchSubgraphToSubagent(namespaceId, description)) {
@@ -537,15 +559,19 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
537
559
  let abortController = null;
538
560
  let historyAbortController = null;
539
561
  let hasSeenThreadId = false;
540
- /** True when the current abort was user-initiated (via stop()). Reset at the start of every new runStream(). */
541
- let userAbortRequested = false;
562
+ const userAbortedControllers = new WeakSet();
542
563
  const toolProgressMap = new Map();
543
564
  // Message ids whose content is known-final (installed by a canonical
544
565
  // replacement). Late streamed deltas for these ids are stale stragglers and
545
566
  // are ignored — decided by identity, never by comparing text to text.
546
567
  const canonicalMessageIds = new Set();
547
568
  const queuedRuns = [];
548
- let drainingQueue = false;
569
+ let queueDrainEpoch = 0;
570
+ let activeQueueDrainEpoch = null;
571
+ let attemptSequence = 0;
572
+ const messageDeliveries = new Map();
573
+ const deliveryRevision = signal(0, ...(ngDevMode ? [{ debugName: "deliveryRevision" }] : []));
574
+ let activeAttempt = null;
549
575
  const subagentManager = new SubagentTracker({
550
576
  subagentToolNames: options.subagentToolNames,
551
577
  onSubagentChange: publishSubagents,
@@ -565,6 +591,135 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
565
591
  * resetThreadState() and on bridge teardown.
566
592
  */
567
593
  const reasoningTimingMap = new Map();
594
+ function notifyDeliveryChange() {
595
+ deliveryRevision.update(revision => revision + 1);
596
+ }
597
+ function beginAttempt(allowBaselineTail = false) {
598
+ if (activeAttempt?.awaitingFinalSync) {
599
+ historyAbortController?.abort();
600
+ }
601
+ if (activeAttempt && !activeAttempt.terminalOutcome) {
602
+ finalizeAttempt(activeAttempt, 'interrupted');
603
+ }
604
+ const baselineMessageIds = new Set();
605
+ for (const message of subjects.messages$.value) {
606
+ const id = message['id'];
607
+ if (typeof id === 'string')
608
+ baselineMessageIds.add(id);
609
+ }
610
+ attemptSequence += 1;
611
+ const attempt = {
612
+ generation: `attempt-${attemptSequence}-${Math.random().toString(36).slice(2, 10)}`,
613
+ messageIds: new Set(),
614
+ finalizedMessageIds: new Set(),
615
+ baselineMessageIds,
616
+ eligibleBaselineAssistantId: allowBaselineTail
617
+ ? getTailAssistantMessageId(subjects.messages$.value)
618
+ : undefined,
619
+ sawAssistantChunk: false,
620
+ currentStepHasTerminalEvidence: false,
621
+ awaitingFinalSync: false,
622
+ };
623
+ activeAttempt = attempt;
624
+ return attempt;
625
+ }
626
+ function isCurrentExecution(controller, attempt) {
627
+ return abortController === controller && activeAttempt === attempt;
628
+ }
629
+ function setDelivery(id, delivery) {
630
+ const previous = messageDeliveries.get(id);
631
+ if (previous?.generation === delivery.generation
632
+ && previous.phase === delivery.phase
633
+ && (previous.phase !== 'complete' || delivery.phase !== 'complete' || previous.outcome === delivery.outcome)) {
634
+ return;
635
+ }
636
+ messageDeliveries.set(id, delivery);
637
+ notifyDeliveryChange();
638
+ }
639
+ function finalizeMessage(attempt, id, outcome) {
640
+ setDelivery(id, completeDelivery(attempt.generation, outcome));
641
+ attempt.finalizedMessageIds.add(id);
642
+ }
643
+ function finalizeAttempt(attempt, outcome) {
644
+ if (attempt.terminalOutcome)
645
+ return;
646
+ attempt.terminalOutcome = outcome;
647
+ for (const id of attempt.messageIds) {
648
+ if (attempt.finalizedMessageIds.has(id))
649
+ continue;
650
+ finalizeMessage(attempt, id, outcome);
651
+ }
652
+ }
653
+ function finishOutcome(attempt) {
654
+ return attempt.terminalOutcome
655
+ ?? (attempt.currentStepHasTerminalEvidence || !attempt.sawAssistantChunk ? 'success' : 'interrupted');
656
+ }
657
+ async function finalizeClosedAttempt(controller, attempt) {
658
+ if (attempt.terminalOutcome)
659
+ return attempt.terminalOutcome;
660
+ const outcome = finishOutcome(attempt);
661
+ attempt.awaitingFinalSync = true;
662
+ try {
663
+ await refreshHistory(true, () => isCurrentExecution(controller, attempt) && !attempt.terminalOutcome);
664
+ }
665
+ finally {
666
+ attempt.awaitingFinalSync = false;
667
+ }
668
+ if (!isCurrentExecution(controller, attempt))
669
+ return null;
670
+ if (!attempt.terminalOutcome)
671
+ finalizeAttempt(attempt, outcome);
672
+ return attempt.terminalOutcome ?? outcome;
673
+ }
674
+ function trackAssistantMessages(messages) {
675
+ const attempt = activeAttempt;
676
+ if (!attempt || attempt.terminalOutcome)
677
+ return;
678
+ const assistantMessages = messages.filter(message => {
679
+ const raw = message;
680
+ const type = normalizeMessageType(typeof message._getType === 'function' ? message._getType() : raw['type']);
681
+ const id = typeof raw['id'] === 'string' ? raw['id'] : undefined;
682
+ return type === 'ai' && id && !attempt.finalizedMessageIds.has(id);
683
+ });
684
+ const newAssistantMessages = assistantMessages.filter(message => {
685
+ const id = message['id'];
686
+ return typeof id === 'string' && !attempt.baselineMessageIds.has(id);
687
+ });
688
+ const currentStepMessages = newAssistantMessages.length > 0
689
+ ? newAssistantMessages
690
+ : assistantMessages.filter(message => message['id'] === attempt.eligibleBaselineAssistantId);
691
+ for (const message of currentStepMessages) {
692
+ const id = message['id'];
693
+ if (attempt.currentAssistantMessageId && attempt.currentAssistantMessageId !== id) {
694
+ finalizeMessage(attempt, attempt.currentAssistantMessageId, 'success');
695
+ attempt.currentStepHasTerminalEvidence = false;
696
+ }
697
+ attempt.currentAssistantMessageId = id;
698
+ attempt.messageIds.add(id);
699
+ attempt.sawAssistantChunk = true;
700
+ setDelivery(id, streamingDelivery(attempt.generation));
701
+ }
702
+ }
703
+ function invalidateQueueDrain() {
704
+ queueDrainEpoch += 1;
705
+ activeQueueDrainEpoch = null;
706
+ }
707
+ function markNormalTerminal(event) {
708
+ const attempt = activeAttempt;
709
+ if (!attempt
710
+ || attempt.terminalOutcome
711
+ || !attempt.sawAssistantChunk
712
+ || (getEventNamespace(event)?.length ?? 0) > 0)
713
+ return;
714
+ const baseType = getBaseEventType(event.type);
715
+ // These are the canonical state/snapshot signals available in the current
716
+ // transport contract. Iterator close alone is deliberately not terminal
717
+ // evidence: after assistant chunks, a close without one of these markers
718
+ // is classified as interrupted.
719
+ if (baseType === 'values' || baseType === 'messages/complete' || baseType === 'checkpoints') {
720
+ attempt.currentStepHasTerminalEvidence = true;
721
+ }
722
+ }
568
723
  function resetThreadState() {
569
724
  historyAbortController?.abort();
570
725
  subjects.values$.next({});
@@ -584,10 +739,19 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
584
739
  subagentManager.clear();
585
740
  reasoningTimingMap.clear();
586
741
  canonicalMessageIds.clear();
742
+ if (activeAttempt && !activeAttempt.terminalOutcome) {
743
+ finalizeAttempt(activeAttempt, 'interrupted');
744
+ }
745
+ messageDeliveries.clear();
746
+ activeAttempt = null;
747
+ notifyDeliveryChange();
587
748
  }
588
749
  function setThreadId(id, resetState) {
589
750
  if (resetState) {
751
+ invalidateQueueDrain();
590
752
  abortController?.abort();
753
+ lastPayload = null;
754
+ lastOptions = undefined;
591
755
  }
592
756
  currentThreadId = id;
593
757
  if (resetState) {
@@ -602,11 +766,18 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
602
766
  setThreadId(id, shouldReset);
603
767
  });
604
768
  destroy$.subscribe(() => {
769
+ invalidateQueueDrain();
605
770
  abortController?.abort();
606
771
  historyAbortController?.abort();
607
772
  reasoningTimingMap.clear();
773
+ if (activeAttempt && !activeAttempt.terminalOutcome) {
774
+ finalizeAttempt(activeAttempt, 'interrupted');
775
+ }
776
+ messageDeliveries.clear();
777
+ activeAttempt = null;
778
+ notifyDeliveryChange();
608
779
  });
609
- async function refreshHistory(force = false) {
780
+ async function refreshHistory(force = false, isRelevant = () => true) {
610
781
  const getHistory = transport.getHistory?.bind(transport);
611
782
  if (!currentThreadId || !getHistory)
612
783
  return;
@@ -616,8 +787,8 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
616
787
  const threadId = currentThreadId;
617
788
  subjects.isThreadLoading$.next(true);
618
789
  try {
619
- const history = await getHistory(threadId, controller.signal);
620
- if (!controller.signal.aborted && currentThreadId === threadId) {
790
+ const history = await waitForHistory(getHistory(threadId, controller.signal), controller.signal);
791
+ if (!controller.signal.aborted && currentThreadId === threadId && isRelevant()) {
621
792
  subjects.history$.next(history);
622
793
  // Project the latest checkpoint into messages$ + values$:
623
794
  // - On first connect (`force=false`): only when messages$ is
@@ -636,7 +807,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
636
807
  // canonical surface for them; keeping a duplicate in values$
637
808
  // would confuse downstream consumers reading both subjects.
638
809
  delete restoredValues.messages;
639
- subjects.messages$.next(restoredMessages);
810
+ subjects.messages$.next(preserveIds(subjects.messages$.value, restoredMessages));
640
811
  subjects.values$.next(restoredValues);
641
812
  // Rebuild derived subjects from the new authoritative messages$.
642
813
  // Tool-call results displayed by chat-tool-calls come from
@@ -653,7 +824,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
653
824
  }
654
825
  }
655
826
  catch (err) {
656
- if (!controller.signal.aborted && err?.name !== 'AbortError') {
827
+ if (!controller.signal.aborted && isRelevant() && err?.name !== 'AbortError') {
657
828
  subjects.error$.next(toAgentError(err));
658
829
  }
659
830
  }
@@ -664,6 +835,30 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
664
835
  }
665
836
  }
666
837
  }
838
+ function waitForHistory(history, signal) {
839
+ if (signal.aborted)
840
+ return Promise.reject(createHistoryAbortError());
841
+ return new Promise((resolve, reject) => {
842
+ const onAbort = () => {
843
+ cleanup();
844
+ reject(createHistoryAbortError());
845
+ };
846
+ const cleanup = () => signal.removeEventListener('abort', onAbort);
847
+ signal.addEventListener('abort', onAbort, { once: true });
848
+ history.then(value => {
849
+ cleanup();
850
+ resolve(value);
851
+ }, error => {
852
+ cleanup();
853
+ reject(error);
854
+ });
855
+ });
856
+ }
857
+ function createHistoryAbortError() {
858
+ const error = new Error('History refresh aborted.');
859
+ error.name = 'AbortError';
860
+ return error;
861
+ }
667
862
  function publishQueue() {
668
863
  subjects.queue$.next(createQueueSnapshot());
669
864
  }
@@ -719,24 +914,33 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
719
914
  await Promise.all(entries.map(entry => cancelRun(entry.threadId, entry.id, new AbortController().signal)));
720
915
  }
721
916
  async function drainQueue() {
722
- if (drainingQueue || queuedRuns.length === 0)
917
+ if (activeQueueDrainEpoch !== null || queuedRuns.length === 0)
723
918
  return;
724
- drainingQueue = true;
919
+ queueDrainEpoch += 1;
920
+ const drainEpoch = queueDrainEpoch;
921
+ activeQueueDrainEpoch = drainEpoch;
725
922
  try {
726
923
  while (queuedRuns.length > 0) {
924
+ if (activeQueueDrainEpoch !== drainEpoch)
925
+ return;
727
926
  const entry = queuedRuns.shift();
728
927
  publishQueue();
729
928
  if (!entry || !transport.joinStream)
730
929
  continue;
731
- await joinQueuedRun(entry);
930
+ await joinQueuedRun(entry, drainEpoch);
732
931
  }
733
932
  }
734
933
  finally {
735
- drainingQueue = false;
934
+ if (activeQueueDrainEpoch === drainEpoch)
935
+ activeQueueDrainEpoch = null;
736
936
  }
737
937
  }
738
- async function joinQueuedRun(entry) {
739
- abortController = new AbortController();
938
+ async function joinQueuedRun(entry, drainEpoch) {
939
+ if (activeQueueDrainEpoch !== drainEpoch)
940
+ return;
941
+ const controller = new AbortController();
942
+ abortController = controller;
943
+ const attempt = beginAttempt(true);
740
944
  const startedAt = Date.now();
741
945
  captureRuntimeRequestTelemetry('join_queued');
742
946
  captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_started', telemetryProperties);
@@ -746,19 +950,22 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
746
950
  subjects.status$.next(ResourceStatus.Loading);
747
951
  try {
748
952
  const iter = transport.joinStream
749
- ? transport.joinStream(entry.threadId, entry.id, undefined, abortController.signal)
953
+ ? transport.joinStream(entry.threadId, entry.id, undefined, controller.signal)
750
954
  : [];
751
955
  for await (const event of iter) {
752
- if (abortController.signal.aborted)
956
+ if (controller.signal.aborted || !isCurrentExecution(controller, attempt))
753
957
  break;
754
958
  processEvent(event);
755
959
  }
756
- if (!abortController.signal.aborted) {
757
- subjects.status$.next(ResourceStatus.Resolved);
758
- // force=true: rehydrate from server-authoritative state so any
759
- // post-process node mutations (RemoveMessage, id-match content
760
- // replacement) reflected on the server are picked up client-side.
761
- await refreshHistory(true);
960
+ if (!isCurrentExecution(controller, attempt))
961
+ return;
962
+ const outcome = await finalizeClosedAttempt(controller, attempt);
963
+ if (outcome === null)
964
+ return;
965
+ if (!controller.signal.aborted) {
966
+ if (outcome !== 'error') {
967
+ subjects.status$.next(ResourceStatus.Resolved);
968
+ }
762
969
  captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_ended', {
763
970
  ...telemetryProperties,
764
971
  durationMs: Date.now() - startedAt,
@@ -766,19 +973,37 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
766
973
  }
767
974
  }
768
975
  catch (err) {
769
- subjects.error$.next(toAgentError(err));
770
- subjects.status$.next(ResourceStatus.Error);
771
- captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_errored', {
772
- ...telemetryProperties,
773
- durationMs: Date.now() - startedAt,
774
- errorClass: agentRuntimeTelemetryErrorClass(err),
775
- });
976
+ if (!isCurrentExecution(controller, attempt))
977
+ return;
978
+ if (attempt.terminalOutcome)
979
+ return;
980
+ if (isAbortError(err) && userAbortedControllers.has(controller)) {
981
+ finalizeAttempt(attempt, 'aborted');
982
+ subjects.error$.next(undefined);
983
+ subjects.status$.next(ResourceStatus.Idle);
984
+ }
985
+ else {
986
+ finalizeAttempt(attempt, attempt.sawAssistantChunk ? 'interrupted' : 'error');
987
+ subjects.error$.next(toAgentError(err));
988
+ subjects.status$.next(ResourceStatus.Error);
989
+ captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_errored', {
990
+ ...telemetryProperties,
991
+ durationMs: Date.now() - startedAt,
992
+ errorClass: agentRuntimeTelemetryErrorClass(err),
993
+ });
994
+ }
995
+ }
996
+ finally {
997
+ if (abortController === controller)
998
+ abortController = null;
776
999
  }
777
1000
  }
778
1001
  async function runStream(payload, opts, requestType = 'submit') {
1002
+ invalidateQueueDrain();
779
1003
  abortController?.abort();
780
- abortController = new AbortController();
781
- userAbortRequested = false;
1004
+ const controller = new AbortController();
1005
+ abortController = controller;
1006
+ const attempt = beginAttempt(requestType === 'resubmit' || (isRecord$1(opts?.command) && 'resume' in opts.command));
782
1007
  const startedAt = Date.now();
783
1008
  captureRuntimeRequestTelemetry(requestType);
784
1009
  captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_started', telemetryProperties);
@@ -788,7 +1013,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
788
1013
  subjects.toolProgress$.next([]);
789
1014
  toolProgressMap.clear();
790
1015
  canonicalMessageIds.clear();
791
- lastPayload = payload;
1016
+ lastPayload = payload ?? null;
792
1017
  lastOptions = opts;
793
1018
  // Tracks whether at least one stream event has been processed this run.
794
1019
  // Used to distinguish a mid-stream network interruption (kind:'interrupted')
@@ -813,31 +1038,43 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
813
1038
  subjects.messages$.next([...existing, ...stamped]);
814
1039
  }
815
1040
  try {
816
- const iter = transport.stream(options.assistantId, currentThreadId, payload, opts?.signal ?? abortController.signal, opts);
1041
+ const iter = transport.stream(options.assistantId, currentThreadId, payload, opts?.signal ?? controller.signal, opts);
817
1042
  for await (const event of iter) {
818
- if (abortController.signal.aborted)
1043
+ if (controller.signal.aborted || !isCurrentExecution(controller, attempt))
819
1044
  break;
820
1045
  streamingStarted = true;
821
1046
  processEvent(event);
822
1047
  }
823
- if (!abortController.signal.aborted) {
824
- subjects.status$.next(ResourceStatus.Resolved);
825
- // force=true: see refreshHistory comment — server state is
826
- // authoritative after run completion.
827
- await refreshHistory(true);
1048
+ if (!isCurrentExecution(controller, attempt))
1049
+ return finishOutcome(attempt);
1050
+ const outcome = await finalizeClosedAttempt(controller, attempt);
1051
+ if (outcome === null)
1052
+ return finishOutcome(attempt);
1053
+ if (!controller.signal.aborted) {
1054
+ if (outcome !== 'error') {
1055
+ subjects.status$.next(ResourceStatus.Resolved);
1056
+ }
828
1057
  await drainQueue();
829
1058
  captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_ended', {
830
1059
  ...telemetryProperties,
831
1060
  durationMs: Date.now() - startedAt,
832
1061
  });
833
1062
  }
1063
+ return outcome;
834
1064
  }
835
1065
  catch (err) {
836
- if (isAbortError(err) && userAbortRequested) {
1066
+ if (!isCurrentExecution(controller, attempt))
1067
+ return finishOutcome(attempt);
1068
+ if (attempt.terminalOutcome)
1069
+ return attempt.terminalOutcome;
1070
+ if (isAbortError(err) && userAbortedControllers.has(controller)) {
1071
+ finalizeAttempt(attempt, 'aborted');
837
1072
  // User explicitly called stop() — treat as graceful idle, not an error.
1073
+ subjects.error$.next(undefined);
838
1074
  subjects.status$.next(ResourceStatus.Idle);
839
1075
  }
840
1076
  else if (isAbortError(err)) {
1077
+ finalizeAttempt(attempt, attempt.sawAssistantChunk ? 'interrupted' : 'error');
841
1078
  // A non-user-requested abort: interrupted if a stream had started, else a
842
1079
  // connect-phase failure. Never "aborted" (that's reserved for user stop).
843
1080
  const e = streamingStarted
@@ -852,6 +1089,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
852
1089
  });
853
1090
  }
854
1091
  else {
1092
+ finalizeAttempt(attempt, attempt.sawAssistantChunk ? 'interrupted' : 'error');
855
1093
  subjects.error$.next(toAgentError(err));
856
1094
  subjects.status$.next(ResourceStatus.Error);
857
1095
  captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_errored', {
@@ -860,11 +1098,19 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
860
1098
  errorClass: agentRuntimeTelemetryErrorClass(err),
861
1099
  });
862
1100
  }
1101
+ return finishOutcome(attempt);
1102
+ }
1103
+ finally {
1104
+ if (abortController === controller)
1105
+ abortController = null;
863
1106
  }
864
1107
  }
865
1108
  function processEvent(event) {
866
1109
  const baseType = getBaseEventType(event.type);
867
1110
  const namespace = getEventNamespace(event);
1111
+ if (baseType === 'checkpoints' || baseType === 'messages/complete') {
1112
+ markNormalTerminal(event);
1113
+ }
868
1114
  if (isMessagesEvent(event.type)) {
869
1115
  const msgs = normalizeMessages(event);
870
1116
  if (!msgs)
@@ -887,8 +1133,22 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
887
1133
  // Partial and message-tuple events are incremental. Merge them by id
888
1134
  // so optimistic human messages and earlier tool messages are preserved.
889
1135
  if (event.type === 'messages/partial' || event.messageMetadata) {
1136
+ if (!isTranscriptMessageEvent(event, options.transcriptNodeNames)) {
1137
+ storeMessageMetadata(normalized, event);
1138
+ syncSubagentsFromMessages(normalized);
1139
+ return;
1140
+ }
890
1141
  const mode = event.messageMetadata ? 'delta' : 'snapshot';
891
- subjects.messages$.next(mergeMessages(subjects.messages$.value, normalized, reasoningTimingMap, mode, canonicalMessageIds));
1142
+ const affectedMessageIds = new Set();
1143
+ const merged = mergeMessages(subjects.messages$.value, normalized, reasoningTimingMap, mode, canonicalMessageIds, affectedMessageIds, activeAttempt?.currentAssistantMessageId !== undefined
1144
+ && activeAttempt.currentStepHasTerminalEvidence !== true);
1145
+ subjects.messages$.next(merged);
1146
+ if (!isSubagentNamespace(namespace)) {
1147
+ trackAssistantMessages(merged.filter(message => {
1148
+ const id = message['id'];
1149
+ return typeof id === 'string' && affectedMessageIds.has(id);
1150
+ }));
1151
+ }
892
1152
  if (isLgTraceEnabled()) {
893
1153
  const msgs = subjects.messages$.value;
894
1154
  const last = msgs[msgs.length - 1];
@@ -903,8 +1163,17 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
903
1163
  else {
904
1164
  // Preserve existing ids by content so the final-id swap doesn't
905
1165
  // tear down the chat-message DOM (and its streaming-md renderer).
906
- subjects.messages$.next(preserveIds(subjects.messages$.value, normalized));
1166
+ const affectedMessageIds = new Set();
1167
+ const preserved = preserveIds(subjects.messages$.value, normalized, affectedMessageIds);
1168
+ subjects.messages$.next(preserved);
1169
+ if (!isSubagentNamespace(namespace)) {
1170
+ trackAssistantMessages(preserved.filter(message => {
1171
+ const id = message['id'];
1172
+ return typeof id === 'string' && affectedMessageIds.has(id);
1173
+ }));
1174
+ }
907
1175
  }
1176
+ markNormalTerminal(event);
908
1177
  storeMessageMetadata(normalized, event);
909
1178
  syncSubagentsFromMessages(normalized);
910
1179
  syncToolCallsFromMessages();
@@ -920,6 +1189,15 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
920
1189
  updateSubagentValues(namespace, vals);
921
1190
  break;
922
1191
  }
1192
+ if ((namespace?.length ?? 0) === 0) {
1193
+ if (hasInterrupts(vals)) {
1194
+ if (activeAttempt)
1195
+ finalizeAttempt(activeAttempt, 'paused');
1196
+ }
1197
+ else {
1198
+ markNormalTerminal(event);
1199
+ }
1200
+ }
923
1201
  if (vals != null) {
924
1202
  extractInterrupts(vals, subjects);
925
1203
  subjects.values$.next(vals);
@@ -988,13 +1266,19 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
988
1266
  break;
989
1267
  }
990
1268
  case 'error':
1269
+ if (activeAttempt)
1270
+ finalizeAttempt(activeAttempt, 'error');
991
1271
  subjects.error$.next(toAgentError(event['error']));
992
1272
  subjects.status$.next(ResourceStatus.Error);
993
1273
  break;
994
1274
  case 'interrupt':
1275
+ if (activeAttempt)
1276
+ finalizeAttempt(activeAttempt, 'paused');
995
1277
  subjects.interrupt$.next(event['interrupt']);
996
1278
  break;
997
1279
  case 'interrupts':
1280
+ if (activeAttempt)
1281
+ finalizeAttempt(activeAttempt, 'paused');
998
1282
  subjects.interrupts$.next(event['interrupts']);
999
1283
  break;
1000
1284
  case 'custom': {
@@ -1127,19 +1411,24 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
1127
1411
  submit: async (payload, opts) => {
1128
1412
  if (opts?.multitaskStrategy === 'enqueue' && subjects.status$.value === ResourceStatus.Loading) {
1129
1413
  await enqueueRun(payload, opts);
1130
- return;
1414
+ return 'success';
1131
1415
  }
1132
- await runStream(payload, opts);
1416
+ return runStream(payload, opts);
1133
1417
  },
1134
1418
  stop: async () => {
1135
- userAbortRequested = true;
1419
+ invalidateQueueDrain();
1420
+ const shouldAbortAttempt = Boolean(abortController && activeAttempt && !activeAttempt.terminalOutcome);
1421
+ if (shouldAbortAttempt && abortController)
1422
+ userAbortedControllers.add(abortController);
1423
+ if (shouldAbortAttempt && activeAttempt)
1424
+ finalizeAttempt(activeAttempt, 'aborted');
1136
1425
  abortController?.abort();
1426
+ if (activeAttempt?.awaitingFinalSync)
1427
+ historyAbortController?.abort();
1137
1428
  await clearQueue();
1138
- // Note: status is set to Idle by the runStream() catch when it sees
1139
- // isAbortError && userAbortRequested. The explicit set here handles
1140
- // the case where stop() is called when no stream is active (so the
1141
- // catch never fires) or when clearQueue() raised an error.
1142
- if (subjects.status$.value !== ResourceStatus.Idle) {
1429
+ // Set Idle synchronously for an active user cancellation. Attempts that
1430
+ // already reached a terminal outcome retain their existing status.
1431
+ if (shouldAbortAttempt && subjects.status$.value !== ResourceStatus.Idle) {
1143
1432
  subjects.status$.next(ResourceStatus.Idle);
1144
1433
  }
1145
1434
  },
@@ -1149,8 +1438,12 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
1149
1438
  joinStream: async (runId, lastEventId) => {
1150
1439
  if (!currentThreadId)
1151
1440
  return;
1441
+ invalidateQueueDrain();
1152
1442
  abortController?.abort();
1153
- abortController = new AbortController();
1443
+ const controller = new AbortController();
1444
+ abortController = controller;
1445
+ const attempt = beginAttempt(true);
1446
+ const threadId = currentThreadId;
1154
1447
  const startedAt = Date.now();
1155
1448
  captureRuntimeRequestTelemetry('join');
1156
1449
  captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_started', telemetryProperties);
@@ -1158,34 +1451,61 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
1158
1451
  subjects.toolProgress$.next([]);
1159
1452
  toolProgressMap.clear();
1160
1453
  subjects.status$.next(ResourceStatus.Loading);
1454
+ subjects.error$.next(undefined);
1161
1455
  try {
1162
1456
  const iter = transport.joinStream
1163
- ? transport.joinStream(currentThreadId, runId, lastEventId, abortController.signal)
1457
+ ? transport.joinStream(threadId, runId, lastEventId, controller.signal)
1164
1458
  : [];
1165
1459
  for await (const event of iter) {
1460
+ if (controller.signal.aborted || !isCurrentExecution(controller, attempt))
1461
+ break;
1166
1462
  processEvent(event);
1167
1463
  }
1168
- subjects.status$.next(ResourceStatus.Resolved);
1169
- await refreshHistory();
1170
- captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_ended', {
1171
- ...telemetryProperties,
1172
- durationMs: Date.now() - startedAt,
1173
- });
1464
+ if (!isCurrentExecution(controller, attempt))
1465
+ return;
1466
+ const outcome = await finalizeClosedAttempt(controller, attempt);
1467
+ if (outcome === null)
1468
+ return;
1469
+ if (!controller.signal.aborted) {
1470
+ if (outcome !== 'error') {
1471
+ subjects.status$.next(ResourceStatus.Resolved);
1472
+ }
1473
+ captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_ended', {
1474
+ ...telemetryProperties,
1475
+ durationMs: Date.now() - startedAt,
1476
+ });
1477
+ }
1174
1478
  }
1175
1479
  catch (err) {
1176
- subjects.error$.next(toAgentError(err));
1177
- subjects.status$.next(ResourceStatus.Error);
1178
- captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_errored', {
1179
- ...telemetryProperties,
1180
- durationMs: Date.now() - startedAt,
1181
- errorClass: agentRuntimeTelemetryErrorClass(err),
1182
- });
1480
+ if (!isCurrentExecution(controller, attempt))
1481
+ return;
1482
+ if (attempt.terminalOutcome)
1483
+ return;
1484
+ if (isAbortError(err) && userAbortedControllers.has(controller)) {
1485
+ finalizeAttempt(attempt, 'aborted');
1486
+ subjects.error$.next(undefined);
1487
+ subjects.status$.next(ResourceStatus.Idle);
1488
+ }
1489
+ else {
1490
+ finalizeAttempt(attempt, attempt.sawAssistantChunk ? 'interrupted' : 'error');
1491
+ subjects.error$.next(toAgentError(err));
1492
+ subjects.status$.next(ResourceStatus.Error);
1493
+ captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_errored', {
1494
+ ...telemetryProperties,
1495
+ durationMs: Date.now() - startedAt,
1496
+ errorClass: agentRuntimeTelemetryErrorClass(err),
1497
+ });
1498
+ }
1499
+ }
1500
+ finally {
1501
+ if (abortController === controller)
1502
+ abortController = null;
1183
1503
  }
1184
1504
  },
1185
1505
  resubmitLast: async () => {
1186
- if (lastPayload !== null) {
1187
- await runStream(lastPayload, lastOptions, 'resubmit');
1188
- }
1506
+ if (lastPayload === null)
1507
+ return 'not-started';
1508
+ return runStream(lastPayload, lastOptions, 'resubmit');
1189
1509
  },
1190
1510
  getReasoningDurationMs: (id) => {
1191
1511
  const entry = reasoningTimingMap.get(id);
@@ -1195,6 +1515,9 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
1195
1515
  return undefined;
1196
1516
  return entry.endedAt - entry.startedAt;
1197
1517
  },
1518
+ getMessageDelivery: (id) => messageDeliveries.get(id) ?? staticDelivery(id),
1519
+ getSubagentMessageDelivery: (toolCallId, message) => subagentManager.getMessageDelivery(toolCallId, message),
1520
+ deliveryRevision,
1198
1521
  updateState: async (values, opts) => {
1199
1522
  // No-op when there is no thread yet or the transport doesn't support
1200
1523
  // updateState (e.g. MockAgentTransport in unit tests without a threadId).
@@ -1208,6 +1531,14 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
1208
1531
  },
1209
1532
  };
1210
1533
  }
1534
+ function isTranscriptMessageEvent(event, transcriptNodeNames) {
1535
+ if (!transcriptNodeNames || transcriptNodeNames.length === 0)
1536
+ return true;
1537
+ const node = event.messageMetadata?.['langgraph_node'];
1538
+ if (typeof node !== 'string')
1539
+ return true;
1540
+ return transcriptNodeNames.includes(node);
1541
+ }
1211
1542
  /**
1212
1543
  * Extracts the payload data from a normalized SDK event.
1213
1544
  *
@@ -1242,6 +1573,12 @@ function extractInterrupts(payload, subjects) {
1242
1573
  subjects.interrupts$.next([]);
1243
1574
  }
1244
1575
  }
1576
+ function hasInterrupts(payload) {
1577
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload))
1578
+ return false;
1579
+ const raw = payload['__interrupt__'];
1580
+ return Array.isArray(raw) && raw.length > 0;
1581
+ }
1245
1582
  /**
1246
1583
  * Projects pending interrupts from the latest history checkpoint onto the
1247
1584
  * interrupt$ / interrupts$ subjects. ThreadState exposes interrupts under
@@ -1337,7 +1674,7 @@ function normalizeMessages(event) {
1337
1674
  * carry the full text we collapse them, keeping the older slot's id so
1338
1675
  * track-by-id stays stable in the chat list.
1339
1676
  */
1340
- function collapseAdjacentAi(messages) {
1677
+ function collapseAdjacentAi(messages, affectedMessageIds, allowCrossIdAiMerge = true) {
1341
1678
  if (messages.length < 2)
1342
1679
  return messages;
1343
1680
  const out = [];
@@ -1352,13 +1689,21 @@ function collapseAdjacentAi(messages) {
1352
1689
  if (lastType === 'ai' && msgType === 'ai') {
1353
1690
  const lastText = extractText(last.content);
1354
1691
  const msgText = extractText(msg.content);
1355
- if (lastText.length === 0
1692
+ const lastRaw = last;
1693
+ const msgRaw = msg;
1694
+ const differentIds = lastRaw['id'] !== msgRaw['id'];
1695
+ if (!(differentIds && !allowCrossIdAiMerge) && (lastText.length === 0
1356
1696
  || msgText.length === 0
1357
1697
  || lastText === msgText
1358
1698
  || lastText.startsWith(msgText)
1359
- || msgText.startsWith(lastText)) {
1699
+ || msgText.startsWith(lastText))) {
1360
1700
  // Keep the longer content; preserve last (older) id and metadata.
1361
1701
  const longerText = msgText.length >= lastText.length ? msgText : lastText;
1702
+ const lastId = last['id'];
1703
+ const msgId = msg['id'];
1704
+ if (typeof msgId === 'string' && affectedMessageIds?.delete(msgId) && typeof lastId === 'string') {
1705
+ affectedMessageIds.add(lastId);
1706
+ }
1362
1707
  out[out.length - 1] = { ...last, content: longerText };
1363
1708
  continue;
1364
1709
  }
@@ -1367,7 +1712,7 @@ function collapseAdjacentAi(messages) {
1367
1712
  }
1368
1713
  return out;
1369
1714
  }
1370
- function mergeMessages(existing, incoming, reasoningTimingMap, mode = 'snapshot', canonicalMessageIds) {
1715
+ function mergeMessages(existing, incoming, reasoningTimingMap, mode = 'snapshot', canonicalMessageIds, affectedMessageIds, allowCrossIdAiMerge = true) {
1371
1716
  const merged = [...existing];
1372
1717
  for (const msg of incoming) {
1373
1718
  const rawIn = msg;
@@ -1381,6 +1726,9 @@ function mergeMessages(existing, incoming, reasoningTimingMap, mode = 'snapshot'
1381
1726
  // prevents DOM teardown + animation restarts mid-stream.
1382
1727
  if (idx < 0) {
1383
1728
  idx = findContentMatch(merged, msg);
1729
+ if (idx >= 0 && !canMergeCrossIdAi(merged[idx], msg, allowCrossIdAiMerge)) {
1730
+ idx = -1;
1731
+ }
1384
1732
  }
1385
1733
  // When an AIMessageChunk arrives without an id-match or content-prefix
1386
1734
  // match, treat the trailing AI message as its accumulator. The
@@ -1395,6 +1743,8 @@ function mergeMessages(existing, incoming, reasoningTimingMap, mode = 'snapshot'
1395
1743
  ? merged[i]._getType()
1396
1744
  : merged[i]['type']);
1397
1745
  if (t === 'ai') {
1746
+ if (!canMergeCrossIdAi(merged[i], msg, allowCrossIdAiMerge))
1747
+ break;
1398
1748
  idx = i;
1399
1749
  break;
1400
1750
  }
@@ -1450,7 +1800,10 @@ function mergeMessages(existing, incoming, reasoningTimingMap, mode = 'snapshot'
1450
1800
  if (existingId) {
1451
1801
  next['id'] = existingId;
1452
1802
  }
1803
+ const changed = mode === 'delta' || messageChangedForDelivery(existing, next);
1453
1804
  merged[idx] = next;
1805
+ if (targetId && changed)
1806
+ affectedMessageIds?.add(targetId);
1454
1807
  }
1455
1808
  else {
1456
1809
  const incomingRaw = msg;
@@ -1467,9 +1820,19 @@ function mergeMessages(existing, incoming, reasoningTimingMap, mode = 'snapshot'
1467
1820
  const next = { ...msg };
1468
1821
  next['reasoning'] = initialReasoning;
1469
1822
  merged.push(next);
1823
+ const nextId = next['id'];
1824
+ if (typeof nextId === 'string')
1825
+ affectedMessageIds?.add(nextId);
1470
1826
  }
1471
1827
  }
1472
- return collapseAdjacentAi(merged);
1828
+ return collapseAdjacentAi(merged, affectedMessageIds, allowCrossIdAiMerge);
1829
+ }
1830
+ function canMergeCrossIdAi(candidate, incoming, allowCrossIdAiMerge) {
1831
+ const candidateRaw = candidate;
1832
+ const incomingRaw = incoming;
1833
+ if (candidateRaw['id'] === incomingRaw['id'])
1834
+ return true;
1835
+ return allowCrossIdAiMerge;
1473
1836
  }
1474
1837
  /**
1475
1838
  * Merge an incoming chunk's content into prior accumulated content for the
@@ -1627,9 +1990,15 @@ function accumulateReasoning(existing, incoming) {
1627
1990
  * (role, content) matches positionally and the existing id differs. Keeps
1628
1991
  * track-by-id stable across server echoes and final-id swaps.
1629
1992
  */
1630
- function preserveIds(existing, incoming) {
1631
- if (existing.length === 0)
1632
- return collapseAdjacentAi(incoming);
1993
+ function preserveIds(existing, incoming, affectedMessageIds) {
1994
+ if (existing.length === 0) {
1995
+ for (const message of incoming) {
1996
+ const id = message['id'];
1997
+ if (typeof id === 'string')
1998
+ affectedMessageIds?.add(id);
1999
+ }
2000
+ return collapseAdjacentAi(incoming, affectedMessageIds);
2001
+ }
1633
2002
  const usedExisting = new Set();
1634
2003
  const remapped = incoming.map((msg, i) => {
1635
2004
  const inRaw = msg;
@@ -1643,15 +2012,40 @@ function preserveIds(existing, incoming) {
1643
2012
  // Fallback: any unused existing message with matching role+content.
1644
2013
  matchIdx = existing.findIndex((m, j) => !usedExisting.has(j) && sameRoleAndContent(m, msg));
1645
2014
  }
1646
- if (matchIdx < 0)
2015
+ if (matchIdx < 0) {
2016
+ if (typeof inId === 'string')
2017
+ affectedMessageIds?.add(inId);
1647
2018
  return msg;
2019
+ }
1648
2020
  usedExisting.add(matchIdx);
1649
2021
  const existingId = existing[matchIdx]['id'];
1650
- if (!existingId || existingId === inId)
1651
- return msg;
1652
- return { ...msg, id: existingId };
2022
+ const remappedMessage = !existingId || existingId === inId
2023
+ ? msg
2024
+ : { ...msg, id: existingId };
2025
+ if (typeof existingId === 'string' && messageChangedForDelivery(existing[matchIdx], remappedMessage)) {
2026
+ affectedMessageIds?.add(existingId);
2027
+ }
2028
+ return remappedMessage;
1653
2029
  });
1654
- return collapseAdjacentAi(remapped);
2030
+ return collapseAdjacentAi(remapped, affectedMessageIds);
2031
+ }
2032
+ function messageChangedForDelivery(existing, incoming) {
2033
+ const existingRaw = existing;
2034
+ const incomingRaw = incoming;
2035
+ const existingType = normalizeMessageType(typeof existing._getType === 'function' ? existing._getType() : existingRaw['type']);
2036
+ const incomingType = normalizeMessageType(typeof incoming._getType === 'function' ? incoming._getType() : incomingRaw['type']);
2037
+ if (existingType !== incomingType || extractText(existing.content) !== extractText(incoming.content)) {
2038
+ return true;
2039
+ }
2040
+ const existingReasoning = typeof existingRaw['reasoning'] === 'string'
2041
+ ? existingRaw['reasoning']
2042
+ : extractReasoning(existingRaw['reasoning']);
2043
+ const incomingReasoning = typeof incomingRaw['reasoning'] === 'string'
2044
+ ? incomingRaw['reasoning']
2045
+ : extractReasoning(incomingRaw['reasoning']);
2046
+ if (existingReasoning !== incomingReasoning)
2047
+ return true;
2048
+ return JSON.stringify(existingRaw['tool_calls'] ?? null) !== JSON.stringify(incomingRaw['tool_calls'] ?? null);
1655
2049
  }
1656
2050
  function sameRoleAndContent(a, b) {
1657
2051
  const aType = normalizeMessageType(typeof a._getType === 'function' ? a._getType() : a['type']);
@@ -1723,6 +2117,14 @@ function normalizeMessageType(t) {
1723
2117
  return 'system';
1724
2118
  return t;
1725
2119
  }
2120
+ function getTailAssistantMessageId(messages) {
2121
+ const tail = messages[messages.length - 1];
2122
+ if (!tail)
2123
+ return undefined;
2124
+ const raw = tail;
2125
+ const type = normalizeMessageType(typeof tail._getType === 'function' ? tail._getType() : raw['type']);
2126
+ return type === 'ai' && typeof raw['id'] === 'string' ? raw['id'] : undefined;
2127
+ }
1726
2128
  function toSubagentRefs(subagents) {
1727
2129
  const refs = new Map();
1728
2130
  subagents.forEach((subagent, key) => {
@@ -1921,12 +2323,19 @@ function normalizeCitation(entry, fallbackIndex) {
1921
2323
  }
1922
2324
  return undefined;
1923
2325
  };
2326
+ const publishedAt = e['publishedAt'];
2327
+ const normalizedPublishedAt = typeof publishedAt === 'string' || typeof publishedAt === 'number' || publishedAt instanceof Date
2328
+ ? publishedAt
2329
+ : undefined;
1924
2330
  return {
1925
2331
  id: str('id') ?? str('refId') ?? `c${fallbackIndex}`,
1926
2332
  index: typeof e['index'] === 'number' ? e['index'] : fallbackIndex,
1927
2333
  title: firstStr('title', 'name'),
1928
2334
  url: firstStr('url', 'href', 'source'),
1929
2335
  snippet: firstStr('snippet', 'content', 'excerpt'),
2336
+ sourceType: str('sourceType'),
2337
+ iconUrl: str('iconUrl'),
2338
+ publishedAt: normalizedPublishedAt,
1930
2339
  extra: typeof e['extra'] === 'object' && e['extra'] !== null
1931
2340
  ? e['extra']
1932
2341
  : undefined,
@@ -1959,6 +2368,41 @@ function mergeClientTools(payload, catalog) {
1959
2368
  return payload;
1960
2369
  return { ...payload, client_tools: catalog };
1961
2370
  }
2371
+ /**
2372
+ * Merge A2UI client capabilities into a run payload under the
2373
+ * `a2ui_client_capabilities` state key. Same payload semantics as
2374
+ * {@link mergeClientTools}: null/undefined payloads (command resumes,
2375
+ * regenerates) and non-record payloads pass through untouched, and the
2376
+ * original object is never mutated. Because LangGraph thread state
2377
+ * persists across runs, the capabilities stamped by any run remain
2378
+ * readable by later runs on the same thread.
2379
+ */
2380
+ function mergeA2uiClientCapabilities(payload, capabilities) {
2381
+ if (!capabilities)
2382
+ return payload;
2383
+ if (payload === null || payload === undefined)
2384
+ return payload;
2385
+ if (typeof payload !== 'object' || Array.isArray(payload))
2386
+ return payload;
2387
+ return { ...payload, a2ui_client_capabilities: capabilities };
2388
+ }
2389
+ /**
2390
+ * Prepend staged tool messages to a run payload's message list.
2391
+ *
2392
+ * Mirrors mergeClientTools: a null payload signals a no-input resume and must
2393
+ * stay null, so staged messages cannot ride along and are left buffered.
2394
+ */
2395
+ function mergeStagedToolMessages(payload, staged) {
2396
+ if (staged.length === 0)
2397
+ return payload;
2398
+ if (payload === null || payload === undefined)
2399
+ return payload;
2400
+ if (typeof payload !== 'object' || Array.isArray(payload))
2401
+ return payload;
2402
+ const record = payload;
2403
+ const existing = Array.isArray(record['messages']) ? record['messages'] : [];
2404
+ return { ...record, messages: [...staged, ...existing] };
2405
+ }
1962
2406
  /**
1963
2407
  * Creates a ClientToolsCapability backed by a LangGraph submit function and
1964
2408
  * a store of tool-call signals. Extracted into a factory so it can be
@@ -1973,14 +2417,26 @@ function mergeClientTools(payload, catalog) {
1973
2417
  * yet — but ONLY when the run is not in progress (isLoading===false).
1974
2418
  * The backend ends the run without emitting a ToolMessage result for
1975
2419
  * client tools, so `result` stays undefined on those entries.
1976
- * - resolve(id, result): marks the call as resolved, then issues a NEW
1977
- * run on the SAME thread by calling submitFn with:
2420
+ * - settle(id, result): marks the call as resolved, writes the local result,
2421
+ * and stages a ToolMessage with a deterministic ID without issuing a run.
2422
+ * - flush(): snapshots the whole staged group into ONE persistFn call without
2423
+ * starting a run — the settlement path for tool groups that never continue.
2424
+ * Successful persistence acknowledges the captured entries; failed writes
2425
+ * retain them. When persistFn is absent, a non-empty flush rejects without
2426
+ * changing the staged results. Flushes are chained so entries settled after
2427
+ * one snapshot receive their own write. Concurrent persistence,
2428
+ * continuation, and ordinary submits may safely carry the same stable IDs.
2429
+ * - clearStagedToolMessages(): discards staged results on a thread switch and
2430
+ * advances the generation so late acknowledgments cannot affect new state.
2431
+ * - resolve(id, result): settles the result, then issues a NEW run on the SAME
2432
+ * thread by calling submitFn with a non-destructive ToolMessage snapshot:
1978
2433
  * input: {
1979
2434
  * messages: [{ type: 'tool', role: 'tool', tool_call_id: id, content }],
1980
2435
  * client_tools: catalog(),
1981
2436
  * }
1982
- * The `add_messages` reducer on the Python side appends the ToolMessage
1983
- * to thread state. Including `client_tools` ensures the model sees the
2437
+ * The snapshot remains staged unless that continuation reports success.
2438
+ * LangGraph's `add_messages` reducer reuses each stable message ID on safe
2439
+ * overlap or replay. Including `client_tools` ensures the model sees the
1984
2440
  * full tool catalog on the continuation run.
1985
2441
  *
1986
2442
  * Catalog shipping: the catalog is NOT injected by this factory's
@@ -1990,62 +2446,199 @@ function mergeClientTools(payload, catalog) {
1990
2446
  * before forwarding to manager.submit. This keeps injection concerns
1991
2447
  * co-located with the run-issuing call sites.
1992
2448
  */
1993
- function createClientToolsCapability(submitFn, store) {
2449
+ function createClientToolsCapability(submitFn, store, persistFn, currentThreadIdFn) {
1994
2450
  const catalog = signal([], ...(ngDevMode ? [{ debugName: "catalog" }] : []));
1995
2451
  const resolvedIds = signal(new Set(), ...(ngDevMode ? [{ debugName: "resolvedIds" }] : []));
2452
+ const toolMessageBuffer = [];
2453
+ let flushInFlight;
2454
+ // Bumped whenever a thread reset discards staged state. Every snapshot keeps
2455
+ // its generation, making acknowledgments from the prior thread no-ops.
2456
+ let bufferGeneration = 0;
2457
+ // Tool calls belonging to threads we have left. A handler still running when
2458
+ // the user switches threads settles AFTER the switch, by which point both the
2459
+ // store and the current thread id already describe the NEW thread — the id is
2460
+ // the only durable way left to recognise the result as stale.
2461
+ const retiredToolCallIds = new Set();
1996
2462
  const pending = computed(() => {
1997
2463
  // Client tools are only actionable after the run ends (the backend
1998
2464
  // signals this by ending the run WITHOUT emitting a ToolMessage result
1999
2465
  // for client tools).
2000
- if (store.isLoading())
2001
- return [];
2002
- const names = new Set(catalog().map((s) => s.name));
2003
- const done = resolvedIds();
2004
- return store.toolCalls().filter((tc) => names.has(tc.name) && tc.result === undefined && !done.has(tc.id));
2466
+ return selectPendingClientToolCalls({
2467
+ isLoading: store.isLoading(),
2468
+ toolCalls: store.toolCalls(),
2469
+ catalogNames: new Set(catalog().map((s) => s.name)),
2470
+ resolvedIds: resolvedIds(),
2471
+ });
2005
2472
  }, ...(ngDevMode ? [{ debugName: "pending" }] : []));
2473
+ function settleResult(id, result) {
2474
+ // Mark as resolved first so pending() drops it immediately.
2475
+ resolvedIds.update((s) => new Set(s).add(id));
2476
+ // Cast rather than rely on discriminant narrowing: consumer apps that
2477
+ // compile this source with `strictNullChecks: false` don't narrow the
2478
+ // ClientToolResult union in a ternary.
2479
+ const ok = result.ok;
2480
+ const value = result.value;
2481
+ const error = result.error;
2482
+ // Write the outcome onto the LOCAL ToolCall (via the adapter's override
2483
+ // layer). The client tool DID produce a result client-side, so this is
2484
+ // semantically correct — and it freezes the transcript card: the mounted
2485
+ // ask component re-renders with its own emitted value as props and can
2486
+ // branch to a resolved/frozen state. Without this, the LOCAL tool call
2487
+ // never gets a result (only the backend ToolMessage does) so the card
2488
+ // stays interactive forever.
2489
+ store.applyClientResult(id, {
2490
+ result: ok ? value : { error },
2491
+ ...(ok ? {} : { error, status: 'error' }),
2492
+ });
2493
+ const content = ok
2494
+ ? safeStringify(value)
2495
+ : `Error: ${error}`;
2496
+ // The tool call belongs to a thread the user has already left, so there is
2497
+ // nowhere valid to send this result: the current thread has no matching
2498
+ // tool call, and the old thread is no longer the write target.
2499
+ if (retiredToolCallIds.has(id)) {
2500
+ console.warn(`Discarding client tool result for ${id}: its thread is no longer active.`);
2501
+ return;
2502
+ }
2503
+ // Message shape: both `type` and `role` are set for compatibility —
2504
+ // the LangGraph server's add_messages coercion reads `role` (Python
2505
+ // side), while the bridge's local optimistic-message path reads `type`
2506
+ // (via toMessage's normalizeMessageType). This mirrors the human-message
2507
+ // shape used in buildSubmitUpdate (agent.fn.ts line 732).
2508
+ toolMessageBuffer.push({
2509
+ threadId: currentThreadIdFn?.() ?? null,
2510
+ message: {
2511
+ id: `client-tool-result-${id}`,
2512
+ type: 'tool',
2513
+ role: 'tool',
2514
+ tool_call_id: id,
2515
+ content,
2516
+ },
2517
+ });
2518
+ }
2519
+ /**
2520
+ * Returns a non-destructive snapshot of entries still valid for the thread a
2521
+ * write would land on right now. Captured entries remain staged, so
2522
+ * overlapping operations may carry the same deterministic message IDs.
2523
+ * Acknowledgment removes only the exact captured entries while the snapshot
2524
+ * generation is current. An entry stamped for another thread is dropped
2525
+ * rather than misdelivered.
2526
+ *
2527
+ * A null on either side means "thread not tracked yet" (no threadId option
2528
+ * and no run has reported one); those are kept, since there is no evidence of
2529
+ * a switch and dropping them would lose results on untracked transports.
2530
+ */
2531
+ function snapshotToolMessages() {
2532
+ const current = currentThreadIdFn?.() ?? null;
2533
+ for (let index = toolMessageBuffer.length - 1; index >= 0; index -= 1) {
2534
+ const entry = toolMessageBuffer[index];
2535
+ const stale = entry.threadId !== null && current !== null && entry.threadId !== current;
2536
+ if (stale) {
2537
+ console.warn(`Discarding a client tool result staged for thread ${entry.threadId}; ` +
2538
+ `the active thread is now ${current}.`);
2539
+ toolMessageBuffer.splice(index, 1);
2540
+ }
2541
+ }
2542
+ const generation = bufferGeneration;
2543
+ const entries = [...toolMessageBuffer];
2544
+ const messages = entries.map(({ message }) => ({ ...message }));
2545
+ let acknowledged = false;
2546
+ return {
2547
+ generation,
2548
+ messages,
2549
+ acknowledge() {
2550
+ if (acknowledged || generation !== bufferGeneration)
2551
+ return;
2552
+ acknowledged = true;
2553
+ for (const entry of entries) {
2554
+ const index = toolMessageBuffer.indexOf(entry);
2555
+ if (index !== -1)
2556
+ toolMessageBuffer.splice(index, 1);
2557
+ }
2558
+ },
2559
+ };
2560
+ }
2561
+ /**
2562
+ * Persist one snapshot. Only that snapshot is acknowledged after persistence
2563
+ * succeeds; failure leaves its exact entries staged for retry or submit.
2564
+ */
2565
+ function runFlush() {
2566
+ const batch = snapshotToolMessages();
2567
+ if (batch.messages.length === 0)
2568
+ return Promise.resolve();
2569
+ if (!persistFn) {
2570
+ return Promise.reject(new Error('Cannot flush staged client tool results. ' +
2571
+ 'Custom LangGraph transports using terminal client tools must implement updateState().'));
2572
+ }
2573
+ return persistFn(batch.messages)
2574
+ .then(() => {
2575
+ batch.acknowledge();
2576
+ })
2577
+ .catch((err) => {
2578
+ console.warn(`Client tool flush failed; ${batch.messages.length} result(s) remain staged for the next run.`, err);
2579
+ });
2580
+ }
2006
2581
  const capability = {
2007
2582
  catalog,
2008
2583
  setCatalog(specs) {
2009
2584
  catalog.set([...specs]);
2010
2585
  },
2011
2586
  pending,
2587
+ settle(id, result) {
2588
+ settleResult(id, result);
2589
+ },
2590
+ snapshotToolMessages() {
2591
+ return snapshotToolMessages();
2592
+ },
2593
+ /**
2594
+ * Discard everything staged. Called when the active thread changes: a
2595
+ * ToolMessage only makes sense against the thread whose AIMessage produced
2596
+ * its tool_call_id, so carrying it over would poison the new thread.
2597
+ */
2598
+ clearStagedToolMessages() {
2599
+ // Retire the outgoing thread's tool calls so a handler that settles after
2600
+ // the switch is recognised as stale. Read the store BEFORE it resets —
2601
+ // agent.fn.ts calls this ahead of manager.switchThread for that reason.
2602
+ for (const toolCall of store.toolCalls())
2603
+ retiredToolCallIds.add(toolCall.id);
2604
+ toolMessageBuffer.length = 0;
2605
+ // Invalidate every outstanding snapshot acknowledgment from the old thread.
2606
+ bufferGeneration += 1;
2607
+ },
2608
+ flush() {
2609
+ // Only this method owns the queue tail. Results settled after an active
2610
+ // snapshot need their own write, and every caller must remain behind all
2611
+ // callers already queued. The chain terminates when a fresh snapshot is
2612
+ // empty.
2613
+ const queued = flushInFlight
2614
+ ? flushInFlight.then(() => runFlush(), () => runFlush())
2615
+ : runFlush();
2616
+ flushInFlight = queued;
2617
+ const clearTail = () => {
2618
+ if (flushInFlight === queued)
2619
+ flushInFlight = undefined;
2620
+ };
2621
+ // Both handlers return normally, so this bookkeeping branch cannot
2622
+ // create an unhandled rejection when the caller observes `queued`.
2623
+ void queued.then(clearTail, clearTail);
2624
+ return queued;
2625
+ },
2012
2626
  resolve(id, result) {
2013
- // Mark as resolved first so pending() drops it immediately.
2014
- resolvedIds.update((s) => new Set(s).add(id));
2015
- // Cast rather than rely on discriminant narrowing: consumer apps that
2016
- // compile this source with `strictNullChecks: false` don't narrow the
2017
- // ClientToolResult union in a ternary.
2018
- const ok = result.ok;
2019
- const value = result.value;
2020
- const error = result.error;
2021
- // Write the outcome onto the LOCAL ToolCall (via the adapter's override
2022
- // layer). The client tool DID produce a result client-side, so this is
2023
- // semantically correct — and it freezes the transcript card: the mounted
2024
- // ask component re-renders with its own emitted value as props and can
2025
- // branch to a resolved/frozen state. Without this, the LOCAL tool call
2026
- // never gets a result (only the backend ToolMessage does) so the card
2027
- // stays interactive forever.
2028
- store.applyClientResult(id, {
2029
- result: ok ? value : { error },
2030
- ...(ok ? {} : { error, status: 'error' }),
2031
- });
2032
- const content = ok
2033
- ? safeStringify(value)
2034
- : `Error: ${error}`;
2035
- // Issue a new run on the same thread. LangGraph's add_messages reducer
2036
- // appends the ToolMessage to the thread state. `client_tools` is
2037
- // included so the model sees the full tool catalog on the continuation.
2038
- //
2039
- // Message shape: both `type` and `role` are set for compatibility —
2040
- // the LangGraph server's add_messages coercion reads `role` (Python
2041
- // side), while the bridge's local optimistic-message path reads `type`
2042
- // (via toMessage's normalizeMessageType). This mirrors the human-message
2043
- // shape used in buildSubmitUpdate (agent.fn.ts line 732).
2627
+ settleResult(id, result);
2628
+ // Issue a new run with a non-destructive snapshot. The exact batch is
2629
+ // acknowledged only when that continuation succeeds; failures retain it
2630
+ // for retry. Stable IDs make overlap with flush or submit safe under
2631
+ // LangGraph's add_messages reducer. `client_tools` keeps the full catalog
2632
+ // visible to the continuation.
2633
+ const batch = snapshotToolMessages();
2044
2634
  const toolPayload = {
2045
- messages: [{ type: 'tool', role: 'tool', tool_call_id: id, content }],
2635
+ messages: batch.messages,
2046
2636
  client_tools: catalog(),
2047
2637
  };
2048
- void submitFn(toolPayload);
2638
+ void submitFn(toolPayload, undefined, batch).then((outcome) => {
2639
+ if (outcome === 'success')
2640
+ batch.acknowledge();
2641
+ });
2049
2642
  },
2050
2643
  };
2051
2644
  return capability;
@@ -2156,10 +2749,22 @@ function agent(options) {
2156
2749
  });
2157
2750
  const custom$ = new BehaviorSubject([]);
2158
2751
  const hasValue$ = new BehaviorSubject(false);
2752
+ // Forward reference. The client-tools capability is built much further down
2753
+ // (it needs `manager`, which needs these subjects), but the thread-change
2754
+ // seam lives here. A holder keeps the binding itself a `const` while its
2755
+ // member is filled in later.
2756
+ const clientToolStaging = {};
2757
+ let retryableToolMessageBatch;
2159
2758
  function resetDerivedThreadState() {
2160
2759
  status$.next(ResourceStatus.Idle);
2161
2760
  error$.next(undefined);
2162
2761
  hasValue$.next(false);
2762
+ // Staged client-tool results belong to the thread whose AIMessage produced
2763
+ // their tool_call_ids. Carrying them into a different thread would prepend
2764
+ // a ToolMessage that matches no tool call there — a 400 on that turn.
2765
+ // Runs BEFORE manager.switchThread resets the store, so the capability can
2766
+ // still read the outgoing thread's tool calls.
2767
+ clientToolStaging.clear?.();
2163
2768
  }
2164
2769
  // Track hasValue — becomes true once values or messages arrive
2165
2770
  values$.pipe(takeUntil$1(destroy$)).subscribe(v => {
@@ -2314,7 +2919,10 @@ function agent(options) {
2314
2919
  // `@let content = messageContent(message)` short-circuits — DOM never
2315
2920
  // updates per token. DOM stability is provided by `track message.id`
2316
2921
  // in chat-message-list, not by Message identity.
2317
- const messagesNeutral = computed(() => rawMessages().map((m) => toMessage(m, manager.getReasoningDurationMs)), ...(ngDevMode ? [{ debugName: "messagesNeutral" }] : []));
2922
+ const messagesNeutral = computed(() => {
2923
+ manager.deliveryRevision();
2924
+ return rawMessages().map((m) => toMessage(m, manager.getReasoningDurationMs, manager.getMessageDelivery));
2925
+ }, ...(ngDevMode ? [{ debugName: "messagesNeutral" }] : []));
2318
2926
  // Client-tool resolutions written client-side. The raw `toolCalls$` stream
2319
2927
  // (and thus `rawToolCalls`) only ever carries backend results — a resolved
2320
2928
  // client tool (`ask`/`view`) never receives a backend ToolMessage on its
@@ -2341,7 +2949,7 @@ function agent(options) {
2341
2949
  }, ...(ngDevMode ? [{ debugName: "interruptNeutral" }] : []));
2342
2950
  const subagentsNeutral = computed(() => {
2343
2951
  const out = new Map();
2344
- subagentsSig().forEach((sa, key) => out.set(key, toSubagent(sa)));
2952
+ subagentsSig().forEach((sa, key) => out.set(key, toSubagent(sa, manager)));
2345
2953
  return out;
2346
2954
  }, ...(ngDevMode ? [{ debugName: "subagentsNeutral" }] : []));
2347
2955
  const historyNeutral = computed(() => historySig().map(toCheckpoint), ...(ngDevMode ? [{ debugName: "historyNeutral" }] : []));
@@ -2353,11 +2961,47 @@ function agent(options) {
2353
2961
  // follow-up runs (resolve) without going through the full submit() wrapper.
2354
2962
  // The catalog is injected into every outbound payload via mergeClientTools()
2355
2963
  // in the submit wrapper below and in the resolve path inside the capability.
2356
- const clientToolsCap = createClientToolsCapability((payload, opts) => manager.submit(payload, opts), {
2964
+ //
2965
+ // flush() needs a durable write that does NOT start a run. The bridge's
2966
+ // updateState() silently no-ops when the transport has no updateState, so
2967
+ // only supply a persist function when the effective transport supports it —
2968
+ // an omitted transport means the bridge builds a FetchStreamTransport, which
2969
+ // does. When persistFn is undefined, a non-empty flush() rejects while the
2970
+ // results stay staged; an ordinary non-null submit remains their in-memory
2971
+ // fallback path.
2972
+ const canPersistToolMessages = !transport || typeof transport.updateState === 'function';
2973
+ const clientToolsCap = createClientToolsCapability((payload, opts, batch) => {
2974
+ retryableToolMessageBatch = batch;
2975
+ return manager.submit(payload, opts);
2976
+ }, {
2357
2977
  toolCalls: toolCallsNeutral,
2358
2978
  isLoading,
2359
2979
  applyClientResult: (id, patch) => clientResultOverrides.update((m) => new Map(m).set(id, patch)),
2360
- });
2980
+ }, canPersistToolMessages
2981
+ ? async (messages) => {
2982
+ // Throw rather than let the bridge no-op: without a thread there is
2983
+ // nothing to write to, and flush() must keep the buffer staged.
2984
+ if (!manager.currentThreadId) {
2985
+ throw new Error('no threadId for client tool flush');
2986
+ }
2987
+ // No asNode: add_messages appends the ToolMessages and the graph's
2988
+ // resume point is left untouched, so no run is started.
2989
+ await manager.updateState({ messages: [...messages] });
2990
+ }
2991
+ : undefined,
2992
+ // Stamps each staged result with the thread it was settled on, so a write
2993
+ // can never land on a thread the user has since moved to.
2994
+ () => manager.currentThreadId);
2995
+ clientToolStaging.clear = () => {
2996
+ retryableToolMessageBatch = undefined;
2997
+ clientToolsCap.clearStagedToolMessages();
2998
+ };
2999
+ async function resubmitWithToolRecovery() {
3000
+ const batch = retryableToolMessageBatch;
3001
+ const outcome = await manager.resubmitLast();
3002
+ if (outcome === 'success')
3003
+ batch?.acknowledge();
3004
+ }
2361
3005
  return {
2362
3006
  // ── Runtime-neutral surface (AgentWithHistory) ────────────────────────
2363
3007
  messages: messagesNeutral,
@@ -2371,7 +3015,7 @@ function agent(options) {
2371
3015
  events$,
2372
3016
  history: historyNeutral,
2373
3017
  messageCheckpoints: messageCheckpointsSig,
2374
- submit: (input, opts) => {
3018
+ submit: async (input, opts) => {
2375
3019
  // Lifecycle: first submit with no existing threadId → thread create.
2376
3020
  if (lcThreadCreatedAt() === null && lastThreadId == null) {
2377
3021
  lcThreadCreatedAt.set(Date.now());
@@ -2384,15 +3028,34 @@ function agent(options) {
2384
3028
  // Thread the client-tools catalog into every outbound payload so the
2385
3029
  // backend middleware can merge them into the model's tool list. Null
2386
3030
  // payloads (regenerate re-runs, command resumes) are left unchanged.
2387
- const payload = mergeClientTools(request.payload, clientToolsCap.catalog());
2388
- return manager.submit(payload, request.options);
3031
+ //
3032
+ // Snapshot results settled but not yet durable (flush unavailable or a
3033
+ // prior flush failed) so they ride along without being forgotten. The
3034
+ // exact snapshot is acknowledged only when this operation succeeds;
3035
+ // overlaps may safely carry the same deterministic message IDs. A null
3036
+ // payload cannot carry results, so it leaves staging unchanged.
3037
+ const batch = request.payload === null || request.payload === undefined
3038
+ ? undefined
3039
+ : clientToolsCap.snapshotToolMessages();
3040
+ const staged = batch?.messages ?? [];
3041
+ const withStaged = staged.length > 0
3042
+ ? mergeStagedToolMessages(request.payload, staged)
3043
+ : request.payload;
3044
+ const payload = mergeA2uiClientCapabilities(mergeClientTools(withStaged, clientToolsCap.catalog()), options.a2uiClientCapabilities);
3045
+ const createsQueuedRun = request.options?.multitaskStrategy === 'enqueue' && isLoading();
3046
+ if (!createsQueuedRun) {
3047
+ retryableToolMessageBatch = batch;
3048
+ }
3049
+ const outcome = await manager.submit(payload, request.options);
3050
+ if (outcome === 'success')
3051
+ batch?.acknowledge();
2389
3052
  },
2390
3053
  stop: () => manager.stop(),
2391
3054
  retry: async () => {
2392
3055
  if (isLoading())
2393
3056
  return; // no-op while a run is in flight
2394
3057
  error$.next(undefined); // clear the error before re-running
2395
- await manager.resubmitLast();
3058
+ await resubmitWithToolRecovery();
2396
3059
  },
2397
3060
  clientTools: clientToolsCap,
2398
3061
  regenerate: async (assistantMessageIndex) => {
@@ -2449,6 +3112,7 @@ function agent(options) {
2449
3112
  // at `__start__`, this resumes at the entry node and produces a fresh
2450
3113
  // assistant message — the trailing user message becomes the active
2451
3114
  // prompt without being re-appended.
3115
+ retryableToolMessageBatch = undefined;
2452
3116
  await manager.submit(null, undefined);
2453
3117
  },
2454
3118
  // ── Raw LangGraph signals ─────────────────────────────────────────────
@@ -2460,7 +3124,9 @@ function agent(options) {
2460
3124
  // ── Other LangGraph-specific fields ──────────────────────────────────
2461
3125
  value: value,
2462
3126
  hasValue: hasValueSig,
2463
- reload: () => manager.resubmitLast(),
3127
+ reload: () => {
3128
+ void resubmitWithToolRecovery();
3129
+ },
2464
3130
  toolProgress: toolProgSig,
2465
3131
  queue: queueSig,
2466
3132
  activeSubagents,
@@ -2537,7 +3203,7 @@ function mapStatus(s) {
2537
3203
  return 'idle';
2538
3204
  }
2539
3205
  }
2540
- function toMessage(m, getReasoningDurationMs) {
3206
+ function toMessage(m, getReasoningDurationMs, getDelivery) {
2541
3207
  const raw = m;
2542
3208
  const typeVal = typeof m._getType === 'function'
2543
3209
  ? m._getType()
@@ -2556,6 +3222,7 @@ function toMessage(m, getReasoningDurationMs) {
2556
3222
  const result = {
2557
3223
  id,
2558
3224
  role,
3225
+ delivery: getDelivery?.(id) ?? staticDelivery(id),
2559
3226
  content: extractTextContent(m.content),
2560
3227
  toolCallId: raw['tool_call_id'],
2561
3228
  name: raw['name'],
@@ -2632,12 +3299,15 @@ function toInterrupt(ix) {
2632
3299
  resumable: true,
2633
3300
  };
2634
3301
  }
2635
- function toSubagent(sa) {
3302
+ function toSubagent(sa, manager) {
2636
3303
  return {
2637
3304
  toolCallId: sa.toolCallId,
2638
3305
  name: sa.name,
2639
3306
  status: sa.status,
2640
- messages: computed(() => sa.messages().map((m) => toMessage(m))),
3307
+ messages: computed(() => {
3308
+ manager.deliveryRevision();
3309
+ return sa.messages().map((m) => toMessage(m, undefined, () => manager.getSubagentMessageDelivery(sa.toolCallId, m)));
3310
+ }),
2641
3311
  state: sa.values,
2642
3312
  };
2643
3313
  }
@@ -2756,6 +3426,7 @@ function agentFactory() {
2756
3426
  ...(config.telemetry !== undefined ? { telemetry: config.telemetry } : {}),
2757
3427
  ...(config.filterSubagentMessages !== undefined ? { filterSubagentMessages: config.filterSubagentMessages } : {}),
2758
3428
  ...(config.subagentToolNames !== undefined ? { subagentToolNames: config.subagentToolNames } : {}),
3429
+ ...(config.transcriptNodeNames !== undefined ? { transcriptNodeNames: config.transcriptNodeNames } : {}),
2759
3430
  });
2760
3431
  }
2761
3432
  function isAgentRef(x) {