Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/stuck-banner-idle-chat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-native/core": patch
---

Stop the "chat looks stuck" banner from appearing on an idle chat whose turn already finished, and keep its Retry/Cancel buttons clickable after an automatic retry.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@
"react": "19.2.7",
"react-dom": "19.2.7",
"@tanstack/react-query": "5.101.2",
"@radix-ui/react-dismissable-layer": "1.1.19",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"rollup": ">=4.59.0",
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/client/MultiTabAssistantChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2610,6 +2610,9 @@ export function MultiTabAssistantChat({
hasInFlightWork={() =>
chatRefs.current.get(tabId)?.hasInFlightWork() ?? false
}
isAwaitingResponse={() =>
chatRefs.current.get(tabId)?.isRunning() ?? false
}
onRetry={() => {
const handle = chatRefs.current.get(tabId);
handle?.sendRecoveryMessage(
Expand Down
83 changes: 83 additions & 0 deletions packages/core/src/client/RunStuckBanner.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -785,4 +785,87 @@ describe("RunStuckBanner", () => {
secondContainer.remove();
}
});

it("stays hidden when the chat is not waiting on a reply", async () => {
// A turn that finished normally can leave the run row in `running` until
// the stale-run reaper catches it. That is server hygiene, not a stuck
// chat: warning about it — and auto-retrying, which re-prompts a thread
// the user considers done — is the bug.
const onRetry = vi.fn();
const fetchSpy = vi.fn(async (url: string) => {
if (url.includes("/runs/active")) {
return jsonResponse({
active: true,
runId: "run-abandoned",
status: "running",
heartbeatAt: 10_000,
lastProgressAt: 10_000,
serverNow: 101_000,
});
}
return jsonResponse({ error: "unexpected" }, false);
});
vi.stubGlobal("fetch", fetchSpy);

await act(async () => {
root.render(
<RunStuckBanner
threadId="thread-1"
autoRetry
onRetry={onRetry}
isAwaitingResponse={() => false}
/>,
);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(2_000);
});

expect(container.textContent).toBe("");
expect(onRetry).not.toHaveBeenCalled();
expect(
fetchSpy.mock.calls.some(
([url, init]) =>
String(url).includes("/abort") && init?.method === "POST",
),
).toBe(false);
});

it("re-enables the controls when an auto-retry leaves the run running", async () => {
// The abort is accepted but the run never leaves `running`, so polling
// never observes a new run id. The controls must not stay disabled — that
// left a reload as the only way out.
const fetchSpy = vi.fn(async (url: string) => {
if (url.includes("/runs/active")) {
return jsonResponse({
active: true,
runId: "run-wedged",
status: "running",
heartbeatAt: 10_000,
lastProgressAt: 10_000,
serverNow: 101_000,
});
}
if (url.includes("/runs/run-wedged/abort")) {
return jsonResponse({ ok: true });
}
return jsonResponse({ error: "unexpected" }, false);
});
vi.stubGlobal("fetch", fetchSpy);

await act(async () => {
root.render(<RunStuckBanner threadId="thread-1" autoRetry />);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(30_000);
});

expect(container.textContent).toContain("Retrying automatically now.");
const buttons = [...container.querySelectorAll("button")];
expect(buttons.map((button) => button.textContent)).toEqual([
"Retry",
"Cancel",
]);
expect(buttons.every((button) => button.disabled)).toBe(false);
});
});
42 changes: 35 additions & 7 deletions packages/core/src/client/RunStuckBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@ export interface RunStuckBannerProps {
* render; omit to keep the unconditional Retry/Cancel behavior.
*/
hasInFlightWork?: () => boolean;
/**
* Returns true when this chat is actually waiting on a reply — typically
* backed by `chatHandle.isRunning()`. "Stuck" is a claim about THIS client,
* but the run row is the server's; a turn that finished normally can leave a
* row in `running` (terminal marking is best-effort, and the stale-run reaper
* runs later). Without this gate an idle, completed chat shows the warning
* and auto-retry re-prompts a thread the user considers done. Checked fresh
* on every render; omit to keep the unconditional behavior.
*/
isAwaitingResponse?: () => boolean;
/**
* Called whenever the stuck state transitions. Useful for surfacing
* observability events (Sentry, PostHog) at the call site.
Expand Down Expand Up @@ -170,6 +180,7 @@ export function RunStuckBanner({
autoRetry = false,
autoRetryOwnerId,
hasInFlightWork,
isAwaitingResponse,
className,
}: RunStuckBannerProps) {
const state = useRunStuckDetection({
Expand All @@ -180,6 +191,11 @@ export function RunStuckBanner({
});
const abortRun = useAbortRun(apiUrl);
const [busy, setBusy] = useState<BusyState>({ type: "none" });
// "An automatic attempt was made for this run" is a different fact from
// "the controls are locked while a request is in flight". Sharing one flag
// for both is what previously forced the buttons to stay disabled just to
// keep the message on screen.
const [autoRetriedRunId, setAutoRetriedRunId] = useState<string | null>(null);
const autoRetriedRunIdsRef = useRef<Set<string>>(new Set());
const generatedOwnerIdRef = useRef<string | null>(null);
if (!generatedOwnerIdRef.current) {
Expand All @@ -204,6 +220,7 @@ export function RunStuckBanner({
// either signal says so.
const inFlightWork =
state.hasInFlightWork === true || (hasInFlightWork?.() ?? false);
const awaitingResponse = isAwaitingResponse?.() ?? true;
// Server-continued runs are recovered by the SERVER (chained continuation
// chunks + lost-handoff sweep); an automatic client abort would kill a live
// server-chained run. Auto-retry is therefore disabled unconditionally for
Expand Down Expand Up @@ -261,6 +278,9 @@ export function RunStuckBanner({
// A live tool/A2A call in flight — never auto-abort real work (see
// `inFlightWork` comment above).
inFlightWork ||
// Nothing is waiting on this run here; re-prompting would restart a
// thread the user already considers finished.
!awaitingResponse ||
!state.isStuck ||
!state.runId ||
busy.type !== "none" ||
Expand All @@ -274,21 +294,26 @@ export function RunStuckBanner({
autoRetriedRunIdsRef.current.add(runId);
if (!claimed) return;
setBusy({ type: "retry", runId });
setAutoRetriedRunId(runId);
trackEvent("agent_chat_stuck_auto_retry", {
runId,
threadId: threadId ?? null,
stuckSinceMs: state.stuckSinceMs ?? null,
});
void abortRun(runId, "auto_stuck_retry").then((aborted) => {
if (aborted) {
onRetry?.(aborted);
return;
}
// Release the controls on BOTH outcomes. Leaving them spinning until
// polling observes a new run id assumes the retry produces one; when
// the abort does not move the run off `running`, the banner keeps
// rendering with Retry and Cancel permanently disabled and the user
// has no way out but a reload. Re-entry is already blocked by
// `autoRetriedRunIdsRef`, so clearing here cannot re-trigger the
// automatic attempt.
setBusy((current) =>
current.type !== "none" && current.runId === runId
? { type: "none" }
: current,
);
if (aborted) onRetry?.(aborted);
});
});
}, [
Comment thread
builder-io-integration[bot] marked this conversation as resolved.
Expand All @@ -304,17 +329,20 @@ export function RunStuckBanner({
state.runId,
state.stuckSinceMs,
threadId,
awaitingResponse,
]);

// A stale progress timestamp is not actionable while the server is still
// heartbeating or an unresolved tool/A2A call is known to be running. Keep
// those healthy long-running states in the chat's inline activity UI; the
// warning is reserved for runs that may actually need recovery.
// warning is reserved for runs that may actually need recovery — and only
// while this chat is still waiting for one (see `isAwaitingResponse`).
if (
!state.isStuck ||
!state.runId ||
backgroundWorkerStillAlive ||
inFlightWork
inFlightWork ||
!awaitingResponse
) {
return null;
}
Expand Down Expand Up @@ -380,7 +408,7 @@ export function RunStuckBanner({
No progress
{stuckSeconds != null ? ` for ${stuckSeconds}s` : ""}. The agent may
have hit a server timeout or lost its connection.
{autoRetry && busyType === "retry"
{autoRetry && autoRetriedRunId === state.runId

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Clear the automatic retry message before manual retry

When an automatic retry settles without changing the run ID, autoRetriedRunId remains set. A subsequent manual Retry therefore still renders “Retrying automatically now.” while the user’s request is running; clear the marker in the manual handler or gate this message on the automatic busy state.

Fix in Builder

? " Retrying automatically now."
: ""}
</span>
Expand Down
Loading
Loading