@agent-native/toolkit 0.204.0 → 0.204.1-nightly-20261008034100

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 (144) hide show
  1. package/dist/app/agent-page/AgentJobsTab.d.ts.map +1 -1
  2. package/dist/app/agent-page/AgentJobsTab.js +4 -2
  3. package/dist/app/agent-page/AgentJobsTab.js.map +1 -1
  4. package/dist/app/agent-page/AutomationDetailsDialog.d.ts +2 -1
  5. package/dist/app/agent-page/AutomationDetailsDialog.d.ts.map +1 -1
  6. package/dist/app/agent-page/AutomationDetailsDialog.js +8 -4
  7. package/dist/app/agent-page/AutomationDetailsDialog.js.map +1 -1
  8. package/dist/app/agent-page/ScheduledTriggerNotice.d.ts.map +1 -1
  9. package/dist/app/agent-page/ScheduledTriggerNotice.js +41 -19
  10. package/dist/app/agent-page/ScheduledTriggerNotice.js.map +1 -1
  11. package/dist/app/agent-page/use-jobs.d.ts +1 -1
  12. package/dist/app/agent-page/use-jobs.d.ts.map +1 -1
  13. package/dist/app/agentkit/react/components.d.ts.map +1 -1
  14. package/dist/app/agentkit/react/components.js +124 -45
  15. package/dist/app/agentkit/react/components.js.map +1 -1
  16. package/dist/app/agentkit/react/context.d.ts +22 -1
  17. package/dist/app/agentkit/react/context.d.ts.map +1 -1
  18. package/dist/app/agentkit/react/context.js +14 -1
  19. package/dist/app/agentkit/react/context.js.map +1 -1
  20. package/dist/app/agentkit/react/root.d.ts +3 -2
  21. package/dist/app/agentkit/react/root.d.ts.map +1 -1
  22. package/dist/app/agentkit/react/root.js +2 -2
  23. package/dist/app/agentkit/react/root.js.map +1 -1
  24. package/dist/app/agentkit/react/styles.css +32 -2
  25. package/dist/app/chat/AgentKitAssistantChat.d.ts.map +1 -1
  26. package/dist/app/chat/AgentKitAssistantChat.js +28 -3
  27. package/dist/app/chat/AgentKitAssistantChat.js.map +1 -1
  28. package/dist/app/chat/AgentPanel.js +1 -1
  29. package/dist/app/chat/AgentPanel.js.map +1 -1
  30. package/dist/app/chat/RunStuckBanner.d.ts +8 -1
  31. package/dist/app/chat/RunStuckBanner.d.ts.map +1 -1
  32. package/dist/app/chat/RunStuckBanner.js +68 -6
  33. package/dist/app/chat/RunStuckBanner.js.map +1 -1
  34. package/dist/app/chat/chat/run-recovery.d.ts +4 -2
  35. package/dist/app/chat/chat/run-recovery.d.ts.map +1 -1
  36. package/dist/app/chat/chat/run-recovery.js +3 -3
  37. package/dist/app/chat/chat/run-recovery.js.map +1 -1
  38. package/dist/app/chat/composer/use-mention-search.d.ts +1 -0
  39. package/dist/app/chat/composer/use-mention-search.d.ts.map +1 -1
  40. package/dist/app/chat/feedback-report.d.ts +12 -0
  41. package/dist/app/chat/feedback-report.d.ts.map +1 -0
  42. package/dist/app/chat/feedback-report.js +21 -0
  43. package/dist/app/chat/feedback-report.js.map +1 -0
  44. package/dist/app/chat/reconcile-settled-run.d.ts +15 -0
  45. package/dist/app/chat/reconcile-settled-run.d.ts.map +1 -0
  46. package/dist/app/chat/reconcile-settled-run.js +41 -0
  47. package/dist/app/chat/reconcile-settled-run.js.map +1 -0
  48. package/dist/app/i18n/catalogs/ar-SA.d.ts.map +1 -1
  49. package/dist/app/i18n/catalogs/ar-SA.js +9 -0
  50. package/dist/app/i18n/catalogs/ar-SA.js.map +1 -1
  51. package/dist/app/i18n/catalogs/de-DE.d.ts.map +1 -1
  52. package/dist/app/i18n/catalogs/de-DE.js +9 -0
  53. package/dist/app/i18n/catalogs/de-DE.js.map +1 -1
  54. package/dist/app/i18n/catalogs/en-US.d.ts +9 -0
  55. package/dist/app/i18n/catalogs/en-US.d.ts.map +1 -1
  56. package/dist/app/i18n/catalogs/en-US.js +9 -0
  57. package/dist/app/i18n/catalogs/en-US.js.map +1 -1
  58. package/dist/app/i18n/catalogs/es-ES.d.ts.map +1 -1
  59. package/dist/app/i18n/catalogs/es-ES.js +9 -0
  60. package/dist/app/i18n/catalogs/es-ES.js.map +1 -1
  61. package/dist/app/i18n/catalogs/fr-FR.d.ts.map +1 -1
  62. package/dist/app/i18n/catalogs/fr-FR.js +9 -0
  63. package/dist/app/i18n/catalogs/fr-FR.js.map +1 -1
  64. package/dist/app/i18n/catalogs/hi-IN.d.ts.map +1 -1
  65. package/dist/app/i18n/catalogs/hi-IN.js +9 -0
  66. package/dist/app/i18n/catalogs/hi-IN.js.map +1 -1
  67. package/dist/app/i18n/catalogs/ja-JP.d.ts.map +1 -1
  68. package/dist/app/i18n/catalogs/ja-JP.js +9 -0
  69. package/dist/app/i18n/catalogs/ja-JP.js.map +1 -1
  70. package/dist/app/i18n/catalogs/ko-KR.d.ts.map +1 -1
  71. package/dist/app/i18n/catalogs/ko-KR.js +9 -0
  72. package/dist/app/i18n/catalogs/ko-KR.js.map +1 -1
  73. package/dist/app/i18n/catalogs/pt-BR.d.ts.map +1 -1
  74. package/dist/app/i18n/catalogs/pt-BR.js +9 -0
  75. package/dist/app/i18n/catalogs/pt-BR.js.map +1 -1
  76. package/dist/app/i18n/catalogs/zh-CN.d.ts.map +1 -1
  77. package/dist/app/i18n/catalogs/zh-CN.js +9 -0
  78. package/dist/app/i18n/catalogs/zh-CN.js.map +1 -1
  79. package/dist/app/i18n/catalogs/zh-TW.d.ts.map +1 -1
  80. package/dist/app/i18n/catalogs/zh-TW.js +9 -0
  81. package/dist/app/i18n/catalogs/zh-TW.js.map +1 -1
  82. package/dist/app/settings/shell/pages/audit.d.ts.map +1 -1
  83. package/dist/app/settings/shell/pages/audit.js +11 -2
  84. package/dist/app/settings/shell/pages/audit.js.map +1 -1
  85. package/dist/composer/ComposerContextMenu.d.ts +14 -1
  86. package/dist/composer/ComposerContextMenu.d.ts.map +1 -1
  87. package/dist/composer/ComposerContextMenu.js +65 -19
  88. package/dist/composer/ComposerContextMenu.js.map +1 -1
  89. package/dist/composer/MentionPopover.d.ts.map +1 -1
  90. package/dist/composer/MentionPopover.js +3 -3
  91. package/dist/composer/MentionPopover.js.map +1 -1
  92. package/dist/composer/PromptComposer.d.ts +1 -1
  93. package/dist/composer/PromptComposer.d.ts.map +1 -1
  94. package/dist/composer/TiptapComposer.d.ts +2 -2
  95. package/dist/composer/TiptapComposer.d.ts.map +1 -1
  96. package/dist/composer/TiptapComposer.js +92 -36
  97. package/dist/composer/TiptapComposer.js.map +1 -1
  98. package/dist/composer/use-mention-search.d.ts +1 -0
  99. package/dist/composer/use-mention-search.d.ts.map +1 -1
  100. package/dist/composer/use-mention-search.js +8 -1
  101. package/dist/composer/use-mention-search.js.map +1 -1
  102. package/package.json +4 -4
  103. package/src/app/agent-page/AgentJobsTab.blocked.spec.tsx +43 -0
  104. package/src/app/agent-page/AgentJobsTab.tsx +14 -5
  105. package/src/app/agent-page/AutomationDetailsDialog.tsx +22 -8
  106. package/src/app/agent-page/ScheduledTriggerNotice.spec.tsx +45 -3
  107. package/src/app/agent-page/ScheduledTriggerNotice.tsx +52 -31
  108. package/src/app/agent-page/use-jobs.ts +1 -1
  109. package/src/app/agentkit/react/components.interaction.spec.tsx +311 -1
  110. package/src/app/agentkit/react/components.spec.tsx +106 -2
  111. package/src/app/agentkit/react/components.tsx +217 -80
  112. package/src/app/agentkit/react/context.tsx +36 -0
  113. package/src/app/agentkit/react/root.tsx +4 -0
  114. package/src/app/agentkit/react/styles.css +32 -2
  115. package/src/app/chat/AgentKitAssistantChat.tsx +35 -2
  116. package/src/app/chat/AgentPanel.header.spec.ts +43 -7
  117. package/src/app/chat/AgentPanel.tsx +1 -1
  118. package/src/app/chat/RunStuckBanner.spec.tsx +258 -17
  119. package/src/app/chat/RunStuckBanner.tsx +107 -5
  120. package/src/app/chat/chat/run-recovery.tsx +10 -2
  121. package/src/app/chat/feedback-report.spec.ts +45 -0
  122. package/src/app/chat/feedback-report.ts +27 -0
  123. package/src/app/chat/reconcile-settled-run.spec.ts +112 -0
  124. package/src/app/chat/reconcile-settled-run.ts +57 -0
  125. package/src/app/i18n/catalogs/ar-SA.ts +12 -0
  126. package/src/app/i18n/catalogs/de-DE.ts +12 -0
  127. package/src/app/i18n/catalogs/en-US.ts +12 -0
  128. package/src/app/i18n/catalogs/es-ES.ts +12 -0
  129. package/src/app/i18n/catalogs/fr-FR.ts +12 -0
  130. package/src/app/i18n/catalogs/hi-IN.ts +12 -0
  131. package/src/app/i18n/catalogs/ja-JP.ts +12 -0
  132. package/src/app/i18n/catalogs/ko-KR.ts +12 -0
  133. package/src/app/i18n/catalogs/pt-BR.ts +12 -0
  134. package/src/app/i18n/catalogs/zh-CN.ts +10 -0
  135. package/src/app/i18n/catalogs/zh-TW.ts +10 -0
  136. package/src/app/settings/shell/pages/audit.spec.tsx +30 -0
  137. package/src/app/settings/shell/pages/audit.tsx +12 -1
  138. package/src/composer/ComposerContextMenu.tsx +94 -22
  139. package/src/composer/ComposerPlusMenu.parity.spec.tsx +218 -233
  140. package/src/composer/MentionPopover.tsx +12 -3
  141. package/src/composer/PromptComposer.context.spec.tsx +526 -195
  142. package/src/composer/PromptComposer.tsx +1 -1
  143. package/src/composer/TiptapComposer.tsx +105 -37
  144. package/src/composer/use-mention-search.ts +8 -1
@@ -169,7 +169,9 @@ import {
169
169
  } from "./chat/tool-call-display.js";
170
170
  import { resolveAgentKitToolSource } from "./chat/tool-integration.js";
171
171
  import { ExternalAgentNudge } from "./external-agent-host.js";
172
+ import { formatFeedbackReport } from "./feedback-report.js";
172
173
  import { FileStorageSetupPopover } from "./FileStorageSetupPopover.js";
174
+ import { reconcileSettledRun } from "./reconcile-settled-run.js";
173
175
  import { RunStuckBanner } from "./RunStuckBanner.js";
174
176
  import { ThinkingDisplayProvider } from "./thinking-display.js";
175
177
 
@@ -856,6 +858,11 @@ export const AgentKitAssistantChat = forwardRef<
856
858
  shortcut: "{{shortcut}}",
857
859
  }),
858
860
  feedbackSubmit: t("agentChat.feedback.submit"),
861
+ feedbackReasonMisread: t("agentChat.feedback.reasonMisread"),
862
+ feedbackReasonNotDone: t("agentChat.feedback.reasonNotDone"),
863
+ feedbackReasonWrongNumbers: t("agentChat.feedback.reasonWrongNumbers"),
864
+ feedbackReasonTooSlow: t("agentChat.feedback.tooSlow"),
865
+ feedbackCopyDetails: t("agentChat.feedback.copyDetails"),
859
866
  fork: t("agentChat.message.forkChat"),
860
867
  previousBranch: t("agentChat.message.previousBranch"),
861
868
  nextBranch: t("agentChat.message.nextBranch"),
@@ -863,6 +870,8 @@ export const AgentKitAssistantChat = forwardRef<
863
870
  error: t("agentChat.error.failed"),
864
871
  renderError: t("agentChat.error.render"),
865
872
  runFailed: t("agentChat.error.failed"),
873
+ continueRun: t("agentChat.common.continue"),
874
+ continueRunUnavailable: t("agentChat.recovery.continueUnavailable"),
866
875
  reconnect: t("agentChat.agentPanel.chatgptSubscriptionReconnect"),
867
876
  reasoning: t("agentChat.status.thinking"),
868
877
  expandActivity: t("agentChat.common.expand"),
@@ -1202,6 +1211,7 @@ export const AgentKitAssistantChat = forwardRef<
1202
1211
  labels={labels}
1203
1212
  branchNavigation={props.branchNavigation}
1204
1213
  loadRunUsage={loadRunUsage}
1214
+ buildFeedbackReport={formatFeedbackReport}
1205
1215
  onThreadForked={(thread) => props.onForkedThread?.(thread.id)}
1206
1216
  onCopyMessage={({ text }) => {
1207
1217
  const html = renderMarkdownToClipboardHtml(text);
@@ -1522,6 +1532,15 @@ const AgentKitAssistantChatBody = forwardRef<
1522
1532
  () => hasActiveAgentRuns(controller.getThread(threadId)),
1523
1533
  [controller, threadId],
1524
1534
  );
1535
+ const reconcileServerSettled = useCallback(
1536
+ () =>
1537
+ reconcileSettledRun({
1538
+ load: control.load,
1539
+ getThread: () => controller.getThread(threadId),
1540
+ tabId: props.tabId ?? threadId,
1541
+ }),
1542
+ [control.load, controller, props.tabId, threadId],
1543
+ );
1525
1544
 
1526
1545
  useEffect(() => {
1527
1546
  if (previousPrefillRevisionRef.current === prefillRevision) return;
@@ -3141,6 +3160,7 @@ const AgentKitAssistantChatBody = forwardRef<
3141
3160
  )
3142
3161
  }
3143
3162
  isAwaitingResponse={() => isRunning}
3163
+ onServerSettled={reconcileServerSettled}
3144
3164
  onRetry={() =>
3145
3165
  void sendRecoveryMessage(RECOVERY_CONTINUE_PROMPT, "continue")
3146
3166
  }
@@ -4607,6 +4627,7 @@ function AgentKitRunFailure({
4607
4627
  const surface = useAgentKitSurface();
4608
4628
  const t = useT();
4609
4629
  const [dismissed, setDismissed] = useState<string | null>(null);
4630
+ const [continueFailed, setContinueFailed] = useState(false);
4610
4631
  const authErrorReason =
4611
4632
  error.code === "unauthorized" || error.code === "http_401"
4612
4633
  ? "session-expired"
@@ -4690,6 +4711,17 @@ function AgentKitRunFailure({
4690
4711
  );
4691
4712
  }
4692
4713
  if (wasRetried(thread.messages, runId)) return null;
4714
+ // Continuing resumes the stopped run's own turn, so finished steps are not
4715
+ // run again; that needs it to still be the turn's newest run.
4716
+ const continueStoppedRun = control.canContinueRun
4717
+ ? superseded
4718
+ ? undefined
4719
+ : () => {
4720
+ setContinueFailed(false);
4721
+ control.continueRun(runId).catch(() => setContinueFailed(true));
4722
+ }
4723
+ : () =>
4724
+ void surface.sendRecoveryMessage(RECOVERY_CONTINUE_PROMPT, "continue");
4693
4725
  const info: RunErrorInfo = {
4694
4726
  message: formatAgentKitErrorText(error, t),
4695
4727
  errorCode: error.code,
@@ -4700,8 +4732,9 @@ function AgentKitRunFailure({
4700
4732
  return (
4701
4733
  <RunErrorRecoveryCard
4702
4734
  info={info}
4703
- onContinue={() =>
4704
- void surface.sendRecoveryMessage(RECOVERY_CONTINUE_PROMPT, "continue")
4735
+ onContinue={continueStoppedRun}
4736
+ continueError={
4737
+ continueFailed ? t("agentChat.recovery.continueUnavailable") : null
4705
4738
  }
4706
4739
  onRetry={() => void retryFailedTurn()}
4707
4740
  retryHasUnavailableAttachment={retryRequest.hasUnavailableAttachment}
@@ -9,7 +9,7 @@ import {
9
9
  } from "@agent-native/toolkit/ui/dropdown-menu";
10
10
  import React, { act } from "react";
11
11
  import { createRoot, type Root } from "react-dom/client";
12
- import { MemoryRouter, useLocation } from "react-router";
12
+ import { MemoryRouter, useHref, useLocation } from "react-router";
13
13
  import { describe, expect, it, vi } from "vitest";
14
14
 
15
15
  import {
@@ -445,6 +445,45 @@ describe("AgentPanel settings navigation", () => {
445
445
  }
446
446
  });
447
447
 
448
+ it("keeps a settings request inside the mounted app path", () => {
449
+ const container = document.createElement("div");
450
+ document.body.appendChild(container);
451
+ const root: Root = createRoot(container);
452
+ let href = "";
453
+
454
+ function LocationProbe() {
455
+ const location = useLocation();
456
+ href = useHref(`${location.pathname}${location.hash}`);
457
+ return null;
458
+ }
459
+
460
+ try {
461
+ vi.stubEnv("VITE_APP_BASE_PATH", "/dispatch");
462
+ act(() => {
463
+ window.history.replaceState(null, "", "/dispatch/chat#llm");
464
+ root.render(
465
+ React.createElement(
466
+ MemoryRouter,
467
+ { basename: "/dispatch", initialEntries: ["/dispatch/chat#llm"] },
468
+ React.createElement(AgentPanelSettingsNavigation),
469
+ React.createElement(LocationProbe),
470
+ ),
471
+ );
472
+ });
473
+
474
+ act(() => {
475
+ window.dispatchEvent(new CustomEvent("agent-panel:open-settings"));
476
+ });
477
+
478
+ expect(href).toBe("/dispatch/settings#llm");
479
+ } finally {
480
+ act(() => root.unmount());
481
+ container.remove();
482
+ window.history.replaceState(null, "", "/");
483
+ vi.unstubAllEnvs();
484
+ }
485
+ });
486
+
448
487
  it("routes a mounted settings request to an existing secret-specific hash", () => {
449
488
  const container = document.createElement("div");
450
489
  document.body.appendChild(container);
@@ -532,7 +571,7 @@ describe("AgentPanel settings navigation", () => {
532
571
  }
533
572
  });
534
573
 
535
- it("preserves the app base path when opening settings", () => {
574
+ it("navigates with a router-local settings path", () => {
536
575
  const container = document.createElement("div");
537
576
  document.body.appendChild(container);
538
577
  const root: Root = createRoot(container);
@@ -545,11 +584,10 @@ describe("AgentPanel settings navigation", () => {
545
584
 
546
585
  try {
547
586
  act(() => {
548
- window.history.replaceState(null, "", "/dispatch/_agent-native/poll");
549
587
  root.render(
550
588
  React.createElement(
551
589
  MemoryRouter,
552
- { initialEntries: ["/"] },
590
+ { initialEntries: ["/chat"] },
553
591
  React.createElement(AgentPanelSettingsNavigation),
554
592
  React.createElement(LocationProbe),
555
593
  ),
@@ -564,11 +602,10 @@ describe("AgentPanel settings navigation", () => {
564
602
  );
565
603
  });
566
604
 
567
- expect(pathname).toBe("/dispatch/settings");
605
+ expect(pathname).toBe("/settings");
568
606
  } finally {
569
607
  act(() => root.unmount());
570
608
  container.remove();
571
- window.history.replaceState(null, "", "/");
572
609
  }
573
610
  });
574
611
 
@@ -1134,7 +1171,6 @@ describe("AgentChatSurface chrome defaults", () => {
1134
1171
  expect(source).not.toContain("SettingsPanel");
1135
1172
  expect(source).not.toContain("allowSettingsMode");
1136
1173
  expect(source).not.toContain('mode === "settings"');
1137
- expect(source).toContain('pathname: appPath("/settings")');
1138
1174
  });
1139
1175
 
1140
1176
  it("mounts URL command sync for a full-page chat surface", () => {
@@ -199,7 +199,7 @@ export function AgentPanelSettingsNavigation({
199
199
  const requested = requestedSettingsSection(section, window.location.hash);
200
200
  const navigation = navigate(
201
201
  {
202
- pathname: appPath("/settings"),
202
+ pathname: "/settings",
203
203
  hash: settingsRouteHashForSection(section, window.location.hash),
204
204
  },
205
205
  // The hash can't tell API keys from Integrations; the redesigned
@@ -1,5 +1,6 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
+ import type { RunStuckState } from "@agent-native/core/client/agent-chat";
3
4
  import { act } from "react";
4
5
  import { createRoot, type Root } from "react-dom/client";
5
6
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -8,20 +9,42 @@ import { RunStuckBanner } from "./RunStuckBanner.js";
8
9
 
9
10
  const trackEventMock = vi.hoisted(() => vi.fn());
10
11
 
12
+ const STUCK_STATE: RunStuckState = {
13
+ isStuck: true,
14
+ runId: "run-1",
15
+ status: "running",
16
+ lastProgressAt: 0,
17
+ stuckSinceMs: 90_000,
18
+ lastProgressSeq: 1,
19
+ heartbeatAt: null,
20
+ heartbeatSinceMs: null,
21
+ dispatchMode: null,
22
+ hasInFlightWork: false,
23
+ serverSettled: false,
24
+ statusUnreadable: false,
25
+ };
26
+
27
+ const SETTLED_STATE: RunStuckState = {
28
+ ...STUCK_STATE,
29
+ isStuck: false,
30
+ status: "idle",
31
+ stuckSinceMs: null,
32
+ serverSettled: true,
33
+ };
34
+
35
+ const hookState = vi.hoisted(() => ({
36
+ current: null as RunStuckState | null,
37
+ awaitingResponse: undefined as boolean | undefined,
38
+ }));
39
+
40
+ const abortRunMock = vi.hoisted(() => vi.fn(async () => null));
41
+
11
42
  vi.mock("@agent-native/core/client/agent-chat", () => ({
12
- useRunStuckDetection: () => ({
13
- isStuck: true,
14
- runId: "run-1",
15
- status: "running",
16
- lastProgressAt: 0,
17
- stuckSinceMs: 90_000,
18
- lastProgressSeq: 1,
19
- heartbeatAt: null,
20
- heartbeatSinceMs: null,
21
- dispatchMode: null,
22
- hasInFlightWork: false,
23
- }),
24
- useAbortRun: () => vi.fn(),
43
+ useRunStuckDetection: (options: { awaitingResponse?: boolean }) => {
44
+ hookState.awaitingResponse = options.awaitingResponse;
45
+ return hookState.current;
46
+ },
47
+ useAbortRun: () => abortRunMock,
25
48
  }));
26
49
 
27
50
  vi.mock("@agent-native/core/client/analytics", () => ({
@@ -38,28 +61,246 @@ describe("RunStuckBanner", () => {
38
61
 
39
62
  beforeEach(() => {
40
63
  vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
64
+ vi.useFakeTimers({ toFake: ["Date"] });
65
+ vi.setSystemTime(1_000_000);
66
+ trackEventMock.mockClear();
67
+ abortRunMock.mockClear();
68
+ window.localStorage.clear();
69
+ hookState.current = STUCK_STATE;
41
70
  container = document.createElement("div");
42
71
  document.body.appendChild(container);
72
+ root = createRoot(container);
43
73
  });
44
74
 
45
75
  afterEach(() => {
76
+ act(() => root.unmount());
46
77
  container.remove();
78
+ vi.useRealTimers();
47
79
  vi.unstubAllGlobals();
48
80
  });
49
81
 
82
+ async function render(props: Parameters<typeof RunStuckBanner>[0]) {
83
+ await act(async () => {
84
+ root.render(<RunStuckBanner {...props} />);
85
+ });
86
+ }
87
+
50
88
  it("reports a stuck run once, even when the chat view remounts", async () => {
51
89
  for (let mount = 0; mount < 2; mount += 1) {
52
- root = createRoot(container);
90
+ const remounted = createRoot(document.createElement("div"));
53
91
  await act(async () => {
54
- root.render(<RunStuckBanner threadId="thread-1" />);
92
+ remounted.render(<RunStuckBanner threadId="thread-1" />);
55
93
  });
56
- await act(async () => root.unmount());
94
+ await act(async () => remounted.unmount());
57
95
  }
58
96
 
59
97
  expect(trackEventMock).toHaveBeenCalledTimes(1);
60
98
  expect(trackEventMock).toHaveBeenCalledWith(
61
99
  "agent_chat_stuck_detected",
62
- expect.objectContaining({ runId: "run-1", threadId: "thread-1" }),
100
+ expect.objectContaining({
101
+ runId: "run-1",
102
+ threadId: "thread-1",
103
+ reason: "no_progress",
104
+ dispatchMode: null,
105
+ hasInFlightWork: false,
106
+ }),
107
+ );
108
+ });
109
+
110
+ const isAwaitingResponse = () => true;
111
+
112
+ describe("when the server stops tracking a run the chat shows as running", () => {
113
+ beforeEach(() => {
114
+ hookState.current = SETTLED_STATE;
115
+ });
116
+
117
+ it("reloads the thread once and says nothing when that settles the chat", async () => {
118
+ const onServerSettled = vi.fn(async () => "settled" as const);
119
+
120
+ await render({
121
+ threadId: "thread-1",
122
+ onServerSettled,
123
+ isAwaitingResponse,
124
+ });
125
+ await render({
126
+ threadId: "thread-1",
127
+ onServerSettled,
128
+ isAwaitingResponse,
129
+ });
130
+
131
+ expect(onServerSettled).toHaveBeenCalledTimes(1);
132
+ expect(container.textContent).toBe("");
133
+ });
134
+
135
+ it("does not reconcile a host that cannot say whether the chat is waiting", async () => {
136
+ const onServerSettled = vi.fn(async () => "settled" as const);
137
+
138
+ await render({ threadId: "thread-1", onServerSettled });
139
+
140
+ expect(onServerSettled).not.toHaveBeenCalled();
141
+ });
142
+
143
+ it("does nothing for a chat that is not waiting on a response", async () => {
144
+ const onServerSettled = vi.fn(async () => "settled" as const);
145
+
146
+ await render({
147
+ threadId: "thread-1",
148
+ onServerSettled,
149
+ isAwaitingResponse: () => false,
150
+ });
151
+
152
+ expect(onServerSettled).not.toHaveBeenCalled();
153
+ expect(container.textContent).toBe("");
154
+ });
155
+
156
+ it("tells the user when the reload could not settle the chat, and retries at a bounded pace", async () => {
157
+ const onServerSettled = vi.fn(async () => "still_running" as const);
158
+
159
+ await render({
160
+ threadId: "thread-1",
161
+ onServerSettled,
162
+ isAwaitingResponse,
163
+ });
164
+ expect(container.textContent).toContain(
165
+ "agentChat.recovery.statusMismatch",
166
+ );
167
+ expect(container.textContent).toContain("agentChat.recovery.reload");
168
+ expect(container.textContent).not.toContain("recovery.stuckTitle");
169
+
170
+ // Every poll hands the banner a fresh state object.
171
+ vi.setSystemTime(1_015_000);
172
+ hookState.current = { ...SETTLED_STATE };
173
+ await render({
174
+ threadId: "thread-1",
175
+ onServerSettled,
176
+ isAwaitingResponse,
177
+ });
178
+ expect(onServerSettled).toHaveBeenCalledTimes(1);
179
+
180
+ vi.setSystemTime(1_031_000);
181
+ hookState.current = { ...SETTLED_STATE };
182
+ await render({
183
+ threadId: "thread-1",
184
+ onServerSettled,
185
+ isAwaitingResponse,
186
+ });
187
+ expect(onServerSettled).toHaveBeenCalledTimes(2);
188
+ });
189
+
190
+ it("tells the detector whether the chat is waiting, so a settle predating the wait is dropped", async () => {
191
+ await render({ threadId: "thread-1", isAwaitingResponse: () => true });
192
+ expect(hookState.awaitingResponse).toBe(true);
193
+
194
+ await render({ threadId: "thread-1", isAwaitingResponse: () => false });
195
+ expect(hookState.awaitingResponse).toBe(false);
196
+
197
+ await render({ threadId: "thread-1" });
198
+ expect(hookState.awaitingResponse).toBeUndefined();
199
+ });
200
+
201
+ it("ignores a reload that finishes after the server started a run again", async () => {
202
+ let finishReload: (outcome: "still_running") => void = () => {};
203
+ const onServerSettled = vi.fn(
204
+ () =>
205
+ new Promise<"still_running">((resolve) => {
206
+ finishReload = resolve;
207
+ }),
208
+ );
209
+
210
+ await render({
211
+ threadId: "thread-1",
212
+ onServerSettled,
213
+ isAwaitingResponse,
214
+ });
215
+ expect(onServerSettled).toHaveBeenCalledTimes(1);
216
+
217
+ hookState.current = {
218
+ ...STUCK_STATE,
219
+ isStuck: false,
220
+ serverSettled: false,
221
+ };
222
+ await render({
223
+ threadId: "thread-1",
224
+ onServerSettled,
225
+ isAwaitingResponse,
226
+ });
227
+ await act(async () => finishReload("still_running"));
228
+
229
+ expect(container.textContent).toBe("");
230
+ });
231
+
232
+ it("shows an unreadable status, not a finished chat, when the reload itself fails", async () => {
233
+ const onServerSettled = vi.fn(async () => {
234
+ throw new Error("thread unavailable");
235
+ });
236
+
237
+ await render({
238
+ threadId: "thread-1",
239
+ onServerSettled,
240
+ isAwaitingResponse,
241
+ });
242
+
243
+ expect(container.textContent).toContain(
244
+ "agentChat.recovery.statusUnreadable",
245
+ );
246
+ expect(container.textContent).not.toContain(
247
+ "agentChat.recovery.statusMismatch",
248
+ );
249
+ });
250
+ });
251
+
252
+ it("shows an unreadable status when the run's status cannot be fetched", async () => {
253
+ hookState.current = {
254
+ ...STUCK_STATE,
255
+ isStuck: false,
256
+ stuckSinceMs: null,
257
+ statusUnreadable: true,
258
+ };
259
+
260
+ await render({ threadId: "thread-1" });
261
+ expect(container.textContent).toContain(
262
+ "agentChat.recovery.statusUnreadable",
263
+ );
264
+ expect(container.textContent).not.toContain("recovery.stuckTitle");
265
+ expect(container.textContent).not.toContain("agentChat.recovery.reload");
266
+
267
+ await render({ threadId: "thread-1", isAwaitingResponse: () => false });
268
+ expect(container.textContent).toBe("");
269
+
270
+ // A host that cannot say whether the chat waits only doubts a run it saw.
271
+ hookState.current = { ...hookState.current!, status: "idle", runId: null };
272
+ await render({ threadId: "thread-1" });
273
+ expect(container.textContent).toBe("");
274
+ });
275
+
276
+ it("shows the unreadable status, not a stuck banner, when the status is unreadable", async () => {
277
+ hookState.current = { ...STUCK_STATE, statusUnreadable: true };
278
+
279
+ await render({ threadId: "thread-1" });
280
+
281
+ expect(container.textContent).toContain(
282
+ "agentChat.recovery.statusUnreadable",
63
283
  );
284
+ expect(container.textContent).not.toContain(
285
+ "agentChat.recovery.stuckTitle",
286
+ );
287
+ expect(container.querySelectorAll("button")).toHaveLength(0);
288
+ });
289
+
290
+ it("does not auto-abort a run on a stuck verdict it cannot confirm", async () => {
291
+ const props = {
292
+ threadId: "thread-auto-retry",
293
+ autoRetry: true,
294
+ autoRetryOwnerId: "owner-1",
295
+ };
296
+ hookState.current = { ...STUCK_STATE, statusUnreadable: true };
297
+
298
+ await render(props);
299
+ expect(abortRunMock).not.toHaveBeenCalled();
300
+
301
+ hookState.current = { ...STUCK_STATE };
302
+ await render(props);
303
+ await act(async () => {});
304
+ expect(abortRunMock).toHaveBeenCalledWith("run-1", "auto_stuck_retry");
64
305
  });
65
306
  });
@@ -18,11 +18,20 @@ export interface RunStuckBannerProps {
18
18
  hasInFlightWork?: () => boolean;
19
19
  isAwaitingResponse?: () => boolean;
20
20
  onStuckStateChange?: (state: RunStuckState) => void;
21
+ /**
22
+ * The server has stopped tracking a run this chat still shows as running.
23
+ * Resolves `settled` once the chat no longer shows it running; rejects when
24
+ * the thread could not be refreshed.
25
+ */
26
+ onServerSettled?: () => Promise<RunReconcileOutcome>;
21
27
  autoRetry?: boolean;
22
28
  autoRetryOwnerId?: string;
23
29
  className?: string;
24
30
  }
25
31
 
32
+ export type RunReconcileOutcome = "settled" | "still_running";
33
+
34
+ const RECONCILE_RETRY_MS = 30_000;
26
35
  const AUTO_RETRY_CLAIM_TTL_MS = 5 * 60 * 1000;
27
36
  const BACKGROUND_WORKER_FRESH_HEARTBEAT_MS = 30_000;
28
37
 
@@ -123,6 +132,7 @@ export function RunStuckBanner({
123
132
  stuckThresholdMs,
124
133
  onRetry,
125
134
  onStuckStateChange,
135
+ onServerSettled,
126
136
  autoRetry = false,
127
137
  autoRetryOwnerId,
128
138
  hasInFlightWork,
@@ -130,9 +140,11 @@ export function RunStuckBanner({
130
140
  className,
131
141
  }: RunStuckBannerProps) {
132
142
  const t = useT();
143
+ const chatAwaiting = isAwaitingResponse?.();
133
144
  const state = useRunStuckDetection({
134
145
  threadId,
135
146
  enabled,
147
+ awaitingResponse: chatAwaiting,
136
148
  stuckThresholdMs,
137
149
  apiUrl,
138
150
  });
@@ -148,9 +160,13 @@ export function RunStuckBanner({
148
160
  const backgroundWorkerStillAlive = isFreshBackgroundWorker(state);
149
161
  const inFlightWork =
150
162
  state.hasInFlightWork === true || (hasInFlightWork?.() ?? false);
151
- const awaitingResponse = isAwaitingResponse?.() ?? true;
163
+ const awaitingResponse = chatAwaiting ?? true;
164
+ // While the status is unreadable `isStuck` is the last answer carried forward
165
+ // by the clock, not a fresh one: only the unreadable notice may speak, and
166
+ // nothing may abort a run on it.
167
+ const isStuck = state.isStuck && !state.statusUnreadable;
152
168
  const showsStuckBanner =
153
- state.isStuck &&
169
+ isStuck &&
154
170
  !!state.runId &&
155
171
  !backgroundWorkerStillAlive &&
156
172
  !inFlightWork &&
@@ -159,6 +175,50 @@ export function RunStuckBanner({
159
175
  state.dispatchMode === "foreground-self-chain" ||
160
176
  state.dispatchMode?.startsWith("background") === true;
161
177
 
178
+ // Only a host that says when the chat is waiting can tell a lost ending from
179
+ // an idle chat, so reconciling needs `isAwaitingResponse`.
180
+ const chatShowsRunning = isAwaitingResponse != null && awaitingResponse;
181
+ const settledWhileRunning = chatShowsRunning && state.serverSettled;
182
+ const [reconcile, setReconcile] = useState<"idle" | "failed" | "unsettled">(
183
+ "idle",
184
+ );
185
+ const lastReconcileAtRef = useRef(0);
186
+ const reconcileEpochRef = useRef(0);
187
+ const mountedRef = useRef(true);
188
+ useEffect(() => {
189
+ mountedRef.current = true;
190
+ return () => {
191
+ mountedRef.current = false;
192
+ };
193
+ }, []);
194
+ // `state` changes on every poll, so a reconcile that did not settle the chat
195
+ // is retried at most once per RECONCILE_RETRY_MS, never in a loop.
196
+ useEffect(() => {
197
+ if (!settledWhileRunning || !onServerSettled) {
198
+ // A reload still in flight answers a chat that has since moved on.
199
+ reconcileEpochRef.current += 1;
200
+ lastReconcileAtRef.current = 0;
201
+ setReconcile("idle");
202
+ return;
203
+ }
204
+ const now = Date.now();
205
+ if (now - lastReconcileAtRef.current < RECONCILE_RETRY_MS) return;
206
+ lastReconcileAtRef.current = now;
207
+ const epoch = reconcileEpochRef.current;
208
+ const current = () =>
209
+ mountedRef.current && epoch === reconcileEpochRef.current;
210
+ onServerSettled().then(
211
+ (outcome) => {
212
+ if (current()) {
213
+ setReconcile(outcome === "settled" ? "idle" : "unsettled");
214
+ }
215
+ },
216
+ () => {
217
+ if (current()) setReconcile("failed");
218
+ },
219
+ );
220
+ }, [onServerSettled, settledWhileRunning, state]);
221
+
162
222
  const lastReportedRef = useRef<{
163
223
  isStuck: boolean;
164
224
  runId: string | null;
@@ -185,6 +245,13 @@ export function RunStuckBanner({
185
245
  ? Math.floor(state.stuckSinceMs / 1000)
186
246
  : null,
187
247
  runStatus: state.status,
248
+ reason: "no_progress",
249
+ dispatchMode: state.dispatchMode,
250
+ hasInFlightWork: state.hasInFlightWork,
251
+ heartbeatSinceSec:
252
+ state.heartbeatSinceMs != null
253
+ ? Math.floor(state.heartbeatSinceMs / 1000)
254
+ : null,
188
255
  });
189
256
  }, [showsStuckBanner, state, threadId]);
190
257
 
@@ -204,7 +271,7 @@ export function RunStuckBanner({
204
271
  backgroundWorkerStillAlive ||
205
272
  inFlightWork ||
206
273
  !awaitingResponse ||
207
- !state.isStuck ||
274
+ !isStuck ||
208
275
  !state.runId ||
209
276
  busy.type !== "none" ||
210
277
  autoRetriedRunIdsRef.current.has(state.runId)
@@ -239,9 +306,9 @@ export function RunStuckBanner({
239
306
  busy,
240
307
  inFlightWork,
241
308
  isServerContinuedDispatch,
309
+ isStuck,
242
310
  onRetry,
243
311
  ownerId,
244
- state.isStuck,
245
312
  state.runId,
246
313
  state.stuckSinceMs,
247
314
  threadId,
@@ -249,7 +316,42 @@ export function RunStuckBanner({
249
316
  ]);
250
317
 
251
318
  if (!showsStuckBanner) {
252
- return null;
319
+ const notice =
320
+ reconcile === "unsettled"
321
+ ? "agentChat.recovery.statusMismatch"
322
+ : (isAwaitingResponse
323
+ ? awaitingResponse
324
+ : state.status === "running") &&
325
+ (state.statusUnreadable || reconcile === "failed")
326
+ ? "agentChat.recovery.statusUnreadable"
327
+ : null;
328
+ if (!notice) return null;
329
+ return (
330
+ <div
331
+ role="status"
332
+ aria-live="polite"
333
+ className={cn(
334
+ "mx-3 mt-2 flex items-center gap-2.5 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2.5 text-xs text-foreground",
335
+ className,
336
+ )}
337
+ >
338
+ <IconAlertTriangle
339
+ size={16}
340
+ className="shrink-0 text-amber-500"
341
+ aria-hidden="true"
342
+ />
343
+ <span className="min-w-0 flex-1 leading-snug">{t(notice)}</span>
344
+ {reconcile === "unsettled" ? (
345
+ <button
346
+ type="button"
347
+ onClick={() => window.location.reload()}
348
+ className="inline-flex h-7 shrink-0 cursor-pointer items-center rounded-md bg-foreground px-2.5 text-[11px] font-medium text-background transition-colors hover:bg-foreground/90"
349
+ >
350
+ {t("agentChat.recovery.reload")}
351
+ </button>
352
+ ) : null}
353
+ </div>
354
+ );
253
355
  }
254
356
 
255
357
  const handleCancel = async () => {