mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
Add retry-now action to next-step notice
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
079daa7d66
commit
f640d16607
4 files changed
+199
-4
No files matched your search
@@ -3,10 +3,14 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AnchorHTMLAttributes, ReactElement, ReactNode } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import type { IssueRetryNowOutcome, IssueScheduledRetry } from "@paperclipai/shared";
|
||||
import { IssueBlockedNotice } from "./IssueBlockedNotice";
|
||||
import { ToastProvider } from "../context/ToastContext";
|
||||
|
||||
const retryNowMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }) => (
|
||||
@@ -14,11 +18,57 @@ vi.mock("@/lib/router", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
issuesApi: {
|
||||
retryScheduledRetryNow: retryNowMock,
|
||||
},
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
let dateNowSpy: ReturnType<typeof vi.spyOn> | null = null;
|
||||
|
||||
const SYSTEM_NOW = new Date("2026-04-18T20:00:00.000Z").getTime();
|
||||
|
||||
const baseRetry: IssueScheduledRetry = {
|
||||
runId: "retry-run-1",
|
||||
status: "scheduled_retry",
|
||||
agentId: "agent-1",
|
||||
agentName: "CodexCoder",
|
||||
retryOfRunId: "source-run-1",
|
||||
scheduledRetryAt: "2026-04-19T20:00:00.000Z",
|
||||
scheduledRetryAttempt: 1,
|
||||
scheduledRetryReason: "max_turns_continuation",
|
||||
retryExhaustedReason: null,
|
||||
error: null,
|
||||
errorCode: null,
|
||||
};
|
||||
|
||||
function buildRetryResponse(outcome: IssueRetryNowOutcome) {
|
||||
return {
|
||||
outcome,
|
||||
message:
|
||||
outcome === "promoted"
|
||||
? "Promoted scheduled retry"
|
||||
: outcome === "already_promoted"
|
||||
? "Scheduled retry already promoted"
|
||||
: outcome === "no_scheduled_retry"
|
||||
? "No scheduled retry"
|
||||
: "Promotion suppressed by gate",
|
||||
scheduledRetry:
|
||||
outcome === "promoted" || outcome === "already_promoted"
|
||||
? { ...baseRetry, status: "queued" as const }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(SYSTEM_NOW);
|
||||
retryNowMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
@@ -27,13 +77,22 @@ afterEach(() => {
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
dateNowSpy?.mockRestore();
|
||||
dateNowSpy = null;
|
||||
});
|
||||
|
||||
function withProviders(node: ReactNode) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } } });
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0, staleTime: 0 },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return (
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||
<QueryClientProvider client={client}>
|
||||
<ToastProvider>{node}</ToastProvider>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
@@ -68,10 +127,49 @@ describe("IssueBlockedNotice", () => {
|
||||
expect(node.textContent).toContain("This issue still needs a next step.");
|
||||
expect(node.textContent).toContain("Corrective wake queued for CodexCoder");
|
||||
expect(node.textContent).toContain("Detected progress: Updated the plan");
|
||||
expect(node.textContent).not.toContain("Retry now");
|
||||
expect(node.textContent).not.toContain("Work on this issue is blocked until");
|
||||
expect(node.querySelector('[data-successful-run-handoff="required"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows retry-now action for next-step notices with a scheduled retry", async () => {
|
||||
retryNowMock.mockResolvedValue(buildRetryResponse("promoted"));
|
||||
const node = render(
|
||||
<IssueBlockedNotice
|
||||
issueId="issue-1"
|
||||
issueStatus="in_progress"
|
||||
blockers={[]}
|
||||
agentName="CodexCoder"
|
||||
scheduledRetry={baseRetry}
|
||||
successfulRunHandoff={{
|
||||
state: "required",
|
||||
required: true,
|
||||
sourceRunId: "12345678-aaaa-bbbb-cccc-123456789abc",
|
||||
correctiveRunId: null,
|
||||
assigneeAgentId: "agent-1",
|
||||
detectedProgressSummary: null,
|
||||
createdAt: "2026-05-01T00:00:00.000Z",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(node.textContent).toContain("Corrective wake scheduled in 1d");
|
||||
const button = node.querySelector<HTMLButtonElement>('[data-testid="issue-next-step-retry-now"]');
|
||||
expect(button).not.toBeNull();
|
||||
expect(button!.textContent ?? "").toContain("Retry now");
|
||||
|
||||
await act(async () => {
|
||||
button!.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(retryNowMock).toHaveBeenCalledWith("issue-1");
|
||||
expect(button!.textContent ?? "").toContain("Promoted");
|
||||
expect(button!.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not render when the issue is done even if a stale handoff state is required", () => {
|
||||
const node = render(
|
||||
<IssueBlockedNotice
|
||||
|
||||
@@ -2,12 +2,17 @@ import type {
|
||||
IssueBlockerAttention,
|
||||
IssueRecoveryAction,
|
||||
IssueRelationIssueSummary,
|
||||
IssueScheduledRetry,
|
||||
SuccessfulRunHandoffState,
|
||||
} from "@paperclipai/shared";
|
||||
import { AlertTriangle, Flag } from "lucide-react";
|
||||
import { AlertTriangle, CheckCircle2, Flag, Loader2, RotateCcw } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
|
||||
import { formatMonitorOffset } from "../lib/issue-monitor";
|
||||
import { useRetryNowMutation } from "../hooks/useRetryNowMutation";
|
||||
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
|
||||
import { RetryErrorBand } from "./IssueScheduledRetryCard";
|
||||
import { isAssignedBacklogBlocker } from "../lib/issue-blockers";
|
||||
import {
|
||||
deriveActiveRecoveryDisplayState,
|
||||
@@ -34,22 +39,96 @@ function BlockerRecoveryIndicator({ action }: { action: IssueRecoveryAction }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessfulRunRetryNowControl({
|
||||
issueId,
|
||||
scheduledRetry,
|
||||
}: {
|
||||
issueId: string;
|
||||
scheduledRetry: IssueScheduledRetry;
|
||||
}) {
|
||||
const retryNow = useRetryNowMutation(issueId);
|
||||
const dueAtIso = scheduledRetry.scheduledRetryAt
|
||||
? new Date(scheduledRetry.scheduledRetryAt).toISOString()
|
||||
: null;
|
||||
const relative = dueAtIso ? formatMonitorOffset(dueAtIso) : null;
|
||||
const scheduleLabel = relative === "now"
|
||||
? "due now"
|
||||
: relative
|
||||
? `scheduled ${relative}`
|
||||
: "scheduled";
|
||||
const success = retryNow.isSuccess
|
||||
&& (retryNow.data?.outcome === "promoted" || retryNow.data?.outcome === "already_promoted");
|
||||
|
||||
return (
|
||||
<div className="mt-2 rounded-md border border-amber-300/70 bg-background/80 p-2 dark:border-amber-500/40 dark:bg-background/40">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 text-xs leading-5 text-amber-900 dark:text-amber-100">
|
||||
Corrective wake {scheduleLabel}. Retry now starts the same recovery path immediately.
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 border-amber-300/80 bg-background/80 text-amber-950 shadow-none hover:bg-amber-100 dark:border-amber-500/50 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15"
|
||||
onClick={() => retryNow.mutate()}
|
||||
disabled={retryNow.isPending || success}
|
||||
data-testid="issue-next-step-retry-now"
|
||||
>
|
||||
{retryNow.isPending ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
|
||||
Retrying...
|
||||
</span>
|
||||
) : success ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{retryNow.data?.outcome === "already_promoted" ? "Already promoted" : "Promoted"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Retry now
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<RetryErrorBand
|
||||
error={retryNow.lastError}
|
||||
className="mt-2 border-amber-300/70 bg-amber-100/70 text-amber-950 dark:border-amber-500/40 dark:bg-amber-500/15 dark:text-amber-100"
|
||||
onRetry={() => {
|
||||
retryNow.reset();
|
||||
retryNow.mutate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function IssueBlockedNotice({
|
||||
issueId,
|
||||
issueStatus,
|
||||
blockers,
|
||||
blockerAttention,
|
||||
successfulRunHandoff,
|
||||
scheduledRetry,
|
||||
agentName,
|
||||
}: {
|
||||
issueId?: string | null;
|
||||
issueStatus?: string;
|
||||
blockers: IssueRelationIssueSummary[];
|
||||
blockerAttention?: IssueBlockerAttention | null;
|
||||
successfulRunHandoff?: SuccessfulRunHandoffState | null;
|
||||
scheduledRetry?: IssueScheduledRetry | null;
|
||||
agentName?: string | null;
|
||||
}) {
|
||||
if (issueStatus === "done" || issueStatus === "cancelled") return null;
|
||||
const showSuccessfulRunHandoff = successfulRunHandoff?.required === true;
|
||||
if (!showSuccessfulRunHandoff && blockers.length === 0 && issueStatus !== "blocked") return null;
|
||||
const successfulRunRetryNow = showSuccessfulRunHandoff
|
||||
&& issueId
|
||||
&& scheduledRetry?.status === "scheduled_retry"
|
||||
? { issueId, scheduledRetry }
|
||||
: null;
|
||||
|
||||
const blockerLabel = blockers.length === 1 ? "the linked issue" : "the linked issues";
|
||||
const terminalBlockers = blockers
|
||||
@@ -162,6 +241,12 @@ export function IssueBlockedNotice({
|
||||
Detected progress: {successfulRunHandoff.detectedProgressSummary}
|
||||
</p>
|
||||
) : null}
|
||||
{successfulRunRetryNow ? (
|
||||
<SuccessfulRunRetryNowControl
|
||||
issueId={successfulRunRetryNow.issueId}
|
||||
scheduledRetry={successfulRunRetryNow.scheduledRetry}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{showSuccessfulRunHandoff && (blockers.length > 0 || issueStatus === "blocked") ? (
|
||||
|
||||
@@ -38,6 +38,7 @@ import type {
|
||||
IssueBlockerAttention,
|
||||
IssueRecoveryAction,
|
||||
IssueRelationIssueSummary,
|
||||
IssueScheduledRetry,
|
||||
SuccessfulRunHandoffState,
|
||||
IssueWorkMode,
|
||||
} from "@paperclipai/shared";
|
||||
@@ -296,9 +297,11 @@ interface IssueChatThreadProps {
|
||||
timelineEvents?: IssueTimelineEvent[];
|
||||
liveRuns?: LiveRunForIssue[];
|
||||
activeRun?: ActiveRunForIssue | null;
|
||||
issueId?: string | null;
|
||||
blockedBy?: IssueRelationIssueSummary[];
|
||||
blockerAttention?: IssueBlockerAttention | null;
|
||||
successfulRunHandoff?: SuccessfulRunHandoffState | null;
|
||||
scheduledRetry?: IssueScheduledRetry | null;
|
||||
recoveryAction?: IssueRecoveryAction | null;
|
||||
onResolveRecoveryAction?: (outcome: RecoveryResolveOutcome) => void;
|
||||
canFalsePositiveRecoveryAction?: boolean;
|
||||
@@ -3617,9 +3620,11 @@ export function IssueChatThread({
|
||||
timelineEvents = [],
|
||||
liveRuns = [],
|
||||
activeRun = null,
|
||||
issueId = null,
|
||||
blockedBy = [],
|
||||
blockerAttention = null,
|
||||
successfulRunHandoff = null,
|
||||
scheduledRetry = null,
|
||||
recoveryAction = null,
|
||||
onResolveRecoveryAction,
|
||||
canFalsePositiveRecoveryAction = false,
|
||||
@@ -4299,10 +4304,12 @@ export function IssueChatThread({
|
||||
/>
|
||||
) : null}
|
||||
<IssueBlockedNotice
|
||||
issueId={issueId}
|
||||
issueStatus={issueStatus}
|
||||
blockers={unresolvedBlockers}
|
||||
blockerAttention={blockerAttention}
|
||||
successfulRunHandoff={recoveryAction ? null : successfulRunHandoff}
|
||||
scheduledRetry={scheduledRetry}
|
||||
agentName={
|
||||
successfulRunHandoff?.assigneeAgentId
|
||||
? agentMap?.get(successfulRunHandoff.assigneeAgentId)?.name ?? null
|
||||
|
||||
@@ -617,6 +617,7 @@ type IssueDetailChatTabProps = {
|
||||
blockedBy: Issue["blockedBy"];
|
||||
blockerAttention: Issue["blockerAttention"] | null;
|
||||
successfulRunHandoff: Issue["successfulRunHandoff"] | null;
|
||||
scheduledRetry: Issue["scheduledRetry"] | null;
|
||||
recoveryAction: Issue["activeRecoveryAction"];
|
||||
onResolveRecoveryAction?: (outcome: import("../components/IssueRecoveryActionCard").RecoveryResolveOutcome) => void;
|
||||
canFalsePositiveRecoveryAction?: boolean;
|
||||
@@ -689,6 +690,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
blockedBy,
|
||||
blockerAttention,
|
||||
successfulRunHandoff,
|
||||
scheduledRetry,
|
||||
recoveryAction,
|
||||
onResolveRecoveryAction,
|
||||
canFalsePositiveRecoveryAction,
|
||||
@@ -897,9 +899,11 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
timelineEvents={timelineEvents}
|
||||
liveRuns={resolvedLiveRuns}
|
||||
activeRun={resolvedActiveRun}
|
||||
issueId={issueId}
|
||||
blockedBy={blockedBy ?? []}
|
||||
blockerAttention={blockerAttention}
|
||||
successfulRunHandoff={successfulRunHandoff}
|
||||
scheduledRetry={scheduledRetry}
|
||||
recoveryAction={recoveryAction ?? null}
|
||||
onResolveRecoveryAction={onResolveRecoveryAction}
|
||||
canFalsePositiveRecoveryAction={canFalsePositiveRecoveryAction}
|
||||
@@ -3914,6 +3918,7 @@ export function IssueDetail() {
|
||||
blockedBy={issue.blockedBy ?? []}
|
||||
blockerAttention={issue.blockerAttention ?? null}
|
||||
successfulRunHandoff={issue.successfulRunHandoff ?? null}
|
||||
scheduledRetry={issue.scheduledRetry ?? null}
|
||||
recoveryAction={issue.activeRecoveryAction ?? null}
|
||||
onResolveRecoveryAction={handleResolveRecoveryAction}
|
||||
canFalsePositiveRecoveryAction={canResolveBoardRecoveryAction}
|
||||
|
||||
Reference in new issue
Block a user