@threadplane/langgraph 0.0.55 → 0.0.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  /**
@@ -13,8 +13,8 @@ import { toAgentError, isAbortError, AgentError, AGENT_ERROR_MESSAGES, mockAgent
13
13
  * an Angular injection context. External instrumentation packages
14
14
  * (e.g. cockpit-telemetry) provide this token and read from it.
15
15
  *
16
- * `@threadplane/langgraph` does NOT provide this itself — `agent()` writes to
17
- * the registry only when an external consumer has provided it.
16
+ * `@threadplane/langgraph` does NOT provide this itself — the configured agent
17
+ * instance writes to the registry only when an external consumer has provided it.
18
18
  */
19
19
  class AgentLifecycleRegistry {
20
20
  _lifecycles = signal([], ...(ngDevMode ? [{ debugName: "_lifecycles" }] : []));
@@ -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,9 +739,16 @@ 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();
591
753
  }
592
754
  currentThreadId = id;
@@ -602,11 +764,18 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
602
764
  setThreadId(id, shouldReset);
603
765
  });
604
766
  destroy$.subscribe(() => {
767
+ invalidateQueueDrain();
605
768
  abortController?.abort();
606
769
  historyAbortController?.abort();
607
770
  reasoningTimingMap.clear();
771
+ if (activeAttempt && !activeAttempt.terminalOutcome) {
772
+ finalizeAttempt(activeAttempt, 'interrupted');
773
+ }
774
+ messageDeliveries.clear();
775
+ activeAttempt = null;
776
+ notifyDeliveryChange();
608
777
  });
609
- async function refreshHistory(force = false) {
778
+ async function refreshHistory(force = false, isRelevant = () => true) {
610
779
  const getHistory = transport.getHistory?.bind(transport);
611
780
  if (!currentThreadId || !getHistory)
612
781
  return;
@@ -616,8 +785,8 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
616
785
  const threadId = currentThreadId;
617
786
  subjects.isThreadLoading$.next(true);
618
787
  try {
619
- const history = await getHistory(threadId, controller.signal);
620
- if (!controller.signal.aborted && currentThreadId === threadId) {
788
+ const history = await waitForHistory(getHistory(threadId, controller.signal), controller.signal);
789
+ if (!controller.signal.aborted && currentThreadId === threadId && isRelevant()) {
621
790
  subjects.history$.next(history);
622
791
  // Project the latest checkpoint into messages$ + values$:
623
792
  // - On first connect (`force=false`): only when messages$ is
@@ -636,7 +805,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
636
805
  // canonical surface for them; keeping a duplicate in values$
637
806
  // would confuse downstream consumers reading both subjects.
638
807
  delete restoredValues.messages;
639
- subjects.messages$.next(restoredMessages);
808
+ subjects.messages$.next(preserveIds(subjects.messages$.value, restoredMessages));
640
809
  subjects.values$.next(restoredValues);
641
810
  // Rebuild derived subjects from the new authoritative messages$.
642
811
  // Tool-call results displayed by chat-tool-calls come from
@@ -653,7 +822,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
653
822
  }
654
823
  }
655
824
  catch (err) {
656
- if (!controller.signal.aborted && err?.name !== 'AbortError') {
825
+ if (!controller.signal.aborted && isRelevant() && err?.name !== 'AbortError') {
657
826
  subjects.error$.next(toAgentError(err));
658
827
  }
659
828
  }
@@ -664,6 +833,30 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
664
833
  }
665
834
  }
666
835
  }
836
+ function waitForHistory(history, signal) {
837
+ if (signal.aborted)
838
+ return Promise.reject(createHistoryAbortError());
839
+ return new Promise((resolve, reject) => {
840
+ const onAbort = () => {
841
+ cleanup();
842
+ reject(createHistoryAbortError());
843
+ };
844
+ const cleanup = () => signal.removeEventListener('abort', onAbort);
845
+ signal.addEventListener('abort', onAbort, { once: true });
846
+ history.then(value => {
847
+ cleanup();
848
+ resolve(value);
849
+ }, error => {
850
+ cleanup();
851
+ reject(error);
852
+ });
853
+ });
854
+ }
855
+ function createHistoryAbortError() {
856
+ const error = new Error('History refresh aborted.');
857
+ error.name = 'AbortError';
858
+ return error;
859
+ }
667
860
  function publishQueue() {
668
861
  subjects.queue$.next(createQueueSnapshot());
669
862
  }
@@ -719,24 +912,33 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
719
912
  await Promise.all(entries.map(entry => cancelRun(entry.threadId, entry.id, new AbortController().signal)));
720
913
  }
721
914
  async function drainQueue() {
722
- if (drainingQueue || queuedRuns.length === 0)
915
+ if (activeQueueDrainEpoch !== null || queuedRuns.length === 0)
723
916
  return;
724
- drainingQueue = true;
917
+ queueDrainEpoch += 1;
918
+ const drainEpoch = queueDrainEpoch;
919
+ activeQueueDrainEpoch = drainEpoch;
725
920
  try {
726
921
  while (queuedRuns.length > 0) {
922
+ if (activeQueueDrainEpoch !== drainEpoch)
923
+ return;
727
924
  const entry = queuedRuns.shift();
728
925
  publishQueue();
729
926
  if (!entry || !transport.joinStream)
730
927
  continue;
731
- await joinQueuedRun(entry);
928
+ await joinQueuedRun(entry, drainEpoch);
732
929
  }
733
930
  }
734
931
  finally {
735
- drainingQueue = false;
932
+ if (activeQueueDrainEpoch === drainEpoch)
933
+ activeQueueDrainEpoch = null;
736
934
  }
737
935
  }
738
- async function joinQueuedRun(entry) {
739
- abortController = new AbortController();
936
+ async function joinQueuedRun(entry, drainEpoch) {
937
+ if (activeQueueDrainEpoch !== drainEpoch)
938
+ return;
939
+ const controller = new AbortController();
940
+ abortController = controller;
941
+ const attempt = beginAttempt(true);
740
942
  const startedAt = Date.now();
741
943
  captureRuntimeRequestTelemetry('join_queued');
742
944
  captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_started', telemetryProperties);
@@ -746,19 +948,22 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
746
948
  subjects.status$.next(ResourceStatus.Loading);
747
949
  try {
748
950
  const iter = transport.joinStream
749
- ? transport.joinStream(entry.threadId, entry.id, undefined, abortController.signal)
951
+ ? transport.joinStream(entry.threadId, entry.id, undefined, controller.signal)
750
952
  : [];
751
953
  for await (const event of iter) {
752
- if (abortController.signal.aborted)
954
+ if (controller.signal.aborted || !isCurrentExecution(controller, attempt))
753
955
  break;
754
956
  processEvent(event);
755
957
  }
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);
958
+ if (!isCurrentExecution(controller, attempt))
959
+ return;
960
+ const outcome = await finalizeClosedAttempt(controller, attempt);
961
+ if (outcome === null)
962
+ return;
963
+ if (!controller.signal.aborted) {
964
+ if (outcome !== 'error') {
965
+ subjects.status$.next(ResourceStatus.Resolved);
966
+ }
762
967
  captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_ended', {
763
968
  ...telemetryProperties,
764
969
  durationMs: Date.now() - startedAt,
@@ -766,19 +971,37 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
766
971
  }
767
972
  }
768
973
  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
- });
974
+ if (!isCurrentExecution(controller, attempt))
975
+ return;
976
+ if (attempt.terminalOutcome)
977
+ return;
978
+ if (isAbortError(err) && userAbortedControllers.has(controller)) {
979
+ finalizeAttempt(attempt, 'aborted');
980
+ subjects.error$.next(undefined);
981
+ subjects.status$.next(ResourceStatus.Idle);
982
+ }
983
+ else {
984
+ finalizeAttempt(attempt, attempt.sawAssistantChunk ? 'interrupted' : 'error');
985
+ subjects.error$.next(toAgentError(err));
986
+ subjects.status$.next(ResourceStatus.Error);
987
+ captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_errored', {
988
+ ...telemetryProperties,
989
+ durationMs: Date.now() - startedAt,
990
+ errorClass: agentRuntimeTelemetryErrorClass(err),
991
+ });
992
+ }
993
+ }
994
+ finally {
995
+ if (abortController === controller)
996
+ abortController = null;
776
997
  }
777
998
  }
778
999
  async function runStream(payload, opts, requestType = 'submit') {
1000
+ invalidateQueueDrain();
779
1001
  abortController?.abort();
780
- abortController = new AbortController();
781
- userAbortRequested = false;
1002
+ const controller = new AbortController();
1003
+ abortController = controller;
1004
+ const attempt = beginAttempt(requestType === 'resubmit' || (isRecord$1(opts?.command) && 'resume' in opts.command));
782
1005
  const startedAt = Date.now();
783
1006
  captureRuntimeRequestTelemetry(requestType);
784
1007
  captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_started', telemetryProperties);
@@ -813,18 +1036,22 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
813
1036
  subjects.messages$.next([...existing, ...stamped]);
814
1037
  }
815
1038
  try {
816
- const iter = transport.stream(options.assistantId, currentThreadId, payload, opts?.signal ?? abortController.signal, opts);
1039
+ const iter = transport.stream(options.assistantId, currentThreadId, payload, opts?.signal ?? controller.signal, opts);
817
1040
  for await (const event of iter) {
818
- if (abortController.signal.aborted)
1041
+ if (controller.signal.aborted || !isCurrentExecution(controller, attempt))
819
1042
  break;
820
1043
  streamingStarted = true;
821
1044
  processEvent(event);
822
1045
  }
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);
1046
+ if (!isCurrentExecution(controller, attempt))
1047
+ return;
1048
+ const outcome = await finalizeClosedAttempt(controller, attempt);
1049
+ if (outcome === null)
1050
+ return;
1051
+ if (!controller.signal.aborted) {
1052
+ if (outcome !== 'error') {
1053
+ subjects.status$.next(ResourceStatus.Resolved);
1054
+ }
828
1055
  await drainQueue();
829
1056
  captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_ended', {
830
1057
  ...telemetryProperties,
@@ -833,11 +1060,18 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
833
1060
  }
834
1061
  }
835
1062
  catch (err) {
836
- if (isAbortError(err) && userAbortRequested) {
1063
+ if (!isCurrentExecution(controller, attempt))
1064
+ return;
1065
+ if (attempt.terminalOutcome)
1066
+ return;
1067
+ if (isAbortError(err) && userAbortedControllers.has(controller)) {
1068
+ finalizeAttempt(attempt, 'aborted');
837
1069
  // User explicitly called stop() — treat as graceful idle, not an error.
1070
+ subjects.error$.next(undefined);
838
1071
  subjects.status$.next(ResourceStatus.Idle);
839
1072
  }
840
1073
  else if (isAbortError(err)) {
1074
+ finalizeAttempt(attempt, attempt.sawAssistantChunk ? 'interrupted' : 'error');
841
1075
  // A non-user-requested abort: interrupted if a stream had started, else a
842
1076
  // connect-phase failure. Never "aborted" (that's reserved for user stop).
843
1077
  const e = streamingStarted
@@ -852,6 +1086,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
852
1086
  });
853
1087
  }
854
1088
  else {
1089
+ finalizeAttempt(attempt, attempt.sawAssistantChunk ? 'interrupted' : 'error');
855
1090
  subjects.error$.next(toAgentError(err));
856
1091
  subjects.status$.next(ResourceStatus.Error);
857
1092
  captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_errored', {
@@ -861,10 +1096,17 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
861
1096
  });
862
1097
  }
863
1098
  }
1099
+ finally {
1100
+ if (abortController === controller)
1101
+ abortController = null;
1102
+ }
864
1103
  }
865
1104
  function processEvent(event) {
866
1105
  const baseType = getBaseEventType(event.type);
867
1106
  const namespace = getEventNamespace(event);
1107
+ if (baseType === 'checkpoints' || baseType === 'messages/complete') {
1108
+ markNormalTerminal(event);
1109
+ }
868
1110
  if (isMessagesEvent(event.type)) {
869
1111
  const msgs = normalizeMessages(event);
870
1112
  if (!msgs)
@@ -887,8 +1129,22 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
887
1129
  // Partial and message-tuple events are incremental. Merge them by id
888
1130
  // so optimistic human messages and earlier tool messages are preserved.
889
1131
  if (event.type === 'messages/partial' || event.messageMetadata) {
1132
+ if (!isTranscriptMessageEvent(event, options.transcriptNodeNames)) {
1133
+ storeMessageMetadata(normalized, event);
1134
+ syncSubagentsFromMessages(normalized);
1135
+ return;
1136
+ }
890
1137
  const mode = event.messageMetadata ? 'delta' : 'snapshot';
891
- subjects.messages$.next(mergeMessages(subjects.messages$.value, normalized, reasoningTimingMap, mode, canonicalMessageIds));
1138
+ const affectedMessageIds = new Set();
1139
+ const merged = mergeMessages(subjects.messages$.value, normalized, reasoningTimingMap, mode, canonicalMessageIds, affectedMessageIds, activeAttempt?.currentAssistantMessageId !== undefined
1140
+ && activeAttempt.currentStepHasTerminalEvidence !== true);
1141
+ subjects.messages$.next(merged);
1142
+ if (!isSubagentNamespace(namespace)) {
1143
+ trackAssistantMessages(merged.filter(message => {
1144
+ const id = message['id'];
1145
+ return typeof id === 'string' && affectedMessageIds.has(id);
1146
+ }));
1147
+ }
892
1148
  if (isLgTraceEnabled()) {
893
1149
  const msgs = subjects.messages$.value;
894
1150
  const last = msgs[msgs.length - 1];
@@ -903,8 +1159,17 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
903
1159
  else {
904
1160
  // Preserve existing ids by content so the final-id swap doesn't
905
1161
  // tear down the chat-message DOM (and its streaming-md renderer).
906
- subjects.messages$.next(preserveIds(subjects.messages$.value, normalized));
1162
+ const affectedMessageIds = new Set();
1163
+ const preserved = preserveIds(subjects.messages$.value, normalized, affectedMessageIds);
1164
+ subjects.messages$.next(preserved);
1165
+ if (!isSubagentNamespace(namespace)) {
1166
+ trackAssistantMessages(preserved.filter(message => {
1167
+ const id = message['id'];
1168
+ return typeof id === 'string' && affectedMessageIds.has(id);
1169
+ }));
1170
+ }
907
1171
  }
1172
+ markNormalTerminal(event);
908
1173
  storeMessageMetadata(normalized, event);
909
1174
  syncSubagentsFromMessages(normalized);
910
1175
  syncToolCallsFromMessages();
@@ -920,6 +1185,15 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
920
1185
  updateSubagentValues(namespace, vals);
921
1186
  break;
922
1187
  }
1188
+ if ((namespace?.length ?? 0) === 0) {
1189
+ if (hasInterrupts(vals)) {
1190
+ if (activeAttempt)
1191
+ finalizeAttempt(activeAttempt, 'paused');
1192
+ }
1193
+ else {
1194
+ markNormalTerminal(event);
1195
+ }
1196
+ }
923
1197
  if (vals != null) {
924
1198
  extractInterrupts(vals, subjects);
925
1199
  subjects.values$.next(vals);
@@ -988,13 +1262,19 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
988
1262
  break;
989
1263
  }
990
1264
  case 'error':
1265
+ if (activeAttempt)
1266
+ finalizeAttempt(activeAttempt, 'error');
991
1267
  subjects.error$.next(toAgentError(event['error']));
992
1268
  subjects.status$.next(ResourceStatus.Error);
993
1269
  break;
994
1270
  case 'interrupt':
1271
+ if (activeAttempt)
1272
+ finalizeAttempt(activeAttempt, 'paused');
995
1273
  subjects.interrupt$.next(event['interrupt']);
996
1274
  break;
997
1275
  case 'interrupts':
1276
+ if (activeAttempt)
1277
+ finalizeAttempt(activeAttempt, 'paused');
998
1278
  subjects.interrupts$.next(event['interrupts']);
999
1279
  break;
1000
1280
  case 'custom': {
@@ -1132,14 +1412,19 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
1132
1412
  await runStream(payload, opts);
1133
1413
  },
1134
1414
  stop: async () => {
1135
- userAbortRequested = true;
1415
+ invalidateQueueDrain();
1416
+ const shouldAbortAttempt = Boolean(abortController && activeAttempt && !activeAttempt.terminalOutcome);
1417
+ if (shouldAbortAttempt && abortController)
1418
+ userAbortedControllers.add(abortController);
1419
+ if (shouldAbortAttempt && activeAttempt)
1420
+ finalizeAttempt(activeAttempt, 'aborted');
1136
1421
  abortController?.abort();
1422
+ if (activeAttempt?.awaitingFinalSync)
1423
+ historyAbortController?.abort();
1137
1424
  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) {
1425
+ // Set Idle synchronously for an active user cancellation. Attempts that
1426
+ // already reached a terminal outcome retain their existing status.
1427
+ if (shouldAbortAttempt && subjects.status$.value !== ResourceStatus.Idle) {
1143
1428
  subjects.status$.next(ResourceStatus.Idle);
1144
1429
  }
1145
1430
  },
@@ -1149,8 +1434,12 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
1149
1434
  joinStream: async (runId, lastEventId) => {
1150
1435
  if (!currentThreadId)
1151
1436
  return;
1437
+ invalidateQueueDrain();
1152
1438
  abortController?.abort();
1153
- abortController = new AbortController();
1439
+ const controller = new AbortController();
1440
+ abortController = controller;
1441
+ const attempt = beginAttempt(true);
1442
+ const threadId = currentThreadId;
1154
1443
  const startedAt = Date.now();
1155
1444
  captureRuntimeRequestTelemetry('join');
1156
1445
  captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_started', telemetryProperties);
@@ -1158,28 +1447,55 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
1158
1447
  subjects.toolProgress$.next([]);
1159
1448
  toolProgressMap.clear();
1160
1449
  subjects.status$.next(ResourceStatus.Loading);
1450
+ subjects.error$.next(undefined);
1161
1451
  try {
1162
1452
  const iter = transport.joinStream
1163
- ? transport.joinStream(currentThreadId, runId, lastEventId, abortController.signal)
1453
+ ? transport.joinStream(threadId, runId, lastEventId, controller.signal)
1164
1454
  : [];
1165
1455
  for await (const event of iter) {
1456
+ if (controller.signal.aborted || !isCurrentExecution(controller, attempt))
1457
+ break;
1166
1458
  processEvent(event);
1167
1459
  }
1168
- subjects.status$.next(ResourceStatus.Resolved);
1169
- await refreshHistory();
1170
- captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_ended', {
1171
- ...telemetryProperties,
1172
- durationMs: Date.now() - startedAt,
1173
- });
1460
+ if (!isCurrentExecution(controller, attempt))
1461
+ return;
1462
+ const outcome = await finalizeClosedAttempt(controller, attempt);
1463
+ if (outcome === null)
1464
+ return;
1465
+ if (!controller.signal.aborted) {
1466
+ if (outcome !== 'error') {
1467
+ subjects.status$.next(ResourceStatus.Resolved);
1468
+ }
1469
+ captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_ended', {
1470
+ ...telemetryProperties,
1471
+ durationMs: Date.now() - startedAt,
1472
+ });
1473
+ }
1174
1474
  }
1175
1475
  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
- });
1476
+ if (!isCurrentExecution(controller, attempt))
1477
+ return;
1478
+ if (attempt.terminalOutcome)
1479
+ return;
1480
+ if (isAbortError(err) && userAbortedControllers.has(controller)) {
1481
+ finalizeAttempt(attempt, 'aborted');
1482
+ subjects.error$.next(undefined);
1483
+ subjects.status$.next(ResourceStatus.Idle);
1484
+ }
1485
+ else {
1486
+ finalizeAttempt(attempt, attempt.sawAssistantChunk ? 'interrupted' : 'error');
1487
+ subjects.error$.next(toAgentError(err));
1488
+ subjects.status$.next(ResourceStatus.Error);
1489
+ captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_errored', {
1490
+ ...telemetryProperties,
1491
+ durationMs: Date.now() - startedAt,
1492
+ errorClass: agentRuntimeTelemetryErrorClass(err),
1493
+ });
1494
+ }
1495
+ }
1496
+ finally {
1497
+ if (abortController === controller)
1498
+ abortController = null;
1183
1499
  }
1184
1500
  },
1185
1501
  resubmitLast: async () => {
@@ -1195,6 +1511,9 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
1195
1511
  return undefined;
1196
1512
  return entry.endedAt - entry.startedAt;
1197
1513
  },
1514
+ getMessageDelivery: (id) => messageDeliveries.get(id) ?? staticDelivery(id),
1515
+ getSubagentMessageDelivery: (toolCallId, message) => subagentManager.getMessageDelivery(toolCallId, message),
1516
+ deliveryRevision,
1198
1517
  updateState: async (values, opts) => {
1199
1518
  // No-op when there is no thread yet or the transport doesn't support
1200
1519
  // updateState (e.g. MockAgentTransport in unit tests without a threadId).
@@ -1208,6 +1527,14 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
1208
1527
  },
1209
1528
  };
1210
1529
  }
1530
+ function isTranscriptMessageEvent(event, transcriptNodeNames) {
1531
+ if (!transcriptNodeNames || transcriptNodeNames.length === 0)
1532
+ return true;
1533
+ const node = event.messageMetadata?.['langgraph_node'];
1534
+ if (typeof node !== 'string')
1535
+ return true;
1536
+ return transcriptNodeNames.includes(node);
1537
+ }
1211
1538
  /**
1212
1539
  * Extracts the payload data from a normalized SDK event.
1213
1540
  *
@@ -1242,6 +1569,12 @@ function extractInterrupts(payload, subjects) {
1242
1569
  subjects.interrupts$.next([]);
1243
1570
  }
1244
1571
  }
1572
+ function hasInterrupts(payload) {
1573
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload))
1574
+ return false;
1575
+ const raw = payload['__interrupt__'];
1576
+ return Array.isArray(raw) && raw.length > 0;
1577
+ }
1245
1578
  /**
1246
1579
  * Projects pending interrupts from the latest history checkpoint onto the
1247
1580
  * interrupt$ / interrupts$ subjects. ThreadState exposes interrupts under
@@ -1337,7 +1670,7 @@ function normalizeMessages(event) {
1337
1670
  * carry the full text we collapse them, keeping the older slot's id so
1338
1671
  * track-by-id stays stable in the chat list.
1339
1672
  */
1340
- function collapseAdjacentAi(messages) {
1673
+ function collapseAdjacentAi(messages, affectedMessageIds, allowCrossIdAiMerge = true) {
1341
1674
  if (messages.length < 2)
1342
1675
  return messages;
1343
1676
  const out = [];
@@ -1352,13 +1685,21 @@ function collapseAdjacentAi(messages) {
1352
1685
  if (lastType === 'ai' && msgType === 'ai') {
1353
1686
  const lastText = extractText(last.content);
1354
1687
  const msgText = extractText(msg.content);
1355
- if (lastText.length === 0
1688
+ const lastRaw = last;
1689
+ const msgRaw = msg;
1690
+ const differentIds = lastRaw['id'] !== msgRaw['id'];
1691
+ if (!(differentIds && !allowCrossIdAiMerge) && (lastText.length === 0
1356
1692
  || msgText.length === 0
1357
1693
  || lastText === msgText
1358
1694
  || lastText.startsWith(msgText)
1359
- || msgText.startsWith(lastText)) {
1695
+ || msgText.startsWith(lastText))) {
1360
1696
  // Keep the longer content; preserve last (older) id and metadata.
1361
1697
  const longerText = msgText.length >= lastText.length ? msgText : lastText;
1698
+ const lastId = last['id'];
1699
+ const msgId = msg['id'];
1700
+ if (typeof msgId === 'string' && affectedMessageIds?.delete(msgId) && typeof lastId === 'string') {
1701
+ affectedMessageIds.add(lastId);
1702
+ }
1362
1703
  out[out.length - 1] = { ...last, content: longerText };
1363
1704
  continue;
1364
1705
  }
@@ -1367,7 +1708,7 @@ function collapseAdjacentAi(messages) {
1367
1708
  }
1368
1709
  return out;
1369
1710
  }
1370
- function mergeMessages(existing, incoming, reasoningTimingMap, mode = 'snapshot', canonicalMessageIds) {
1711
+ function mergeMessages(existing, incoming, reasoningTimingMap, mode = 'snapshot', canonicalMessageIds, affectedMessageIds, allowCrossIdAiMerge = true) {
1371
1712
  const merged = [...existing];
1372
1713
  for (const msg of incoming) {
1373
1714
  const rawIn = msg;
@@ -1381,6 +1722,9 @@ function mergeMessages(existing, incoming, reasoningTimingMap, mode = 'snapshot'
1381
1722
  // prevents DOM teardown + animation restarts mid-stream.
1382
1723
  if (idx < 0) {
1383
1724
  idx = findContentMatch(merged, msg);
1725
+ if (idx >= 0 && !canMergeCrossIdAi(merged[idx], msg, allowCrossIdAiMerge)) {
1726
+ idx = -1;
1727
+ }
1384
1728
  }
1385
1729
  // When an AIMessageChunk arrives without an id-match or content-prefix
1386
1730
  // match, treat the trailing AI message as its accumulator. The
@@ -1395,6 +1739,8 @@ function mergeMessages(existing, incoming, reasoningTimingMap, mode = 'snapshot'
1395
1739
  ? merged[i]._getType()
1396
1740
  : merged[i]['type']);
1397
1741
  if (t === 'ai') {
1742
+ if (!canMergeCrossIdAi(merged[i], msg, allowCrossIdAiMerge))
1743
+ break;
1398
1744
  idx = i;
1399
1745
  break;
1400
1746
  }
@@ -1450,7 +1796,10 @@ function mergeMessages(existing, incoming, reasoningTimingMap, mode = 'snapshot'
1450
1796
  if (existingId) {
1451
1797
  next['id'] = existingId;
1452
1798
  }
1799
+ const changed = mode === 'delta' || messageChangedForDelivery(existing, next);
1453
1800
  merged[idx] = next;
1801
+ if (targetId && changed)
1802
+ affectedMessageIds?.add(targetId);
1454
1803
  }
1455
1804
  else {
1456
1805
  const incomingRaw = msg;
@@ -1467,9 +1816,19 @@ function mergeMessages(existing, incoming, reasoningTimingMap, mode = 'snapshot'
1467
1816
  const next = { ...msg };
1468
1817
  next['reasoning'] = initialReasoning;
1469
1818
  merged.push(next);
1819
+ const nextId = next['id'];
1820
+ if (typeof nextId === 'string')
1821
+ affectedMessageIds?.add(nextId);
1470
1822
  }
1471
1823
  }
1472
- return collapseAdjacentAi(merged);
1824
+ return collapseAdjacentAi(merged, affectedMessageIds, allowCrossIdAiMerge);
1825
+ }
1826
+ function canMergeCrossIdAi(candidate, incoming, allowCrossIdAiMerge) {
1827
+ const candidateRaw = candidate;
1828
+ const incomingRaw = incoming;
1829
+ if (candidateRaw['id'] === incomingRaw['id'])
1830
+ return true;
1831
+ return allowCrossIdAiMerge;
1473
1832
  }
1474
1833
  /**
1475
1834
  * Merge an incoming chunk's content into prior accumulated content for the
@@ -1627,9 +1986,15 @@ function accumulateReasoning(existing, incoming) {
1627
1986
  * (role, content) matches positionally and the existing id differs. Keeps
1628
1987
  * track-by-id stable across server echoes and final-id swaps.
1629
1988
  */
1630
- function preserveIds(existing, incoming) {
1631
- if (existing.length === 0)
1632
- return collapseAdjacentAi(incoming);
1989
+ function preserveIds(existing, incoming, affectedMessageIds) {
1990
+ if (existing.length === 0) {
1991
+ for (const message of incoming) {
1992
+ const id = message['id'];
1993
+ if (typeof id === 'string')
1994
+ affectedMessageIds?.add(id);
1995
+ }
1996
+ return collapseAdjacentAi(incoming, affectedMessageIds);
1997
+ }
1633
1998
  const usedExisting = new Set();
1634
1999
  const remapped = incoming.map((msg, i) => {
1635
2000
  const inRaw = msg;
@@ -1643,15 +2008,40 @@ function preserveIds(existing, incoming) {
1643
2008
  // Fallback: any unused existing message with matching role+content.
1644
2009
  matchIdx = existing.findIndex((m, j) => !usedExisting.has(j) && sameRoleAndContent(m, msg));
1645
2010
  }
1646
- if (matchIdx < 0)
2011
+ if (matchIdx < 0) {
2012
+ if (typeof inId === 'string')
2013
+ affectedMessageIds?.add(inId);
1647
2014
  return msg;
2015
+ }
1648
2016
  usedExisting.add(matchIdx);
1649
2017
  const existingId = existing[matchIdx]['id'];
1650
- if (!existingId || existingId === inId)
1651
- return msg;
1652
- return { ...msg, id: existingId };
2018
+ const remappedMessage = !existingId || existingId === inId
2019
+ ? msg
2020
+ : { ...msg, id: existingId };
2021
+ if (typeof existingId === 'string' && messageChangedForDelivery(existing[matchIdx], remappedMessage)) {
2022
+ affectedMessageIds?.add(existingId);
2023
+ }
2024
+ return remappedMessage;
1653
2025
  });
1654
- return collapseAdjacentAi(remapped);
2026
+ return collapseAdjacentAi(remapped, affectedMessageIds);
2027
+ }
2028
+ function messageChangedForDelivery(existing, incoming) {
2029
+ const existingRaw = existing;
2030
+ const incomingRaw = incoming;
2031
+ const existingType = normalizeMessageType(typeof existing._getType === 'function' ? existing._getType() : existingRaw['type']);
2032
+ const incomingType = normalizeMessageType(typeof incoming._getType === 'function' ? incoming._getType() : incomingRaw['type']);
2033
+ if (existingType !== incomingType || extractText(existing.content) !== extractText(incoming.content)) {
2034
+ return true;
2035
+ }
2036
+ const existingReasoning = typeof existingRaw['reasoning'] === 'string'
2037
+ ? existingRaw['reasoning']
2038
+ : extractReasoning(existingRaw['reasoning']);
2039
+ const incomingReasoning = typeof incomingRaw['reasoning'] === 'string'
2040
+ ? incomingRaw['reasoning']
2041
+ : extractReasoning(incomingRaw['reasoning']);
2042
+ if (existingReasoning !== incomingReasoning)
2043
+ return true;
2044
+ return JSON.stringify(existingRaw['tool_calls'] ?? null) !== JSON.stringify(incomingRaw['tool_calls'] ?? null);
1655
2045
  }
1656
2046
  function sameRoleAndContent(a, b) {
1657
2047
  const aType = normalizeMessageType(typeof a._getType === 'function' ? a._getType() : a['type']);
@@ -1723,6 +2113,14 @@ function normalizeMessageType(t) {
1723
2113
  return 'system';
1724
2114
  return t;
1725
2115
  }
2116
+ function getTailAssistantMessageId(messages) {
2117
+ const tail = messages[messages.length - 1];
2118
+ if (!tail)
2119
+ return undefined;
2120
+ const raw = tail;
2121
+ const type = normalizeMessageType(typeof tail._getType === 'function' ? tail._getType() : raw['type']);
2122
+ return type === 'ai' && typeof raw['id'] === 'string' ? raw['id'] : undefined;
2123
+ }
1726
2124
  function toSubagentRefs(subagents) {
1727
2125
  const refs = new Map();
1728
2126
  subagents.forEach((subagent, key) => {
@@ -1921,12 +2319,19 @@ function normalizeCitation(entry, fallbackIndex) {
1921
2319
  }
1922
2320
  return undefined;
1923
2321
  };
2322
+ const publishedAt = e['publishedAt'];
2323
+ const normalizedPublishedAt = typeof publishedAt === 'string' || typeof publishedAt === 'number' || publishedAt instanceof Date
2324
+ ? publishedAt
2325
+ : undefined;
1924
2326
  return {
1925
2327
  id: str('id') ?? str('refId') ?? `c${fallbackIndex}`,
1926
2328
  index: typeof e['index'] === 'number' ? e['index'] : fallbackIndex,
1927
2329
  title: firstStr('title', 'name'),
1928
2330
  url: firstStr('url', 'href', 'source'),
1929
2331
  snippet: firstStr('snippet', 'content', 'excerpt'),
2332
+ sourceType: str('sourceType'),
2333
+ iconUrl: str('iconUrl'),
2334
+ publishedAt: normalizedPublishedAt,
1930
2335
  extra: typeof e['extra'] === 'object' && e['extra'] !== null
1931
2336
  ? e['extra']
1932
2337
  : undefined,
@@ -1959,6 +2364,23 @@ function mergeClientTools(payload, catalog) {
1959
2364
  return payload;
1960
2365
  return { ...payload, client_tools: catalog };
1961
2366
  }
2367
+ /**
2368
+ * Prepend staged tool messages to a run payload's message list.
2369
+ *
2370
+ * Mirrors mergeClientTools: a null payload signals a no-input resume and must
2371
+ * stay null, so staged messages cannot ride along and are left buffered.
2372
+ */
2373
+ function mergeStagedToolMessages(payload, staged) {
2374
+ if (staged.length === 0)
2375
+ return payload;
2376
+ if (payload === null || payload === undefined)
2377
+ return payload;
2378
+ if (typeof payload !== 'object' || Array.isArray(payload))
2379
+ return payload;
2380
+ const record = payload;
2381
+ const existing = Array.isArray(record['messages']) ? record['messages'] : [];
2382
+ return { ...record, messages: [...staged, ...existing] };
2383
+ }
1962
2384
  /**
1963
2385
  * Creates a ClientToolsCapability backed by a LangGraph submit function and
1964
2386
  * a store of tool-call signals. Extracted into a factory so it can be
@@ -1973,8 +2395,17 @@ function mergeClientTools(payload, catalog) {
1973
2395
  * yet — but ONLY when the run is not in progress (isLoading===false).
1974
2396
  * The backend ends the run without emitting a ToolMessage result for
1975
2397
  * 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:
2398
+ * - settle(id, result): marks the call as resolved, writes the local result,
2399
+ * and buffers a ToolMessage without issuing a run.
2400
+ * - flush(): makes the whole buffer durable in ONE persistFn call without
2401
+ * starting a run — the settlement path for tool groups that never continue.
2402
+ * The batch leaves the buffer at snapshot time and is re-staged only if the
2403
+ * write fails, so a failure (or an absent persistFn) still degrades to the
2404
+ * next flush or to the drainToolMessages() fallback in the submit wrapper,
2405
+ * while a concurrent resolve()/drain can never re-send an in-flight batch.
2406
+ * - clearStagedToolMessages(): discards the buffer on a thread switch.
2407
+ * - resolve(id, result): settles the result, then issues a NEW run on the SAME
2408
+ * thread by calling submitFn with the full buffered ToolMessage group:
1978
2409
  * input: {
1979
2410
  * messages: [{ type: 'tool', role: 'tool', tool_call_id: id, content }],
1980
2411
  * client_tools: catalog(),
@@ -1990,59 +2421,176 @@ function mergeClientTools(payload, catalog) {
1990
2421
  * before forwarding to manager.submit. This keeps injection concerns
1991
2422
  * co-located with the run-issuing call sites.
1992
2423
  */
1993
- function createClientToolsCapability(submitFn, store) {
2424
+ function createClientToolsCapability(submitFn, store, persistFn, currentThreadIdFn) {
1994
2425
  const catalog = signal([], ...(ngDevMode ? [{ debugName: "catalog" }] : []));
1995
2426
  const resolvedIds = signal(new Set(), ...(ngDevMode ? [{ debugName: "resolvedIds" }] : []));
2427
+ const toolMessageBuffer = [];
2428
+ let flushInFlight;
2429
+ // Bumped whenever the buffer is discarded, so an in-flight flush can tell
2430
+ // whether its batch still belongs to the current thread.
2431
+ let bufferGeneration = 0;
2432
+ // Tool calls belonging to threads we have left. A handler still running when
2433
+ // the user switches threads settles AFTER the switch, by which point both the
2434
+ // store and the current thread id already describe the NEW thread — the id is
2435
+ // the only durable way left to recognise the result as stale.
2436
+ const retiredToolCallIds = new Set();
1996
2437
  const pending = computed(() => {
1997
2438
  // Client tools are only actionable after the run ends (the backend
1998
2439
  // signals this by ending the run WITHOUT emitting a ToolMessage result
1999
2440
  // 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));
2441
+ return selectPendingClientToolCalls({
2442
+ isLoading: store.isLoading(),
2443
+ toolCalls: store.toolCalls(),
2444
+ catalogNames: new Set(catalog().map((s) => s.name)),
2445
+ resolvedIds: resolvedIds(),
2446
+ });
2005
2447
  }, ...(ngDevMode ? [{ debugName: "pending" }] : []));
2448
+ function settleResult(id, result) {
2449
+ // Mark as resolved first so pending() drops it immediately.
2450
+ resolvedIds.update((s) => new Set(s).add(id));
2451
+ // Cast rather than rely on discriminant narrowing: consumer apps that
2452
+ // compile this source with `strictNullChecks: false` don't narrow the
2453
+ // ClientToolResult union in a ternary.
2454
+ const ok = result.ok;
2455
+ const value = result.value;
2456
+ const error = result.error;
2457
+ // Write the outcome onto the LOCAL ToolCall (via the adapter's override
2458
+ // layer). The client tool DID produce a result client-side, so this is
2459
+ // semantically correct — and it freezes the transcript card: the mounted
2460
+ // ask component re-renders with its own emitted value as props and can
2461
+ // branch to a resolved/frozen state. Without this, the LOCAL tool call
2462
+ // never gets a result (only the backend ToolMessage does) so the card
2463
+ // stays interactive forever.
2464
+ store.applyClientResult(id, {
2465
+ result: ok ? value : { error },
2466
+ ...(ok ? {} : { error, status: 'error' }),
2467
+ });
2468
+ const content = ok
2469
+ ? safeStringify(value)
2470
+ : `Error: ${error}`;
2471
+ // The tool call belongs to a thread the user has already left, so there is
2472
+ // nowhere valid to send this result: the current thread has no matching
2473
+ // tool call, and the old thread is no longer the write target.
2474
+ if (retiredToolCallIds.has(id)) {
2475
+ console.warn(`Discarding client tool result for ${id}: its thread is no longer active.`);
2476
+ return;
2477
+ }
2478
+ // Message shape: both `type` and `role` are set for compatibility —
2479
+ // the LangGraph server's add_messages coercion reads `role` (Python
2480
+ // side), while the bridge's local optimistic-message path reads `type`
2481
+ // (via toMessage's normalizeMessageType). This mirrors the human-message
2482
+ // shape used in buildSubmitUpdate (agent.fn.ts line 732).
2483
+ toolMessageBuffer.push({
2484
+ threadId: currentThreadIdFn?.() ?? null,
2485
+ message: { type: 'tool', role: 'tool', tool_call_id: id, content },
2486
+ });
2487
+ }
2488
+ /**
2489
+ * Remove every staged entry, returning only those still valid for the thread
2490
+ * a write would land on right now. An entry is stale when it was stamped with
2491
+ * a different thread — dropped rather than misdelivered.
2492
+ *
2493
+ * A null on either side means "thread not tracked yet" (no threadId option
2494
+ * and no run has reported one); those are kept, since there is no evidence of
2495
+ * a switch and dropping them would lose results on untracked transports.
2496
+ */
2497
+ function takeStagedForCurrentThread() {
2498
+ const current = currentThreadIdFn?.() ?? null;
2499
+ const taken = toolMessageBuffer.splice(0, toolMessageBuffer.length);
2500
+ return taken.filter((entry) => {
2501
+ const stale = entry.threadId !== null && current !== null && entry.threadId !== current;
2502
+ if (stale) {
2503
+ console.warn(`Discarding a client tool result staged for thread ${entry.threadId}; ` +
2504
+ `the active thread is now ${current}.`);
2505
+ }
2506
+ return !stale;
2507
+ });
2508
+ }
2509
+ /**
2510
+ * Persist one batch. Takes ownership of the buffer at snapshot time:
2511
+ * resolve() and drainToolMessages() clear the buffer unconditionally and know
2512
+ * nothing about an in-flight write, so anything left staged across the await
2513
+ * could be re-sent (a duplicate ToolMessage for one tool_call_id) or removed
2514
+ * by the wrong index (dropping a result that was never persisted).
2515
+ */
2516
+ function runFlush() {
2517
+ if (!persistFn)
2518
+ return Promise.resolve();
2519
+ const staged = takeStagedForCurrentThread();
2520
+ if (staged.length === 0)
2521
+ return Promise.resolve();
2522
+ const generation = bufferGeneration;
2523
+ const batch = staged.map((entry) => entry.message);
2524
+ const inFlight = persistFn(batch)
2525
+ .catch((err) => {
2526
+ // Re-stage at the FRONT so ordering is preserved for the next drain —
2527
+ // unless the buffer was cleared meanwhile (thread switch), in which
2528
+ // case these results belong to a thread we have left.
2529
+ if (generation === bufferGeneration) {
2530
+ toolMessageBuffer.unshift(...staged);
2531
+ }
2532
+ console.warn(`Client tool flush failed; ${batch.length} result(s) remain staged for the next run.`, err);
2533
+ })
2534
+ .finally(() => {
2535
+ // Only clear if no later flush has already claimed the slot.
2536
+ if (flushInFlight === inFlight)
2537
+ flushInFlight = undefined;
2538
+ });
2539
+ flushInFlight = inFlight;
2540
+ return inFlight;
2541
+ }
2006
2542
  const capability = {
2007
2543
  catalog,
2008
2544
  setCatalog(specs) {
2009
2545
  catalog.set([...specs]);
2010
2546
  },
2011
2547
  pending,
2548
+ settle(id, result) {
2549
+ settleResult(id, result);
2550
+ },
2551
+ /** Remove and return every buffered tool message valid for this thread. */
2552
+ drainToolMessages() {
2553
+ return takeStagedForCurrentThread().map((entry) => entry.message);
2554
+ },
2555
+ /**
2556
+ * Discard everything staged. Called when the active thread changes: a
2557
+ * ToolMessage only makes sense against the thread whose AIMessage produced
2558
+ * its tool_call_id, so carrying it over would poison the new thread.
2559
+ */
2560
+ clearStagedToolMessages() {
2561
+ // Retire the outgoing thread's tool calls so a handler that settles after
2562
+ // the switch is recognised as stale. Read the store BEFORE it resets —
2563
+ // agent.fn.ts calls this ahead of manager.switchThread for that reason.
2564
+ for (const toolCall of store.toolCalls())
2565
+ retiredToolCallIds.add(toolCall.id);
2566
+ toolMessageBuffer.length = 0;
2567
+ // Invalidate any in-flight flush so its failure path cannot re-stage the
2568
+ // old thread's messages into the new thread's buffer.
2569
+ bufferGeneration += 1;
2570
+ },
2571
+ flush() {
2572
+ if (!persistFn)
2573
+ return Promise.resolve();
2574
+ if (flushInFlight) {
2575
+ // Chain rather than short-circuit. The caller's batch may have been
2576
+ // staged AFTER the in-flight write took its snapshot, so returning that
2577
+ // promise would resolve without ever persisting it — which is exactly
2578
+ // what happens when an abort fires one flush per settled call. The
2579
+ // chain terminates because runFlush() returns immediately once the
2580
+ // buffer is empty.
2581
+ const chained = flushInFlight.then(() => runFlush());
2582
+ flushInFlight = chained;
2583
+ return chained;
2584
+ }
2585
+ return runFlush();
2586
+ },
2012
2587
  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}`;
2588
+ settleResult(id, result);
2035
2589
  // Issue a new run on the same thread. LangGraph's add_messages reducer
2036
- // appends the ToolMessage to the thread state. `client_tools` is
2590
+ // appends the ToolMessages to the thread state. `client_tools` is
2037
2591
  // 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).
2044
2592
  const toolPayload = {
2045
- messages: [{ type: 'tool', role: 'tool', tool_call_id: id, content }],
2593
+ messages: takeStagedForCurrentThread().map((entry) => entry.message),
2046
2594
  client_tools: catalog(),
2047
2595
  };
2048
2596
  void submitFn(toolPayload);
@@ -2156,10 +2704,21 @@ function agent(options) {
2156
2704
  });
2157
2705
  const custom$ = new BehaviorSubject([]);
2158
2706
  const hasValue$ = new BehaviorSubject(false);
2707
+ // Forward reference. The client-tools capability is built much further down
2708
+ // (it needs `manager`, which needs these subjects), but the thread-change
2709
+ // seam lives here. A holder keeps the binding itself a `const` while its
2710
+ // member is filled in later.
2711
+ const clientToolStaging = {};
2159
2712
  function resetDerivedThreadState() {
2160
2713
  status$.next(ResourceStatus.Idle);
2161
2714
  error$.next(undefined);
2162
2715
  hasValue$.next(false);
2716
+ // Staged client-tool results belong to the thread whose AIMessage produced
2717
+ // their tool_call_ids. Carrying them into a different thread would prepend
2718
+ // a ToolMessage that matches no tool call there — a 400 on that turn.
2719
+ // Runs BEFORE manager.switchThread resets the store, so the capability can
2720
+ // still read the outgoing thread's tool calls.
2721
+ clientToolStaging.clear?.();
2163
2722
  }
2164
2723
  // Track hasValue — becomes true once values or messages arrive
2165
2724
  values$.pipe(takeUntil$1(destroy$)).subscribe(v => {
@@ -2314,7 +2873,10 @@ function agent(options) {
2314
2873
  // `@let content = messageContent(message)` short-circuits — DOM never
2315
2874
  // updates per token. DOM stability is provided by `track message.id`
2316
2875
  // in chat-message-list, not by Message identity.
2317
- const messagesNeutral = computed(() => rawMessages().map((m) => toMessage(m, manager.getReasoningDurationMs)), ...(ngDevMode ? [{ debugName: "messagesNeutral" }] : []));
2876
+ const messagesNeutral = computed(() => {
2877
+ manager.deliveryRevision();
2878
+ return rawMessages().map((m) => toMessage(m, manager.getReasoningDurationMs, manager.getMessageDelivery));
2879
+ }, ...(ngDevMode ? [{ debugName: "messagesNeutral" }] : []));
2318
2880
  // Client-tool resolutions written client-side. The raw `toolCalls$` stream
2319
2881
  // (and thus `rawToolCalls`) only ever carries backend results — a resolved
2320
2882
  // client tool (`ask`/`view`) never receives a backend ToolMessage on its
@@ -2341,7 +2903,7 @@ function agent(options) {
2341
2903
  }, ...(ngDevMode ? [{ debugName: "interruptNeutral" }] : []));
2342
2904
  const subagentsNeutral = computed(() => {
2343
2905
  const out = new Map();
2344
- subagentsSig().forEach((sa, key) => out.set(key, toSubagent(sa)));
2906
+ subagentsSig().forEach((sa, key) => out.set(key, toSubagent(sa, manager)));
2345
2907
  return out;
2346
2908
  }, ...(ngDevMode ? [{ debugName: "subagentsNeutral" }] : []));
2347
2909
  const historyNeutral = computed(() => historySig().map(toCheckpoint), ...(ngDevMode ? [{ debugName: "historyNeutral" }] : []));
@@ -2353,11 +2915,34 @@ function agent(options) {
2353
2915
  // follow-up runs (resolve) without going through the full submit() wrapper.
2354
2916
  // The catalog is injected into every outbound payload via mergeClientTools()
2355
2917
  // in the submit wrapper below and in the resolve path inside the capability.
2918
+ //
2919
+ // flush() needs a durable write that does NOT start a run. The bridge's
2920
+ // updateState() silently no-ops when the transport has no updateState, so
2921
+ // only supply a persist function when the effective transport supports it —
2922
+ // an omitted transport means the bridge builds a FetchStreamTransport, which
2923
+ // does. When persistFn is undefined, flush() keeps the buffer and the submit
2924
+ // wrapper below drains it into the next run instead.
2925
+ const canPersistToolMessages = !transport || typeof transport.updateState === 'function';
2356
2926
  const clientToolsCap = createClientToolsCapability((payload, opts) => manager.submit(payload, opts), {
2357
2927
  toolCalls: toolCallsNeutral,
2358
2928
  isLoading,
2359
2929
  applyClientResult: (id, patch) => clientResultOverrides.update((m) => new Map(m).set(id, patch)),
2360
- });
2930
+ }, canPersistToolMessages
2931
+ ? async (messages) => {
2932
+ // Throw rather than let the bridge no-op: without a thread there is
2933
+ // nothing to write to, and flush() must keep the buffer staged.
2934
+ if (!manager.currentThreadId) {
2935
+ throw new Error('no threadId for client tool flush');
2936
+ }
2937
+ // No asNode: add_messages appends the ToolMessages and the graph's
2938
+ // resume point is left untouched, so no run is started.
2939
+ await manager.updateState({ messages: [...messages] });
2940
+ }
2941
+ : undefined,
2942
+ // Stamps each staged result with the thread it was settled on, so a write
2943
+ // can never land on a thread the user has since moved to.
2944
+ () => manager.currentThreadId);
2945
+ clientToolStaging.clear = () => clientToolsCap.clearStagedToolMessages();
2361
2946
  return {
2362
2947
  // ── Runtime-neutral surface (AgentWithHistory) ────────────────────────
2363
2948
  messages: messagesNeutral,
@@ -2384,7 +2969,18 @@ function agent(options) {
2384
2969
  // Thread the client-tools catalog into every outbound payload so the
2385
2970
  // backend middleware can merge them into the model's tool list. Null
2386
2971
  // payloads (regenerate re-runs, command resumes) are left unchanged.
2387
- const payload = mergeClientTools(request.payload, clientToolsCap.catalog());
2972
+ //
2973
+ // Drain any results settled but not yet made durable (flush unavailable
2974
+ // or a prior flush failed) so they ride along with this run. A null
2975
+ // payload cannot carry them, so leave the buffer alone in that case
2976
+ // rather than silently discarding the staged results.
2977
+ const staged = request.payload === null || request.payload === undefined
2978
+ ? []
2979
+ : clientToolsCap.drainToolMessages();
2980
+ const withStaged = staged.length > 0
2981
+ ? mergeStagedToolMessages(request.payload, staged)
2982
+ : request.payload;
2983
+ const payload = mergeClientTools(withStaged, clientToolsCap.catalog());
2388
2984
  return manager.submit(payload, request.options);
2389
2985
  },
2390
2986
  stop: () => manager.stop(),
@@ -2537,7 +3133,7 @@ function mapStatus(s) {
2537
3133
  return 'idle';
2538
3134
  }
2539
3135
  }
2540
- function toMessage(m, getReasoningDurationMs) {
3136
+ function toMessage(m, getReasoningDurationMs, getDelivery) {
2541
3137
  const raw = m;
2542
3138
  const typeVal = typeof m._getType === 'function'
2543
3139
  ? m._getType()
@@ -2556,6 +3152,7 @@ function toMessage(m, getReasoningDurationMs) {
2556
3152
  const result = {
2557
3153
  id,
2558
3154
  role,
3155
+ delivery: getDelivery?.(id) ?? staticDelivery(id),
2559
3156
  content: extractTextContent(m.content),
2560
3157
  toolCallId: raw['tool_call_id'],
2561
3158
  name: raw['name'],
@@ -2632,12 +3229,15 @@ function toInterrupt(ix) {
2632
3229
  resumable: true,
2633
3230
  };
2634
3231
  }
2635
- function toSubagent(sa) {
3232
+ function toSubagent(sa, manager) {
2636
3233
  return {
2637
3234
  toolCallId: sa.toolCallId,
2638
3235
  name: sa.name,
2639
3236
  status: sa.status,
2640
- messages: computed(() => sa.messages().map((m) => toMessage(m))),
3237
+ messages: computed(() => {
3238
+ manager.deliveryRevision();
3239
+ return sa.messages().map((m) => toMessage(m, undefined, () => manager.getSubagentMessageDelivery(sa.toolCallId, m)));
3240
+ }),
2641
3241
  state: sa.values,
2642
3242
  };
2643
3243
  }
@@ -2756,6 +3356,7 @@ function agentFactory() {
2756
3356
  ...(config.telemetry !== undefined ? { telemetry: config.telemetry } : {}),
2757
3357
  ...(config.filterSubagentMessages !== undefined ? { filterSubagentMessages: config.filterSubagentMessages } : {}),
2758
3358
  ...(config.subagentToolNames !== undefined ? { subagentToolNames: config.subagentToolNames } : {}),
3359
+ ...(config.transcriptNodeNames !== undefined ? { transcriptNodeNames: config.transcriptNodeNames } : {}),
2759
3360
  });
2760
3361
  }
2761
3362
  function isAgentRef(x) {
@@ -3105,7 +3706,7 @@ function delay(ms) {
3105
3706
  * @example
3106
3707
  * ```ts
3107
3708
  * TestBed.configureTestingModule({
3108
- * providers: [provideFakeAgent({ responses: ['Hi from the fake LangGraph agent'] })],
3709
+ * providers: [provideFakeAgent({ tokens: ['Hi from the fake LangGraph agent'] })],
3109
3710
  * });
3110
3711
  * ```
3111
3712
  */