Files
PaperClipAI/ui/src/components/IssueMonitorBanner.test.tsx
T
DottaandPaperclip 2904a3a6cc fix(ui): hide retry countdown after execution starts (#13258)
## Thinking Path

> - Paperclip helps operators manage AI agents and their tasks.
> - Task pages show countdowns for deferred checks and automatic
retries.
> - A retry keeps its scheduled start time after it enters the queue or
starts running.
> - The countdown treated that historical time as a pending deadline and
showed an overdue warning beside active work.
> - This pull request limits retry countdowns to retries that are still
scheduled and hides waiting surfaces on terminal tasks.
> - Operators now see a warning only when the displayed retry is still
waiting to start.

## Linked Issues or Issue Description

Refs #9783, which added the monitor surfaces. Searched related PRs and
issues; no duplicate fix was found.

**What happened?**

After a service restart resumed a task through an automatic retry, the
task showed an overdue retry banner while the agent was running. The
banner also remained when the task became done.

**Expected behavior**

A queued or running retry must not show a countdown against its past
scheduled start time. Done and cancelled tasks must not show waiting
banners.

**Steps to reproduce**

1. Open a task with an automatic retry scheduled for a known time.
2. Let the retry enter the queue and start running.
3. Wait until its scheduled time is more than one minute in the past.
4. Observe the overdue banner and Check now button while the agent is
working.

**Paperclip version or commit**

Reproduced on source commit 847d00bdc3.

**Deployment mode**

Self-hosted server built from source. This is a core UI bug.

## What Changed

- Derive a retry countdown only when the retry status is
`scheduled_retry`.
- Ignore retained retry times for queued, running, and cancelled
retries.
- Hide waiting banners for done and cancelled tasks.
- Keep a separate scheduled monitor visible on an open task.
- Add state and rendered-transition regression tests. Document the
display rule.
- Stub the process start time in one restart-recovery test. CI exposed
that the fixture read real host metadata for its fake PID.

## Verification

- Focused monitor tests: 34 passed on the rebased commit.
- Token gates passed on the rebased commit.
- `pnpm -r typecheck` and `pnpm build` passed on the rebased commit. The
full local test run was stopped after embedded PostgreSQL failed to load
missing macOS library aliases. After repairing the local dependency
aliases, the 11-test native status corpus passed. CI exposed an
unrelated restart-recovery fixture that read real host process metadata.
A test-only fix removes that host dependency. All 31 CI checks passed on
the final commit; the two optional Storybook jobs were skipped.
- The runner transport test that timed out in the first CI run passed
locally: 2 tests passed. The final CI Build job passed.
- Restart-recovery fixture suite: 20 tests passed after the test-only
change.
- Greptile reviewed final commit
`b815ac97d36cfcb6d09f31c852465b6474f78fea`: 5/5, with no open review
threads.
- The rendering regression checks waiting, queued, running, rescheduled,
and done states. It also checks that hidden surfaces remove their
buttons and countdown timers.

## Risks

Low risk. This changes display state only. It does not change retry
dispatch, monitor scheduling, database records, or API contracts. A
retry that is still scheduled retains its overdue warning.

## Model Used

OpenAI GPT-6 through Codex. Used reasoning, repository inspection, code
editing, browser inspection, and test execution. The exact runtime model
identifier and context window size are not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-11 15:05:07 -05:00

235 lines
8.1 KiB
TypeScript

// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import type { Issue } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
IssueMonitorBanner,
IssueMonitorComposerStrip,
buildMonitorSurfaceCopy,
hasVisibleMonitorSurface,
} from "./IssueMonitorBanner";
import type { DerivedMonitorState } from "@/lib/issue-monitor";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
const NOW = new Date("2026-07-17T20:00:00.000Z");
function derived(overrides: Partial<DerivedMonitorState> & { state: DerivedMonitorState["state"] }): DerivedMonitorState {
return {
source: "monitor",
nextCheckAt: null,
attemptCount: 0,
serviceName: null,
...overrides,
};
}
describe("buildMonitorSurfaceCopy", () => {
it("leads with two-unit relative time while scheduled", () => {
const copy = buildMonitorSurfaceCopy(
derived({
state: "scheduled",
nextCheckAt: new Date(NOW.getTime() + (2 * 60 + 12) * 60_000).toISOString(),
attemptCount: 1,
serviceName: "vercel-deploy",
}),
NOW,
);
expect(copy).not.toBeNull();
expect(copy!.bannerTitle).toBe("Waiting on monitor — resumes in 2h 12m");
expect(copy!.stripTitle).toBe("Resumes in 2h 12m");
expect(copy!.tone).toBe("info");
expect(copy!.bannerMeta).toContain("Attempt 1");
expect(copy!.bannerMeta).toContain("Watching: vercel-deploy");
// Absolute time carries the "(your time)" hint on the banner only.
expect(copy!.bannerMeta.some((piece) => piece.includes("(your time)"))).toBe(true);
expect(copy!.stripMeta.some((piece) => piece.includes("(your time)"))).toBe(false);
});
it("keeps the retrying attempt count visible", () => {
const copy = buildMonitorSurfaceCopy(
derived({
state: "retrying",
nextCheckAt: new Date(NOW.getTime() + 90 * 60_000).toISOString(),
attemptCount: 3,
}),
NOW,
);
expect(copy!.stripTitle).toBe("Resumes in 1h 30m");
expect(copy!.stripMeta).toContain("Attempt 3");
});
it("uses agent copy for scheduled retries without a monitor", () => {
const copy = buildMonitorSurfaceCopy(
derived({
state: "retrying",
source: "scheduled-retry",
nextCheckAt: new Date(NOW.getTime() + 90 * 60_000).toISOString(),
attemptCount: 2,
}),
NOW,
);
expect(copy!.bannerTitle).toBe("Agent resumes in 1h 30m");
expect(copy!.stripTitle).toBe("Resumes in 1h 30m");
});
it("switches copy for due-now and overdue states", () => {
const dueNow = buildMonitorSurfaceCopy(
derived({ state: "due-now", nextCheckAt: NOW.toISOString(), attemptCount: 1 }),
NOW,
);
expect(dueNow!.bannerTitle).toBe("Waiting on monitor — due now");
expect(dueNow!.stripTitle).toBe("Due now");
expect(dueNow!.bannerMeta).toContain("Checking momentarily…");
expect(dueNow!.tone).toBe("info");
const overdue = buildMonitorSurfaceCopy(
derived({
state: "overdue",
nextCheckAt: new Date(NOW.getTime() - 18 * 60_000).toISOString(),
attemptCount: 2,
}),
NOW,
);
expect(overdue!.bannerTitle).toBe("Waiting on monitor — overdue by 18m");
expect(overdue!.stripTitle).toBe("Overdue by 18m");
expect(overdue!.bannerMeta).toContain("Fires on next tick");
expect(overdue!.tone).toBe("warning");
});
it("hides both surfaces when cleared, none, or without a next check", () => {
expect(buildMonitorSurfaceCopy(derived({ state: "cleared", attemptCount: 2 }), NOW)).toBeNull();
expect(buildMonitorSurfaceCopy(derived({ state: "none" }), NOW)).toBeNull();
// A "scheduled" state with no timestamp cannot render an ETA — hide.
expect(buildMonitorSurfaceCopy(derived({ state: "scheduled", nextCheckAt: null }), NOW)).toBeNull();
});
});
describe("IssueMonitorBanner / IssueMonitorComposerStrip rendering", () => {
let container: HTMLDivElement;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
vi.useRealTimers();
container.remove();
});
function issueWithMonitor(nextCheckAt: string | null): Issue {
return {
executionState: nextCheckAt
? { monitor: { status: "scheduled", nextCheckAt, attemptCount: 1, serviceName: "vercel-deploy" } }
: null,
scheduledRetry: null,
} as unknown as Issue;
}
it("renders the banner with a working Check now button while waiting", () => {
const onCheckNow = vi.fn();
expect(hasVisibleMonitorSurface(issueWithMonitor(new Date(NOW.getTime() + 2 * 60 * 60_000).toISOString()))).toBe(true);
const root = createRoot(container);
flushSync(() => {
root.render(
<IssueMonitorBanner
issue={issueWithMonitor(new Date(NOW.getTime() + 2 * 60 * 60_000).toISOString())}
onCheckNow={onCheckNow}
/>,
);
});
expect(container.textContent).toContain("Waiting on monitor — resumes in 2h");
expect(container.textContent).toContain("Watching: vercel-deploy");
const button = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.includes("Check now"),
);
expect(button).toBeTruthy();
flushSync(() => button?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onCheckNow).toHaveBeenCalledTimes(1);
flushSync(() => root.unmount());
});
it("hides the banner and strip when there is no monitor", () => {
expect(hasVisibleMonitorSurface(issueWithMonitor(null))).toBe(false);
const root = createRoot(container);
flushSync(() => {
root.render(
<>
<IssueMonitorBanner issue={issueWithMonitor(null)} onCheckNow={vi.fn()} />
<IssueMonitorComposerStrip issue={issueWithMonitor(null)} onCheckNow={vi.fn()} />
</>,
);
});
expect(container.textContent).toBe("");
flushSync(() => root.unmount());
});
it("renders the composer strip with the reply-wakes-agent hint", () => {
const root = createRoot(container);
flushSync(() => {
root.render(
<IssueMonitorComposerStrip
issue={issueWithMonitor(new Date(NOW.getTime() + 2 * 60 * 60_000).toISOString())}
onCheckNow={vi.fn()}
/>,
);
});
expect(container.querySelector("[data-testid='issue-monitor-composer-strip']")).toBeTruthy();
expect(container.textContent).toContain("Resumes in 2h");
expect(container.textContent).toContain("Sending a reply wakes the agent now");
flushSync(() => root.unmount());
});
it("removes both countdowns and Check now when the retry starts, then shows a newly scheduled retry", () => {
const root = createRoot(container);
const issue = {
status: "in_progress",
scheduledRetry: {
status: "scheduled_retry",
scheduledRetryAt: new Date(NOW.getTime() - 2 * 60_000).toISOString(),
scheduledRetryAttempt: 1,
},
} as Issue;
const render = (next: Issue) => flushSync(() => root.render(
<>
<IssueMonitorBanner issue={next} onCheckNow={vi.fn()} />
<IssueMonitorComposerStrip issue={next} onCheckNow={vi.fn()} />
</>,
));
render(issue);
expect(container.textContent).toContain("Overdue by 2m");
for (const status of ["queued", "running"] as const) {
const promoted = { ...issue, scheduledRetry: { ...issue.scheduledRetry!, status } };
render(promoted);
expect(hasVisibleMonitorSurface(promoted)).toBe(false);
expect(container.textContent).toBe("");
expect(container.querySelector("button")).toBeNull();
expect(vi.getTimerCount()).toBe(0);
}
render({ ...issue, scheduledRetry: { ...issue.scheduledRetry!, scheduledRetryAt: new Date(NOW.getTime() + 5 * 60_000).toISOString() } });
expect(container.textContent).toContain("Resumes in 5m");
render({ ...issue, status: "done" });
expect(container.textContent).toBe("");
expect(vi.getTimerCount()).toBe(0);
flushSync(() => root.unmount());
});
});