@threadplane/langgraph 0.0.53 → 0.0.55

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.
package/README.md CHANGED
@@ -147,7 +147,7 @@ import { provideAgent, LangGraphThreadsAdapter, LANGGRAPH_THREADS_CONFIG } from
147
147
 
148
148
  export const appConfig: ApplicationConfig = {
149
149
  providers: [
150
- provideAgent({ apiUrl: 'https://your-langgraph-platform.com' }),
150
+ provideAgent({ apiUrl: 'https://your-langgraph-platform.com', assistantId: 'my-agent' }),
151
151
  { provide: LANGGRAPH_THREADS_CONFIG, useValue: { apiUrl: 'https://your-langgraph-platform.com' } },
152
152
  LangGraphThreadsAdapter,
153
153
  ],
@@ -496,7 +496,7 @@ function isLgTraceEnabled() {
496
496
  function lgTrace(...args) {
497
497
  if (isLgTraceEnabled()) {
498
498
  // eslint-disable-next-line no-console
499
- console.debug('[ngaf-chat-stream]', ...args);
499
+ console.debug('[tplane-chat-stream]', ...args);
500
500
  }
501
501
  }
502
502
  function captureAgentRuntimeTelemetry(sink, event, properties) {
@@ -540,6 +540,10 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
540
540
  /** True when the current abort was user-initiated (via stop()). Reset at the start of every new runStream(). */
541
541
  let userAbortRequested = false;
542
542
  const toolProgressMap = new Map();
543
+ // Message ids whose content is known-final (installed by a canonical
544
+ // replacement). Late streamed deltas for these ids are stale stragglers and
545
+ // are ignored — decided by identity, never by comparing text to text.
546
+ const canonicalMessageIds = new Set();
543
547
  const queuedRuns = [];
544
548
  let drainingQueue = false;
545
549
  const subagentManager = new SubagentTracker({
@@ -547,9 +551,9 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
547
551
  onSubagentChange: publishSubagents,
548
552
  });
549
553
  const telemetryProperties = { transport: 'langgraph', surface: 'agent' };
550
- captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:runtime_instance_created', telemetryProperties);
554
+ captureAgentRuntimeTelemetry(options.telemetry, 'tplane:runtime_instance_created', telemetryProperties);
551
555
  function captureRuntimeRequestTelemetry(requestType) {
552
- captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:runtime_request_created', {
556
+ captureAgentRuntimeTelemetry(options.telemetry, 'tplane:runtime_request_created', {
553
557
  ...telemetryProperties,
554
558
  requestType,
555
559
  });
@@ -579,6 +583,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
579
583
  toolProgressMap.clear();
580
584
  subagentManager.clear();
581
585
  reasoningTimingMap.clear();
586
+ canonicalMessageIds.clear();
582
587
  }
583
588
  function setThreadId(id, resetState) {
584
589
  if (resetState) {
@@ -734,7 +739,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
734
739
  abortController = new AbortController();
735
740
  const startedAt = Date.now();
736
741
  captureRuntimeRequestTelemetry('join_queued');
737
- captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_started', telemetryProperties);
742
+ captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_started', telemetryProperties);
738
743
  subjects.custom$.next([]);
739
744
  subjects.toolProgress$.next([]);
740
745
  toolProgressMap.clear();
@@ -754,7 +759,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
754
759
  // post-process node mutations (RemoveMessage, id-match content
755
760
  // replacement) reflected on the server are picked up client-side.
756
761
  await refreshHistory(true);
757
- captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_ended', {
762
+ captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_ended', {
758
763
  ...telemetryProperties,
759
764
  durationMs: Date.now() - startedAt,
760
765
  });
@@ -763,7 +768,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
763
768
  catch (err) {
764
769
  subjects.error$.next(toAgentError(err));
765
770
  subjects.status$.next(ResourceStatus.Error);
766
- captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_errored', {
771
+ captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_errored', {
767
772
  ...telemetryProperties,
768
773
  durationMs: Date.now() - startedAt,
769
774
  errorClass: agentRuntimeTelemetryErrorClass(err),
@@ -776,12 +781,13 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
776
781
  userAbortRequested = false;
777
782
  const startedAt = Date.now();
778
783
  captureRuntimeRequestTelemetry(requestType);
779
- captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_started', telemetryProperties);
784
+ captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_started', telemetryProperties);
780
785
  subjects.status$.next(ResourceStatus.Loading);
781
786
  subjects.error$.next(undefined);
782
787
  subjects.custom$.next([]);
783
788
  subjects.toolProgress$.next([]);
784
789
  toolProgressMap.clear();
790
+ canonicalMessageIds.clear();
785
791
  lastPayload = payload;
786
792
  lastOptions = opts;
787
793
  // Tracks whether at least one stream event has been processed this run.
@@ -820,7 +826,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
820
826
  // authoritative after run completion.
821
827
  await refreshHistory(true);
822
828
  await drainQueue();
823
- captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_ended', {
829
+ captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_ended', {
824
830
  ...telemetryProperties,
825
831
  durationMs: Date.now() - startedAt,
826
832
  });
@@ -839,7 +845,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
839
845
  : new AgentError({ kind: 'connection', message: AGENT_ERROR_MESSAGES.connection, retryable: true, cause: err });
840
846
  subjects.error$.next(e);
841
847
  subjects.status$.next(ResourceStatus.Error);
842
- captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_errored', {
848
+ captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_errored', {
843
849
  ...telemetryProperties,
844
850
  durationMs: Date.now() - startedAt,
845
851
  errorClass: agentRuntimeTelemetryErrorClass(err),
@@ -848,7 +854,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
848
854
  else {
849
855
  subjects.error$.next(toAgentError(err));
850
856
  subjects.status$.next(ResourceStatus.Error);
851
- captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_errored', {
857
+ captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_errored', {
852
858
  ...telemetryProperties,
853
859
  durationMs: Date.now() - startedAt,
854
860
  errorClass: agentRuntimeTelemetryErrorClass(err),
@@ -881,7 +887,8 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
881
887
  // Partial and message-tuple events are incremental. Merge them by id
882
888
  // so optimistic human messages and earlier tool messages are preserved.
883
889
  if (event.type === 'messages/partial' || event.messageMetadata) {
884
- subjects.messages$.next(mergeMessages(subjects.messages$.value, normalized, reasoningTimingMap));
890
+ const mode = event.messageMetadata ? 'delta' : 'snapshot';
891
+ subjects.messages$.next(mergeMessages(subjects.messages$.value, normalized, reasoningTimingMap, mode, canonicalMessageIds));
885
892
  if (isLgTraceEnabled()) {
886
893
  const msgs = subjects.messages$.value;
887
894
  const last = msgs[msgs.length - 1];
@@ -950,7 +957,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
950
957
  // drop the partial AI (or even the optimistic human) and
951
958
  // tear down their DOM mid-stream. Merge by id keeps both,
952
959
  // updates content where ids match, preserves the rest.
953
- subjects.messages$.next(mergeMessages(subjects.messages$.value, remapped, reasoningTimingMap));
960
+ subjects.messages$.next(mergeMessages(subjects.messages$.value, remapped, reasoningTimingMap, 'snapshot', canonicalMessageIds));
954
961
  if (isLgTraceEnabled()) {
955
962
  lgTrace('bridge.values-sync', {
956
963
  incomingLength: stateMessages.length,
@@ -1146,7 +1153,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
1146
1153
  abortController = new AbortController();
1147
1154
  const startedAt = Date.now();
1148
1155
  captureRuntimeRequestTelemetry('join');
1149
- captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_started', telemetryProperties);
1156
+ captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_started', telemetryProperties);
1150
1157
  subjects.custom$.next([]);
1151
1158
  subjects.toolProgress$.next([]);
1152
1159
  toolProgressMap.clear();
@@ -1160,7 +1167,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
1160
1167
  }
1161
1168
  subjects.status$.next(ResourceStatus.Resolved);
1162
1169
  await refreshHistory();
1163
- captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_ended', {
1170
+ captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_ended', {
1164
1171
  ...telemetryProperties,
1165
1172
  durationMs: Date.now() - startedAt,
1166
1173
  });
@@ -1168,7 +1175,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
1168
1175
  catch (err) {
1169
1176
  subjects.error$.next(toAgentError(err));
1170
1177
  subjects.status$.next(ResourceStatus.Error);
1171
- captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_errored', {
1178
+ captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_errored', {
1172
1179
  ...telemetryProperties,
1173
1180
  durationMs: Date.now() - startedAt,
1174
1181
  errorClass: agentRuntimeTelemetryErrorClass(err),
@@ -1360,7 +1367,7 @@ function collapseAdjacentAi(messages) {
1360
1367
  }
1361
1368
  return out;
1362
1369
  }
1363
- function mergeMessages(existing, incoming, reasoningTimingMap) {
1370
+ function mergeMessages(existing, incoming, reasoningTimingMap, mode = 'snapshot', canonicalMessageIds) {
1364
1371
  const merged = [...existing];
1365
1372
  for (const msg of incoming) {
1366
1373
  const rawIn = msg;
@@ -1400,6 +1407,13 @@ function mergeMessages(existing, incoming, reasoningTimingMap) {
1400
1407
  const existing = merged[idx];
1401
1408
  const existingId = existing['id'];
1402
1409
  const incomingRaw = msg;
1410
+ const targetId = (existingId ?? incomingRaw['id']);
1411
+ // Identity backstop: once a message's content is known-final, late
1412
+ // streamed deltas for it are stale stragglers — ignore them outright.
1413
+ if (mode === 'delta' && targetId && canonicalMessageIds?.has(targetId)
1414
+ && !isFinalCanonicalReasoningContent(incomingRaw['content'])) {
1415
+ continue;
1416
+ }
1403
1417
  // Keep the *existing* id so downstream track-by-id sees stable identity.
1404
1418
  // For complex-content streaming (OpenAI gpt-5/o-series, Anthropic) the
1405
1419
  // SDK emits per-chunk *delta* arrays — not accumulated arrays — so a
@@ -1407,7 +1421,10 @@ function mergeMessages(existing, incoming, reasoningTimingMap) {
1407
1421
  // latest token. Accumulate text-bearing content across chunks here
1408
1422
  // and hand a string to consumers; downstream code already handles
1409
1423
  // string content uniformly.
1410
- const accumulatedContent = accumulateContent(existing.content, incomingRaw['content']);
1424
+ const accumulatedContent = accumulateContent(existing.content, incomingRaw['content'], mode);
1425
+ if (targetId && isFinalCanonicalReasoningContent(incomingRaw['content'])) {
1426
+ canonicalMessageIds?.add(targetId);
1427
+ }
1411
1428
  // Only accumulate reasoning when the incoming message explicitly carries
1412
1429
  // a `reasoning` field or complex-content array blocks with
1413
1430
  // type='reasoning'/'thinking'. Never use a plain string content value
@@ -1456,12 +1473,22 @@ function mergeMessages(existing, incoming, reasoningTimingMap) {
1456
1473
  }
1457
1474
  /**
1458
1475
  * Merge an incoming chunk's content into prior accumulated content for the
1459
- * same message id.
1476
+ * same message id. Behavior is governed by `mode`, which reflects the
1477
+ * DECLARED kind of the source event rather than a guess from comparing text:
1478
+ *
1479
+ * - mode 'delta' (messages-tuple / `event.messageMetadata` truthy): the
1480
+ * payload is a genuine per-chunk delta. Append unconditionally — a
1481
+ * prefix-comparison "dedupe" here would silently drop legitimate tokens
1482
+ * that coincide with the message-so-far (e.g. every bare "|" while
1483
+ * streaming a markdown table). Staleness after the message goes canonical
1484
+ * is instead handled by identity in `mergeMessages` (canonicalMessageIds).
1485
+ * - mode 'snapshot' (messages/partial, values-sync): the payload carries the
1486
+ * message-so-far, not a delta, so mutual prefix comparison picks the
1487
+ * longer state and ignores stale shorter snapshots.
1460
1488
  *
1461
- * - string + string concat (delta append)
1462
- * - array + array → concat extracted text from existing + incoming blocks
1463
- * - array + string use the string (server final-id swap)
1464
- * - empty existing → use incoming as-is
1489
+ * In both modes, a "final canonical" reasoning+text array (see
1490
+ * `isFinalCanonicalReasoningContent`) always replaces whatever was
1491
+ * accumulated it's the authoritative final message, not another chunk.
1465
1492
  *
1466
1493
  * We deliberately collapse complex content arrays to a string at this layer.
1467
1494
  * The langgraph-sdk client does not accumulate complex-content arrays the
@@ -1493,7 +1520,7 @@ function isFinalCanonicalReasoningContent(content) {
1493
1520
  }
1494
1521
  return hasReasoning && hasText;
1495
1522
  }
1496
- function accumulateContent(existing, incoming) {
1523
+ function accumulateContent(existing, incoming, mode = 'snapshot') {
1497
1524
  const existingText = extractText(existing);
1498
1525
  const incomingText = extractText(incoming);
1499
1526
  // Always return a string. We never want array content escaping the bridge:
@@ -1504,23 +1531,24 @@ function accumulateContent(existing, incoming) {
1504
1531
  return incomingText;
1505
1532
  if (incomingText.length === 0)
1506
1533
  return existingText;
1507
- // Incoming is a strict-superset of accumulated (final-id swap with full content).
1534
+ // Final-canonical detection applies in both modes: the authoritative
1535
+ // "reasoning + text" array replaces whatever was accumulated.
1536
+ if (isFinalCanonicalReasoningContent(incoming))
1537
+ return incomingText;
1538
+ if (mode === 'delta') {
1539
+ // Tuple chunks are declared deltas. Append unconditionally — any
1540
+ // text-comparison "dedupe" here can silently drop legitimate tokens
1541
+ // that coincide with the message prefix (e.g. every bare "|" in a
1542
+ // markdown table). Staleness is handled by identity in mergeMessages.
1543
+ return existingText + incomingText;
1544
+ }
1545
+ // Snapshot mode (messages/partial, values-sync): payloads carry the
1546
+ // message-so-far, so mutual prefix comparison picks the longer state and
1547
+ // ignores stale shorter snapshots.
1508
1548
  if (incomingText.startsWith(existingText))
1509
1549
  return incomingText;
1510
- // Existing already a strict-superset — chunk arrived after the canonical
1511
- // message merged in via values-sync. Keep what we have.
1512
1550
  if (existingText.startsWith(incomingText))
1513
1551
  return existingText;
1514
- // Final-canonical detection: when incoming is the "reasoning + text"
1515
- // array shape that ships the authoritative final message after a
1516
- // streaming run, replace the partial streamed accumulator with the
1517
- // canonical text instead of appending. Without this branch a small
1518
- // formatting difference between the streamed accumulator and the
1519
- // canonical text breaks the prefix checks above and visible content
1520
- // is duplicated (`existingText + incomingText`).
1521
- if (isFinalCanonicalReasoningContent(incoming))
1522
- return incomingText;
1523
- // Otherwise treat incoming as a delta and append.
1524
1552
  return existingText + incomingText;
1525
1553
  }
1526
1554
  function extractText(content) {