@agent-native/core 0.92.2 → 0.92.4

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.
Files changed (172) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +32 -0
  3. package/corpus/core/docs/content/locales/ar-SA/server.mdx +35 -0
  4. package/corpus/core/docs/content/locales/de-DE/server.mdx +35 -0
  5. package/corpus/core/docs/content/locales/es-ES/server.mdx +35 -0
  6. package/corpus/core/docs/content/locales/fr-FR/server.mdx +35 -0
  7. package/corpus/core/docs/content/locales/hi-IN/server.mdx +35 -0
  8. package/corpus/core/docs/content/locales/ja-JP/server.mdx +35 -0
  9. package/corpus/core/docs/content/locales/ko-KR/server.mdx +35 -0
  10. package/corpus/core/docs/content/locales/pt-BR/server.mdx +35 -0
  11. package/corpus/core/docs/content/locales/zh-CN/server.mdx +35 -0
  12. package/corpus/core/docs/content/locales/zh-TW/server.mdx +35 -0
  13. package/corpus/core/docs/content/server.mdx +35 -0
  14. package/corpus/core/package.json +2 -2
  15. package/corpus/core/src/agent/engine/ai-sdk-engine.ts +14 -4
  16. package/corpus/core/src/agent/engine/anthropic-engine.ts +15 -3
  17. package/corpus/core/src/agent/engine/builder-engine.ts +30 -4
  18. package/corpus/core/src/agent/engine/registry.ts +63 -2
  19. package/corpus/core/src/agent/model-config.ts +0 -2
  20. package/corpus/core/src/agent/production-agent.ts +12 -1
  21. package/corpus/core/src/agent/run-manager.ts +57 -36
  22. package/corpus/core/src/agent/run-store.ts +81 -2
  23. package/corpus/core/src/agent/thread-data-builder.ts +33 -2
  24. package/corpus/core/src/client/AssistantChat.tsx +314 -96
  25. package/corpus/core/src/client/agent-chat-adapter.ts +17 -1
  26. package/corpus/core/src/client/chat/repo-helpers.ts +45 -0
  27. package/corpus/core/src/client/chat/tool-call-display.tsx +36 -27
  28. package/corpus/core/src/client/chat-model-groups.ts +1 -5
  29. package/corpus/core/src/client/composer/TiptapComposer.tsx +0 -2
  30. package/corpus/core/src/client/embed-auth.ts +20 -0
  31. package/corpus/core/src/client/settings/SettingsSection.tsx +66 -5
  32. package/corpus/core/src/client/sse-event-processor.ts +49 -17
  33. package/corpus/core/src/client/use-db-sync.ts +31 -9
  34. package/corpus/core/src/collab/agent-presence.ts +6 -1
  35. package/corpus/core/src/collab/awareness-store.ts +185 -0
  36. package/corpus/core/src/collab/awareness.ts +58 -2
  37. package/corpus/core/src/scripts/agent-engines/manage-agent-engine.ts +7 -8
  38. package/corpus/core/src/scripts/agent-engines/set-agent-engine.ts +7 -18
  39. package/corpus/core/src/server/core-routes-plugin.ts +21 -2
  40. package/corpus/core/src/server/embed-session.ts +20 -4
  41. package/corpus/core/src/styles/agent-native.css +51 -0
  42. package/corpus/core/src/tracking/providers.ts +33 -2
  43. package/corpus/templates/analytics/app/pages/adhoc/explorer-dashboard/index.tsx +8 -1
  44. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +9 -1
  45. package/corpus/templates/analytics/app/pages/monitoring/uptime/MonitorDetail.tsx +41 -11
  46. package/corpus/templates/analytics/app/pages/monitoring/uptime/types.ts +44 -0
  47. package/corpus/templates/analytics/changelog/2026-07-09-dashboard-action-menus-now-close-cleanly-before-opening-hist.md +6 -0
  48. package/corpus/templates/analytics/changelog/2026-07-09-dashboard-email-reports-still-send-when-the-screenshot-captu.md +6 -0
  49. package/corpus/templates/analytics/changelog/2026-07-09-uptime-alerts-now-suppress-recovery-noise-during-flapping-an.md +6 -0
  50. package/corpus/templates/analytics/changelog/2026-07-09-uptime-check-history-now-shows-timing-diagnostics-so-slow-or.md +6 -0
  51. package/corpus/templates/analytics/changelog/2026-07-09-uptime-recovery-alerts-now-send-when-the-original-outage-not.md +6 -0
  52. package/corpus/templates/analytics/docs/uptime-monitoring.md +29 -16
  53. package/corpus/templates/analytics/netlify.toml +3 -0
  54. package/corpus/templates/analytics/scripts/emit-netlify-dashboard-report-cron.ts +126 -0
  55. package/corpus/templates/analytics/server/db/schema-monitoring.ts +5 -0
  56. package/corpus/templates/analytics/server/jobs/uptime-monitors.ts +5 -1
  57. package/corpus/templates/analytics/server/lib/uptime-monitors.ts +305 -31
  58. package/corpus/templates/analytics/server/plugins/db.ts +15 -0
  59. package/corpus/templates/analytics/server/plugins/uptime-monitor-jobs.ts +1 -1
  60. package/corpus/templates/analytics/server/routes/api/uptime-monitors/run.post.ts +57 -0
  61. package/corpus/templates/clips/actions/list-meetings.ts +12 -0
  62. package/corpus/templates/clips/changelog/2026-07-09-meeting-reminders-now-ignore-obvious-solo-personal-calendar-.md +6 -0
  63. package/corpus/templates/clips/desktop/src-tauri/src/meetings_watcher.rs +1 -0
  64. package/corpus/templates/clips/server/lib/calendar-event-classification.ts +79 -0
  65. package/dist/agent/engine/ai-sdk-engine.d.ts.map +1 -1
  66. package/dist/agent/engine/ai-sdk-engine.js +13 -4
  67. package/dist/agent/engine/ai-sdk-engine.js.map +1 -1
  68. package/dist/agent/engine/anthropic-engine.d.ts.map +1 -1
  69. package/dist/agent/engine/anthropic-engine.js +14 -3
  70. package/dist/agent/engine/anthropic-engine.js.map +1 -1
  71. package/dist/agent/engine/builder-engine.d.ts +1 -1
  72. package/dist/agent/engine/builder-engine.d.ts.map +1 -1
  73. package/dist/agent/engine/builder-engine.js +26 -4
  74. package/dist/agent/engine/builder-engine.js.map +1 -1
  75. package/dist/agent/engine/registry.d.ts.map +1 -1
  76. package/dist/agent/engine/registry.js +47 -2
  77. package/dist/agent/engine/registry.js.map +1 -1
  78. package/dist/agent/model-config.d.ts +2 -2
  79. package/dist/agent/model-config.d.ts.map +1 -1
  80. package/dist/agent/model-config.js +0 -2
  81. package/dist/agent/model-config.js.map +1 -1
  82. package/dist/agent/production-agent.d.ts.map +1 -1
  83. package/dist/agent/production-agent.js +12 -1
  84. package/dist/agent/production-agent.js.map +1 -1
  85. package/dist/agent/run-manager.d.ts.map +1 -1
  86. package/dist/agent/run-manager.js +52 -33
  87. package/dist/agent/run-manager.js.map +1 -1
  88. package/dist/agent/run-store.d.ts +24 -0
  89. package/dist/agent/run-store.d.ts.map +1 -1
  90. package/dist/agent/run-store.js +64 -2
  91. package/dist/agent/run-store.js.map +1 -1
  92. package/dist/agent/thread-data-builder.d.ts.map +1 -1
  93. package/dist/agent/thread-data-builder.js +28 -2
  94. package/dist/agent/thread-data-builder.js.map +1 -1
  95. package/dist/client/AssistantChat.d.ts +6 -0
  96. package/dist/client/AssistantChat.d.ts.map +1 -1
  97. package/dist/client/AssistantChat.js +243 -36
  98. package/dist/client/AssistantChat.js.map +1 -1
  99. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  100. package/dist/client/agent-chat-adapter.js +15 -1
  101. package/dist/client/agent-chat-adapter.js.map +1 -1
  102. package/dist/client/chat/repo-helpers.d.ts.map +1 -1
  103. package/dist/client/chat/repo-helpers.js +43 -0
  104. package/dist/client/chat/repo-helpers.js.map +1 -1
  105. package/dist/client/chat/tool-call-display.d.ts.map +1 -1
  106. package/dist/client/chat/tool-call-display.js +4 -4
  107. package/dist/client/chat/tool-call-display.js.map +1 -1
  108. package/dist/client/chat-model-groups.d.ts.map +1 -1
  109. package/dist/client/chat-model-groups.js +1 -3
  110. package/dist/client/chat-model-groups.js.map +1 -1
  111. package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
  112. package/dist/client/composer/TiptapComposer.js +0 -2
  113. package/dist/client/composer/TiptapComposer.js.map +1 -1
  114. package/dist/client/embed-auth.d.ts.map +1 -1
  115. package/dist/client/embed-auth.js +14 -0
  116. package/dist/client/embed-auth.js.map +1 -1
  117. package/dist/client/settings/SettingsSection.d.ts.map +1 -1
  118. package/dist/client/settings/SettingsSection.js +32 -3
  119. package/dist/client/settings/SettingsSection.js.map +1 -1
  120. package/dist/client/sse-event-processor.d.ts.map +1 -1
  121. package/dist/client/sse-event-processor.js +44 -17
  122. package/dist/client/sse-event-processor.js.map +1 -1
  123. package/dist/client/use-db-sync.d.ts.map +1 -1
  124. package/dist/client/use-db-sync.js +29 -9
  125. package/dist/client/use-db-sync.js.map +1 -1
  126. package/dist/collab/agent-presence.d.ts.map +1 -1
  127. package/dist/collab/agent-presence.js +5 -2
  128. package/dist/collab/agent-presence.js.map +1 -1
  129. package/dist/collab/awareness-store.d.ts +1 -1
  130. package/dist/collab/awareness-store.d.ts.map +1 -1
  131. package/dist/collab/awareness-store.js +0 -0
  132. package/dist/collab/awareness-store.js.map +1 -1
  133. package/dist/collab/awareness.d.ts +4 -2
  134. package/dist/collab/awareness.d.ts.map +1 -1
  135. package/dist/collab/awareness.js +46 -2
  136. package/dist/collab/awareness.js.map +1 -1
  137. package/dist/collab/routes.d.ts +1 -1
  138. package/dist/collab/struct-routes.d.ts +1 -1
  139. package/dist/notifications/routes.d.ts +2 -2
  140. package/dist/observability/routes.d.ts +3 -3
  141. package/dist/progress/routes.d.ts +1 -1
  142. package/dist/resources/handlers.d.ts +1 -1
  143. package/dist/scripts/agent-engines/manage-agent-engine.d.ts.map +1 -1
  144. package/dist/scripts/agent-engines/manage-agent-engine.js +6 -6
  145. package/dist/scripts/agent-engines/manage-agent-engine.js.map +1 -1
  146. package/dist/scripts/agent-engines/set-agent-engine.d.ts.map +1 -1
  147. package/dist/scripts/agent-engines/set-agent-engine.js +5 -13
  148. package/dist/scripts/agent-engines/set-agent-engine.js.map +1 -1
  149. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  150. package/dist/server/core-routes-plugin.d.ts +5 -0
  151. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  152. package/dist/server/core-routes-plugin.js +10 -3
  153. package/dist/server/core-routes-plugin.js.map +1 -1
  154. package/dist/server/embed-session.d.ts.map +1 -1
  155. package/dist/server/embed-session.js +17 -4
  156. package/dist/server/embed-session.js.map +1 -1
  157. package/dist/styles/agent-native.css +51 -0
  158. package/dist/tracking/providers.d.ts.map +1 -1
  159. package/dist/tracking/providers.js +23 -5
  160. package/dist/tracking/providers.js.map +1 -1
  161. package/docs/content/locales/ar-SA/server.mdx +35 -0
  162. package/docs/content/locales/de-DE/server.mdx +35 -0
  163. package/docs/content/locales/es-ES/server.mdx +35 -0
  164. package/docs/content/locales/fr-FR/server.mdx +35 -0
  165. package/docs/content/locales/hi-IN/server.mdx +35 -0
  166. package/docs/content/locales/ja-JP/server.mdx +35 -0
  167. package/docs/content/locales/ko-KR/server.mdx +35 -0
  168. package/docs/content/locales/pt-BR/server.mdx +35 -0
  169. package/docs/content/locales/zh-CN/server.mdx +35 -0
  170. package/docs/content/locales/zh-TW/server.mdx +35 -0
  171. package/docs/content/server.mdx +35 -0
  172. package/package.json +2 -2
@@ -366,6 +366,44 @@ function cloneContentParts(content: ContentPart[]): ContentPart[] {
366
366
  );
367
367
  }
368
368
 
369
+ export function settleInterruptedAssistantToolCallsInRepo<
370
+ T extends { messages?: unknown[] },
371
+ >(repo: T): { repo: T; changed: boolean } {
372
+ if (!Array.isArray(repo.messages)) return { repo, changed: false };
373
+ let changed = false;
374
+ const nextMessages = repo.messages.map((entry) => {
375
+ const wrapper =
376
+ entry && typeof entry === "object" && "message" in entry
377
+ ? (entry as { message?: unknown })
378
+ : null;
379
+ const message = (wrapper?.message ?? entry) as
380
+ | { role?: unknown; content?: unknown; status?: unknown }
381
+ | null
382
+ | undefined;
383
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) {
384
+ return entry;
385
+ }
386
+ const content = cloneContentParts(message.content as ContentPart[]);
387
+ if (
388
+ !settleInterruptedToolCalls(content, undefined, { includeActivity: true })
389
+ ) {
390
+ return entry;
391
+ }
392
+ changed = true;
393
+ const nextMessage = {
394
+ ...message,
395
+ content,
396
+ status: { type: "incomplete", reason: "error" },
397
+ };
398
+ return wrapper
399
+ ? { ...(entry as Record<string, unknown>), message: nextMessage }
400
+ : nextMessage;
401
+ });
402
+ return changed
403
+ ? { repo: { ...repo, messages: nextMessages }, changed }
404
+ : { repo, changed };
405
+ }
406
+
369
407
  function clearPendingSelection() {
370
408
  fetch(
371
409
  agentNativePath(
@@ -586,6 +624,28 @@ function toolCallPartHasResult(part: unknown): boolean {
586
624
  return candidate.type === "tool-call" && "result" in candidate;
587
625
  }
588
626
 
627
+ /**
628
+ * Monotonic progress rank for a tool-call part. Higher means the UI should
629
+ * prefer this copy over a lower-ranked duplicate of the same logical call.
630
+ * Used so reconnect overlays that are ahead of lagging thread messages stay
631
+ * visible instead of flickering back to an older pending spinner.
632
+ */
633
+ function toolCallProgressRank(part: unknown): number {
634
+ if (!part || typeof part !== "object") return 0;
635
+ const candidate = part as {
636
+ type?: unknown;
637
+ result?: unknown;
638
+ activity?: unknown;
639
+ argsText?: unknown;
640
+ };
641
+ if (candidate.type !== "tool-call") return 0;
642
+ if ("result" in candidate) return 4;
643
+ if (candidate.activity === true) return 1;
644
+ return typeof candidate.argsText === "string" && candidate.argsText.length > 0
645
+ ? 2
646
+ : 1;
647
+ }
648
+
589
649
  /**
590
650
  * Identity fingerprint for a tool-call part that survives across readers: two
591
651
  * readers of the same run assign unrelated synthetic toolCallIds until both
@@ -615,10 +675,10 @@ function toolCallFingerprintFromContentPart(part: unknown): string | null {
615
675
  }
616
676
 
617
677
  function collectRenderedToolCallStates(messages: readonly unknown[]): {
618
- byId: Map<string, { hasResult: boolean }>;
619
- latestAssistantByFingerprint: Map<string, { hasResult: boolean }>;
678
+ byId: Map<string, { rank: number }>;
679
+ latestAssistantByFingerprint: Map<string, { rank: number }>;
620
680
  } {
621
- const byId = new Map<string, { hasResult: boolean }>();
681
+ const byId = new Map<string, { rank: number }>();
622
682
  for (const message of messages) {
623
683
  const msg = (message as { message?: unknown })?.message ?? message;
624
684
  const content = (msg as { content?: unknown })?.content;
@@ -626,18 +686,15 @@ function collectRenderedToolCallStates(messages: readonly unknown[]): {
626
686
  for (const part of content) {
627
687
  const id = toolCallIdFromContentPart(part);
628
688
  if (!id) continue;
629
- const hasResult = toolCallPartHasResult(part);
689
+ const rank = toolCallProgressRank(part);
630
690
  const existing = byId.get(id);
631
691
  byId.set(id, {
632
- hasResult: Boolean(existing?.hasResult || hasResult),
692
+ rank: Math.max(existing?.rank ?? 0, rank),
633
693
  });
634
694
  }
635
695
  }
636
696
 
637
- const latestAssistantByFingerprint = new Map<
638
- string,
639
- { hasResult: boolean }
640
- >();
697
+ const latestAssistantByFingerprint = new Map<string, { rank: number }>();
641
698
  const latestEntry = messages.at(-1);
642
699
  const latestMessage = getRepoMessage(latestEntry as any);
643
700
  const latestContent = latestMessage?.content;
@@ -645,10 +702,10 @@ function collectRenderedToolCallStates(messages: readonly unknown[]): {
645
702
  for (const part of latestContent) {
646
703
  const fingerprint = toolCallFingerprintFromContentPart(part);
647
704
  if (!fingerprint) continue;
648
- const hasResult = toolCallPartHasResult(part);
705
+ const rank = toolCallProgressRank(part);
649
706
  const existing = latestAssistantByFingerprint.get(fingerprint);
650
707
  latestAssistantByFingerprint.set(fingerprint, {
651
- hasResult: Boolean(existing?.hasResult || hasResult),
708
+ rank: Math.max(existing?.rank ?? 0, rank),
652
709
  });
653
710
  }
654
711
  }
@@ -760,25 +817,35 @@ export function dedupeReconnectContentAgainstMessages(
760
817
  const filtered =
761
818
  byId.size > 0 || latestAssistantByFingerprint.size > 0
762
819
  ? snapshotDeduped.filter((part) => {
820
+ const reconnectRank = toolCallProgressRank(part);
763
821
  const id = toolCallIdFromContentPart(part);
764
822
  const existing = id ? byId.get(id) : undefined;
765
823
  if (existing) {
766
- changed = true;
767
- return false;
824
+ // Keep reconnect copies that are strictly ahead of the rendered
825
+ // message (e.g. completed overlay vs lagging pending thread data).
826
+ // Same-or-behind ranks are duplicates and should stay hidden.
827
+ if (reconnectRank <= existing.rank) {
828
+ changed = true;
829
+ return false;
830
+ }
831
+ return true;
768
832
  }
769
833
  // Fingerprint fallback for the id-convergence window: two readers of
770
834
  // the same active run can assign unrelated ids to the same logical tool
771
835
  // call before the server id converges. Suppress the reconnect overlay
772
- // whenever it matches the latest rendered assistant tool call and either
773
- // side is still pending; completed-vs-completed remains visible so a
774
- // legitimately repeated identical call is not hidden.
836
+ // only when the rendered message is at least as far along; completed
837
+ // reconnect copies stay visible over pending message copies so the UI
838
+ // does not pop back to an older spinner.
775
839
  const fingerprint = toolCallFingerprintFromContentPart(part);
776
840
  const latestByFingerprint = fingerprint
777
841
  ? latestAssistantByFingerprint.get(fingerprint)
778
842
  : undefined;
843
+ const isCompletedRepeat =
844
+ reconnectRank >= 4 && latestByFingerprint?.rank === 4;
779
845
  if (
780
846
  latestByFingerprint &&
781
- (!toolCallPartHasResult(part) || !latestByFingerprint.hasResult)
847
+ !isCompletedRepeat &&
848
+ reconnectRank <= latestByFingerprint.rank
782
849
  ) {
783
850
  changed = true;
784
851
  return false;
@@ -1557,6 +1624,7 @@ const AssistantChatInner = forwardRef<
1557
1624
  const queueDirtyRef = useRef(false);
1558
1625
  const queueMutationVersionRef = useRef(0);
1559
1626
  const dequeueInFlightRef = useRef(false);
1627
+ const queueStopVersionRef = useRef(0);
1560
1628
  const [composerContextItems, setComposerContextItems] = useState<
1561
1629
  AgentChatContextItem[]
1562
1630
  >([]);
@@ -1728,6 +1796,9 @@ const AssistantChatInner = forwardRef<
1728
1796
  const [reconnectContent, setReconnectContent] = useState<ContentPart[]>([]);
1729
1797
  // When stop is clicked during reconnect, keep content visible (don't wipe it)
1730
1798
  const [reconnectFrozen, setReconnectFrozen] = useState(false);
1799
+ // Adapter took over while reconnect still had visible tool cards — keep the
1800
+ // overlay until the adapter message catches up so we don't flash an empty gap.
1801
+ const [adapterHandoffPending, setAdapterHandoffPending] = useState(false);
1731
1802
  const reconnectRunIdRef = useRef<string | null>(null);
1732
1803
  const reconnectTailOnlyRef = useRef(false);
1733
1804
  const reconnectCanMaterializeRef = useRef(false);
@@ -1784,16 +1855,23 @@ const AssistantChatInner = forwardRef<
1784
1855
  });
1785
1856
  const reconnectActivityContent = useMemo(
1786
1857
  () =>
1787
- isReconnecting || reconnectFrozen
1858
+ isReconnecting || reconnectFrozen || adapterHandoffPending
1788
1859
  ? reconnectActivityFallbackContent(runningActivityTool)
1789
1860
  : [],
1790
- [isReconnecting, reconnectFrozen, runningActivityTool],
1861
+ [
1862
+ adapterHandoffPending,
1863
+ isReconnecting,
1864
+ reconnectFrozen,
1865
+ runningActivityTool,
1866
+ ],
1791
1867
  );
1792
1868
  const lastBroadcastRunningRef = useRef(isRunning);
1793
1869
  const tiptapRef = useRef<TiptapComposerHandle>(null);
1794
1870
  // Stable ref to the "stop active run" action so addToQueue can abort
1795
1871
  // a running turn without adding many unstable closure deps to its dep list.
1796
- const stopActiveRunRef = useRef<() => void>(() => {});
1872
+ const stopActiveRunRef = useRef<
1873
+ (options?: { preserveQueuedMessages?: boolean }) => void
1874
+ >(() => {});
1797
1875
 
1798
1876
  const markOptimisticRunning = useCallback(() => {
1799
1877
  setOptimisticRunning(true);
@@ -1888,15 +1966,21 @@ const AssistantChatInner = forwardRef<
1888
1966
  let settledRepo = repo;
1889
1967
  if (repo?.messages?.length > 0) {
1890
1968
  let shouldImport = true;
1891
- try {
1892
- shouldImport = shouldImportServerThreadData(
1893
- normalizeThreadRepository(threadRuntime.export()),
1894
- repo,
1895
- );
1896
- } catch {
1897
- // If the runtime/export comparison itself fails, do not fail open by
1898
- // importing a server snapshot that may be stale relative to local UI.
1969
+ // Adapter owns the live turn (including auto-continuation gaps). Do not
1970
+ // let a lagging server thread snapshot clobber in-flight tool cards.
1971
+ if (isRuntimeRunningRef.current || isAutoResumingRef.current) {
1899
1972
  shouldImport = false;
1973
+ } else {
1974
+ try {
1975
+ shouldImport = shouldImportServerThreadData(
1976
+ normalizeThreadRepository(threadRuntime.export()),
1977
+ repo,
1978
+ );
1979
+ } catch {
1980
+ // If the runtime/export comparison itself fails, do not fail open by
1981
+ // importing a server snapshot that may be stale relative to local UI.
1982
+ shouldImport = false;
1983
+ }
1900
1984
  }
1901
1985
  if (shouldImport) {
1902
1986
  if (options?.markTitleGenerated) {
@@ -2058,6 +2142,25 @@ const AssistantChatInner = forwardRef<
2058
2142
  return false;
2059
2143
  }
2060
2144
 
2145
+ // SUPERSEDE THE PREVIOUS RECONNECT GENERATION. A turn that keeps failing
2146
+ // (e.g. repeated stale_run at "Contacting model") produces a new runId
2147
+ // every few seconds; each call here used to OVERWRITE the single-slot
2148
+ // refs below without tearing down the prior closure, leaving its
2149
+ // watchdog (1s) + idleCheck (1s) + thread poll (2s) + SSE retry loop all
2150
+ // running. 4-5 stacked generations = a ~20 req/s request storm that
2151
+ // hammers the same Neon pool the server needs for heartbeats, deepening
2152
+ // the DB saturation that causes the failures in the first place. Abort
2153
+ // the prior generation's AbortController so its stream loop exits and its
2154
+ // `finally` clears every interval before we start a fresh one.
2155
+ if (reconnectAbortRef.current) {
2156
+ try {
2157
+ reconnectAbortRef.current.abort();
2158
+ } catch {
2159
+ // Already aborted / detached — nothing to unwind.
2160
+ }
2161
+ reconnectAbortRef.current = null;
2162
+ }
2163
+
2061
2164
  reconnectRunIdRef.current = runId;
2062
2165
  const afterSeq = resolveReconnectAfterSeq(threadId, runId);
2063
2166
  reconnectTailOnlyRef.current = afterSeq > 0;
@@ -2072,6 +2175,7 @@ const AssistantChatInner = forwardRef<
2072
2175
  });
2073
2176
  setIsReconnecting(true);
2074
2177
  setReconnectFrozen(false);
2178
+ setAdapterHandoffPending(false);
2075
2179
  setReconnectContent([]);
2076
2180
  window.dispatchEvent(
2077
2181
  new CustomEvent("agentNative.chatRunning", {
@@ -2152,6 +2256,19 @@ const AssistantChatInner = forwardRef<
2152
2256
  let noProgressDuringReconnect = false;
2153
2257
  let latestContent: ContentPart[] = [];
2154
2258
  const preparingActionState: PreparingActionState = {};
2259
+ // Exponential backoff for the reattach retry loop. A run that is
2260
+ // "active" server-side but producing no stream (the exact stuck state
2261
+ // that triggers a reconnect) would otherwise re-fetch /runs/:id/events
2262
+ // every ~250ms — several req/s per generation. Back off 250ms → 5s so a
2263
+ // genuinely stuck run stops hammering the server; reset the moment real
2264
+ // progress streams in (see the onSeq callback) so a recovering run
2265
+ // re-tightens immediately.
2266
+ let reconnectRetryCount = 0;
2267
+ const backoffRetryDelay = () => {
2268
+ const ms = Math.min(250 * 2 ** reconnectRetryCount, 5000);
2269
+ reconnectRetryCount += 1;
2270
+ return new Promise((resolve) => window.setTimeout(resolve, ms));
2271
+ };
2155
2272
  const sameRunStillActive = async (): Promise<
2156
2273
  "active" | "inactive" | "unknown"
2157
2274
  > => {
@@ -2176,6 +2293,11 @@ const AssistantChatInner = forwardRef<
2176
2293
  afterSeq > 0
2177
2294
  ? window.setInterval(() => {
2178
2295
  if (reconnectRunIdRef.current !== runId) return;
2296
+ // Adapter took over mid-poll — skip imports that would race the
2297
+ // live stream and flicker tool cards back to older snapshots.
2298
+ if (isRuntimeRunningRef.current || isAutoResumingRef.current) {
2299
+ return;
2300
+ }
2179
2301
  void refreshThreadFromServer();
2180
2302
  }, 2000)
2181
2303
  : undefined;
@@ -2197,7 +2319,7 @@ const AssistantChatInner = forwardRef<
2197
2319
  if (!sseRes.ok || !sseRes.body) {
2198
2320
  const activeState = await sameRunStillActive();
2199
2321
  if (activeState !== "inactive") {
2200
- await new Promise((resolve) => window.setTimeout(resolve, 250));
2322
+ await backoffRetryDelay();
2201
2323
  continue;
2202
2324
  }
2203
2325
  break;
@@ -2223,6 +2345,7 @@ const AssistantChatInner = forwardRef<
2223
2345
  scheduleUpdate,
2224
2346
  (seq) => {
2225
2347
  markReconnectProgress();
2348
+ reconnectRetryCount = 0;
2226
2349
  updateActiveRunSeq(seq);
2227
2350
  },
2228
2351
  { preparingActionState },
@@ -2241,9 +2364,7 @@ const AssistantChatInner = forwardRef<
2241
2364
  }
2242
2365
  const activeState = await sameRunStillActive();
2243
2366
  if (activeState !== "inactive") {
2244
- await new Promise((resolve) =>
2245
- window.setTimeout(resolve, 250),
2246
- );
2367
+ await backoffRetryDelay();
2247
2368
  continue;
2248
2369
  }
2249
2370
  }
@@ -2604,7 +2725,8 @@ const AssistantChatInner = forwardRef<
2604
2725
  !loadHistoryRepository ||
2605
2726
  !hasRestoredRef.current ||
2606
2727
  isRestoring ||
2607
- isRunning
2728
+ isRunning ||
2729
+ isAutoResuming
2608
2730
  ) {
2609
2731
  return;
2610
2732
  }
@@ -2621,6 +2743,7 @@ const AssistantChatInner = forwardRef<
2621
2743
  }, [
2622
2744
  historyReloadKey,
2623
2745
  importThreadData,
2746
+ isAutoResuming,
2624
2747
  isRestoring,
2625
2748
  isRunning,
2626
2749
  loadHistoryRepository,
@@ -3046,6 +3169,7 @@ const AssistantChatInner = forwardRef<
3046
3169
 
3047
3170
  const next = queuedMessages[0];
3048
3171
  if (!next) return;
3172
+ const stopVersion = queueStopVersionRef.current;
3049
3173
 
3050
3174
  dequeueInFlightRef.current = true;
3051
3175
  let cancelled = false;
@@ -3063,7 +3187,10 @@ const AssistantChatInner = forwardRef<
3063
3187
  await waitForThreadRunToClear(apiUrl, threadId);
3064
3188
  if (cancelled) return;
3065
3189
 
3066
- if (queuedMessagesRef.current[0]?.id !== next.id) {
3190
+ if (
3191
+ queueStopVersionRef.current !== stopVersion ||
3192
+ queuedMessagesRef.current[0]?.id !== next.id
3193
+ ) {
3067
3194
  return;
3068
3195
  }
3069
3196
 
@@ -3100,6 +3227,7 @@ const AssistantChatInner = forwardRef<
3100
3227
  } catch (err) {
3101
3228
  if (
3102
3229
  removedForAppend &&
3230
+ queueStopVersionRef.current === stopVersion &&
3103
3231
  !queuedMessagesRef.current.some((message) => message.id === next.id)
3104
3232
  ) {
3105
3233
  applyLocalQueuedMessages((prev) => [next, ...prev]);
@@ -3154,29 +3282,65 @@ const AssistantChatInner = forwardRef<
3154
3282
  prevIsRuntimeRunningRef.current = isRuntimeRunning;
3155
3283
  if (isRuntimeRunning && !wasRunning) {
3156
3284
  // SINGLE-READER OWNERSHIP: the adapter runtime just took over (a new run
3157
- // started or an adopted run resumed). Any live reconnect reader is now a
3158
- // duplicate accumulator abort it and DISCARD its content; the adapter's
3159
- // stream renders the canonical message. Null the run ref first so the
3160
- // reader's own cleanup paths (which all check reconnectRunIdRef) no-op.
3285
+ // started or an adopted run resumed). Abort the reconnect reader, but keep
3286
+ // its visible content until the adapter message has tool/text parts so the
3287
+ // UI does not flash an empty gap between readers.
3161
3288
  if (reconnectRunIdRef.current !== null) {
3289
+ const keepOverlay = reconnectContent.length > 0;
3162
3290
  reconnectRunIdRef.current = null;
3163
3291
  reconnectAbortRef.current?.abort();
3164
3292
  reconnectAbortRef.current = null;
3165
3293
  setIsReconnecting(false);
3166
- setReconnectContent([]);
3167
3294
  setReconnectFrozen(false);
3168
3295
  reconnectCanMaterializeRef.current = false;
3169
3296
  reconnectTailOnlyRef.current = false;
3297
+ if (keepOverlay) {
3298
+ setAdapterHandoffPending(true);
3299
+ } else {
3300
+ setReconnectContent([]);
3301
+ setAdapterHandoffPending(false);
3302
+ }
3170
3303
  } else if (reconnectFrozen) {
3171
3304
  setReconnectFrozen(false);
3172
3305
  setReconnectContent([]);
3306
+ setAdapterHandoffPending(false);
3173
3307
  reconnectCanMaterializeRef.current = false;
3174
3308
  }
3175
3309
  if (forceStopped) {
3176
3310
  setForceStopped(false);
3177
3311
  }
3178
3312
  }
3179
- }, [isRuntimeRunning, reconnectFrozen, forceStopped]);
3313
+ }, [
3314
+ isRuntimeRunning,
3315
+ reconnectFrozen,
3316
+ forceStopped,
3317
+ reconnectContent.length,
3318
+ ]);
3319
+
3320
+ // Release the deferred reconnect overlay once thread messages have caught
3321
+ // up enough that dedupe would hide the overlay, or after a short timeout so
3322
+ // a stuck handoff cannot leave duplicate tool cards forever.
3323
+ useEffect(() => {
3324
+ if (!adapterHandoffPending) return;
3325
+ if (!isRuntimeRunning) {
3326
+ setReconnectContent([]);
3327
+ setAdapterHandoffPending(false);
3328
+ return;
3329
+ }
3330
+ const stillNeeded =
3331
+ dedupeReconnectContentAgainstMessages(reconnectContent, messages).length >
3332
+ 0;
3333
+ if (!stillNeeded) {
3334
+ setReconnectContent([]);
3335
+ setAdapterHandoffPending(false);
3336
+ return;
3337
+ }
3338
+ const timer = window.setTimeout(() => {
3339
+ setReconnectContent([]);
3340
+ setAdapterHandoffPending(false);
3341
+ }, 2500);
3342
+ return () => window.clearTimeout(timer);
3343
+ }, [adapterHandoffPending, isRuntimeRunning, messages, reconnectContent]);
3180
3344
 
3181
3345
  // Same transition guard for isReconnecting: only clear forceStopped on
3182
3346
  // the false→true edge (a new reconnect starting on page load).
@@ -3194,6 +3358,7 @@ const AssistantChatInner = forwardRef<
3194
3358
  if (!reconnectCanMaterializeRef.current) {
3195
3359
  setReconnectFrozen(false);
3196
3360
  setReconnectContent([]);
3361
+ setAdapterHandoffPending(false);
3197
3362
  return;
3198
3363
  }
3199
3364
  try {
@@ -3236,6 +3401,7 @@ const AssistantChatInner = forwardRef<
3236
3401
  threadRuntime.import(ensureMessageMetadata(repo));
3237
3402
  setReconnectFrozen(false);
3238
3403
  setReconnectContent([]);
3404
+ setAdapterHandoffPending(false);
3239
3405
  reconnectCanMaterializeRef.current = false;
3240
3406
  } catch (err) {
3241
3407
  captureError(err, {
@@ -3259,61 +3425,108 @@ const AssistantChatInner = forwardRef<
3259
3425
  threadRuntime,
3260
3426
  ]);
3261
3427
 
3428
+ const settleVisibleInterruptedTools = useCallback(() => {
3429
+ try {
3430
+ const repo = normalizeThreadRepository(threadRuntime.export());
3431
+ const settled = settleInterruptedAssistantToolCallsInRepo(repo);
3432
+ if (settled.changed) {
3433
+ threadRuntime.import(ensureMessageMetadata(settled.repo));
3434
+ }
3435
+ } catch (err) {
3436
+ captureError(err, {
3437
+ tags: {
3438
+ source: "agent-chat-client",
3439
+ phase: "settle-stopped-tool-calls",
3440
+ },
3441
+ extra: {
3442
+ threadId: threadId ?? null,
3443
+ tabId: tabId ?? null,
3444
+ },
3445
+ });
3446
+ }
3447
+ }, [tabId, threadId, threadRuntime]);
3448
+
3262
3449
  // Abort the active server run (identical to what the Stop button does) so
3263
3450
  // an immediate-while-running send can proceed cleanly without a 409 race.
3264
3451
  // Captured in a stable ref so addToQueue can call it without listing
3265
3452
  // all the stop-related state in its own dep array.
3266
- const stopActiveRun = useCallback(() => {
3267
- setForceStopped(true);
3268
- setOptimisticRunning(false);
3269
- const activeRun = getActiveRun();
3270
- const runIdToAbort = reconnectRunIdRef.current ?? activeRun?.runId;
3271
- userStoppedRunRef.current = {
3272
- at: Date.now(),
3273
- ...(runIdToAbort ? { runId: runIdToAbort } : {}),
3274
- };
3275
- setRunErrorInfo(null);
3276
- setDismissedRunErrorKey(null);
3277
- if (runIdToAbort) {
3278
- if (threadId) clearActiveRunIfMatches(threadId, runIdToAbort);
3279
- fetch(`${apiUrl}/runs/${encodeURIComponent(runIdToAbort)}/abort`, {
3280
- method: "POST",
3281
- }).catch(() => {});
3282
- }
3283
- if (isReconnecting) {
3284
- reconnectAbortRef.current?.abort();
3285
- reconnectAbortRef.current = null;
3286
- reconnectRunIdRef.current = null;
3287
- setIsReconnecting(false);
3288
- const shouldFreezeReconnectContent =
3289
- !reconnectTailOnlyRef.current &&
3290
- reconnectCanMaterializeRef.current &&
3291
- reconnectContent.length > 0;
3292
- if (shouldFreezeReconnectContent) {
3293
- setReconnectFrozen(true);
3294
- } else {
3295
- setReconnectFrozen(false);
3296
- setReconnectContent([]);
3297
- reconnectCanMaterializeRef.current = false;
3453
+ const stopActiveRun = useCallback(
3454
+ (options?: { preserveQueuedMessages?: boolean }) => {
3455
+ setForceStopped(true);
3456
+ setOptimisticRunning(false);
3457
+ setHasActiveServerRun(false);
3458
+ setPendingReconnectRecovery(null);
3459
+ setIsAutoResuming(false);
3460
+ if (autoResumeTimerRef.current !== null) {
3461
+ window.clearTimeout(autoResumeTimerRef.current);
3462
+ autoResumeTimerRef.current = null;
3298
3463
  }
3299
- reconnectTailOnlyRef.current = false;
3300
- }
3301
- threadRuntime.cancelRun();
3302
- if (typeof window !== "undefined") {
3303
- window.dispatchEvent(
3304
- new CustomEvent("agentNative.chatRunning", {
3305
- detail: { isRunning: false, tabId: tabId || threadId },
3306
- }),
3307
- );
3308
- }
3309
- }, [
3310
- apiUrl,
3311
- isReconnecting,
3312
- reconnectContent.length,
3313
- tabId,
3314
- threadId,
3315
- threadRuntime,
3316
- ]);
3464
+ resetRunningActivity();
3465
+ if (!options?.preserveQueuedMessages) {
3466
+ queueStopVersionRef.current += 1;
3467
+ dequeueInFlightRef.current = false;
3468
+ applyLocalQueuedMessages(() => []);
3469
+ }
3470
+ const activeRun = getActiveRun();
3471
+ const runIdToAbort = reconnectRunIdRef.current ?? activeRun?.runId;
3472
+ userStoppedRunRef.current = {
3473
+ at: Date.now(),
3474
+ ...(runIdToAbort ? { runId: runIdToAbort } : {}),
3475
+ };
3476
+ setRunErrorInfo(null);
3477
+ setDismissedRunErrorKey(null);
3478
+ if (runIdToAbort) {
3479
+ if (threadId) clearActiveRunIfMatches(threadId, runIdToAbort);
3480
+ fetch(`${apiUrl}/runs/${encodeURIComponent(runIdToAbort)}/abort`, {
3481
+ method: "POST",
3482
+ }).catch(() => {});
3483
+ }
3484
+ if (isReconnecting) {
3485
+ reconnectAbortRef.current?.abort();
3486
+ reconnectAbortRef.current = null;
3487
+ reconnectRunIdRef.current = null;
3488
+ setIsReconnecting(false);
3489
+ setAdapterHandoffPending(false);
3490
+ const shouldFreezeReconnectContent =
3491
+ !reconnectTailOnlyRef.current &&
3492
+ reconnectCanMaterializeRef.current &&
3493
+ reconnectContent.length > 0;
3494
+ if (shouldFreezeReconnectContent) {
3495
+ const frozenContent = cloneContentParts(reconnectContent);
3496
+ settleInterruptedToolCalls(frozenContent, undefined, {
3497
+ includeActivity: true,
3498
+ });
3499
+ setReconnectContent(frozenContent);
3500
+ setReconnectFrozen(true);
3501
+ } else {
3502
+ setReconnectFrozen(false);
3503
+ setReconnectContent([]);
3504
+ reconnectCanMaterializeRef.current = false;
3505
+ }
3506
+ reconnectTailOnlyRef.current = false;
3507
+ }
3508
+ threadRuntime.cancelRun();
3509
+ settleVisibleInterruptedTools();
3510
+ if (typeof window !== "undefined") {
3511
+ window.dispatchEvent(
3512
+ new CustomEvent("agentNative.chatRunning", {
3513
+ detail: { isRunning: false, tabId: tabId || threadId },
3514
+ }),
3515
+ );
3516
+ }
3517
+ },
3518
+ [
3519
+ apiUrl,
3520
+ applyLocalQueuedMessages,
3521
+ isReconnecting,
3522
+ resetRunningActivity,
3523
+ reconnectContent,
3524
+ settleVisibleInterruptedTools,
3525
+ tabId,
3526
+ threadId,
3527
+ threadRuntime,
3528
+ ],
3529
+ );
3317
3530
  // Keep the ref current so addToQueue can call it without a stale closure.
3318
3531
  stopActiveRunRef.current = stopActiveRun;
3319
3532
 
@@ -3331,7 +3544,8 @@ const AssistantChatInner = forwardRef<
3331
3544
  preserveReconnectAutoRecoveryBudget = false,
3332
3545
  hideUserMessage = false,
3333
3546
  ) => {
3334
- if (!(await ensureAgentEngineReadyForSubmit())) {
3547
+ if (agentEngineConfigured.state === "missing") {
3548
+ void ensureAgentEngineReadyForSubmit();
3335
3549
  return;
3336
3550
  }
3337
3551
  if (!preserveReconnectAutoRecoveryBudget) {
@@ -3500,7 +3714,7 @@ const AssistantChatInner = forwardRef<
3500
3714
  hideUserMessage,
3501
3715
  },
3502
3716
  ]);
3503
- stopActiveRunRef.current();
3717
+ stopActiveRunRef.current({ preserveQueuedMessages: true });
3504
3718
  } else if (isRunning && intent === "queued") {
3505
3719
  applyLocalQueuedMessages((prev) => [
3506
3720
  ...prev,
@@ -3550,6 +3764,7 @@ const AssistantChatInner = forwardRef<
3550
3764
  },
3551
3765
  [
3552
3766
  applyLocalQueuedMessages,
3767
+ agentEngineConfigured.state,
3553
3768
  buildComposerContextSubmission,
3554
3769
  ensureAgentEngineReadyForSubmit,
3555
3770
  execMode,
@@ -4138,7 +4353,9 @@ const AssistantChatInner = forwardRef<
4138
4353
  />
4139
4354
  </MessageScrollerItem>
4140
4355
  )}
4141
- {(isReconnecting || reconnectFrozen) &&
4356
+ {(isReconnecting ||
4357
+ reconnectFrozen ||
4358
+ adapterHandoffPending) &&
4142
4359
  visibleReconnectContent.length > 0 && (
4143
4360
  <MessageScrollerItem>
4144
4361
  <ReconnectStreamMessage
@@ -4146,7 +4363,9 @@ const AssistantChatInner = forwardRef<
4146
4363
  />
4147
4364
  </MessageScrollerItem>
4148
4365
  )}
4149
- {(isReconnecting || reconnectFrozen) &&
4366
+ {(isReconnecting ||
4367
+ reconnectFrozen ||
4368
+ adapterHandoffPending) &&
4150
4369
  visibleReconnectContent.length === 0 &&
4151
4370
  reconnectContent.length === 0 &&
4152
4371
  reconnectActivityContent.length > 0 && (
@@ -4348,7 +4567,6 @@ const AssistantChatInner = forwardRef<
4348
4567
  : undefined
4349
4568
  }
4350
4569
  onSlashCommand={onSlashCommand}
4351
- onBeforeSubmit={ensureAgentEngineReadyForSubmit}
4352
4570
  execMode={execMode}
4353
4571
  onExecModeChange={onExecModeChange}
4354
4572
  planModeDisabled={planModeDisabled}
@@ -4383,7 +4601,7 @@ const AssistantChatInner = forwardRef<
4383
4601
  <TooltipTrigger asChild>
4384
4602
  <button
4385
4603
  type="button"
4386
- onClick={stopActiveRun}
4604
+ onClick={() => stopActiveRun()}
4387
4605
  className="shrink-0 flex h-7 w-7 items-center justify-center rounded-md bg-muted text-foreground hover:bg-muted/80"
4388
4606
  >
4389
4607
  <IconPlayerStop className="h-3.5 w-3.5" />