@threadplane/langgraph 0.0.54 → 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
  ],
@@ -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({
@@ -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) {
@@ -782,6 +787,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
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.
@@ -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,
@@ -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) {