mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
## Thinking Path > - Paperclip manages AI agents and their work. > - A task must keep a durable path to its next action. > - A provider can save a task monitor, then fail to yield because it omits internal continuation fields. > - An interrupted provider-pack check can then be reported as permanent artifact incompatibility. > - The task asks its user to certify recovered workspace files, even though this is an operator responsibility. > - This pull request derives the saved monitor continuation, bounds verification retries, and removes that task form. > - Normal recovery stays quiet. Unresolved holds retain inspection and the existing safety checks. ## Linked Issues or Issue Description **What happened?** A yielded completion without `continuation` is rejected even after the provider saves an eligible monitor on the task. Repeated attempts cannot finish. Remote verification also treats timeouts and missing exit status as artifact incompatibility. The task recovery notice exposes raw diagnostics and asks the task user to certify infrastructure files. **Expected behavior** Use current persisted task state to fill routine continuation fields. Retry interrupted verification reads within a fixed bound. Keep normal recovery in the working state. Show a compact notice only when work cannot safely continue. **Steps to reproduce** 1. Save an eligible monitor on the active task. 2. Call `paperclip_finish` with `reportedWorkDisposition: "yielded"` and omit `continuation`. 3. Return a timed-out remote provider-pack command with empty stderr during recovery. Inspect the resulting task notice. **Paperclip version or commit** Source baseline: `aaeff418cf` (master integrated with its current task visibility and saved-source reconstruction rules). Related changes: #15824, #16104, #16451, #16493, #16690. This fix complements saved-result recovery and verified pack reuse. It does not replace their workspace ownership checks. ## What Changed - Allow omitted or null continuation at the provider input boundary. Read current authorized task context and fill a monitor continuation only for the bound run and an eligible saved monitor. Keep accepted results strict. Apply this to Codex, OpenCode, both direct and production-sidecar ACPX paths, and external drivers. Reserve the semantic call identity during the saved-context read and reject cancellation or a changed run/turn. - Bind the shared completion change and continuation helper in a new Copilot v21 source declaration. Preserve v20 history and its decoder support. Qualification stays pending. - Expose the eligible continuation in task context. Completion admission still checks persisted monitor state. No schedule is created or renewed by the default. - Retry read-only provider verification at most twice per command. Hold exhausted reads with a distinct verification-unavailable cause. Reattachment cannot renew the read budget through an unchanged provider-attempt counter. Keep completed verification failures and wrong versions incompatible. - Remove the task file-certification form. Hide the notice only for a matching durable recovery path without an unknown-action replay hold. Keep missed retries, unsafe outcomes, and unresolved file holds visible. - Add production-component stories for automatic recovery, blocked and expired holds, reader access, persisted repair decisions, source reconstruction, queued continuation, and mobile. Preserve master’s guarded saved-source reconstruction choice after confirmed source destruction. Update the execution contract and access maps. ## Verification - Completion, helper, Codex, OpenCode, ACPX, and profile regressions pass. After the sidecar review fix, 271 tests across the production sidecar and three provider suites pass, plus 72 helper/profile tests. The real sidecar MCP bridge tests cover saved-monitor acceptance, no-monitor refusal, cancellation, duplicate call identity, and raw-versus-forwarded semantic receipt hashes. The OpenCode tests also cover eligible-monitor acceptance and refusal without one. - The fresh full native executor suite passes all 743 tests. Four profile, installation-integrity, and ACPX driver suites pass all 168 tests. The earlier CI fingerprint failure required a new immutable source declaration; its follow-up Docker failure required the matching context allowlist entry. Both fixes are verified locally. Targeted server monitor, tool authority, and provider verification suites pass. The probe tests include a real timed-out subprocess, an unrelated responsive timer, transport errors, and completed integrity rejections. - All 90 tests in the merged task notice, reconstruction choice, recovery display, and lineage suites pass. They cover reader access, live/missed/mismatched recovery, unknown outcomes, saved messages, retry refusal, and removal of the file form. Both review findings have failing-baseline/passing-treatment regressions: identical polling across retry expiry, and a failed refresh after cached running activity. - Browser walkthrough passed for automatic recovery, unresolved details, a 390px mobile story, and the merged saved-source reconstruction and queued states. The reconstruction control starts disabled and normal automatic recovery shows only Working. The stories use production components and fixture API data. They do not prove live cloud recovery. - Token gates, map checks, and Storybook catalog generation pass. The exact Docker context integrity command passes locally after adding the new build declaration to its allowlist. - Workspace typecheck and build pass on the final source, including the versioned identity update. The final server and UI guards pass their focused tests, typechecks, builds, and token gates. The final standalone Storybook builds. The saved-monitor default retains master’s stricter child-task visibility filter. Re-review caught an additional production-sidecar validator before forwarding; the fix now defaults at that boundary and has passing regression controls. After preserving master’s new reconstruction capability, 68 targeted reconstruction, tool-authority, and provider-verification server tests pass. The current-master integration at `2a80050a0d` has a fresh 5/5 review with zero unresolved threads. Its complete CI matrix, including Runner tests, is the merge acceptance record. - On the final master integration, local typecheck, build, and all 90 recovery UI tests pass. A concurrent runner run hit five 5-second sidecar startup timeouts; an isolated sidecar run passed 86 tests and timed out in the unrelated unsupported-bootstrap case. Those local deadline failures remain recorded; the canonical CI Runner lanes use their normal deadlines and pass. The unsharded local full-suite attempt did not complete. It spanned source edits; the fresh native executor run above passes. An unrelated workspace fixture timed out once and then failed to reserve 32 contiguous ports on this host. Its unchanged-base control and CI server suite pass. Full-suite acceptance uses the fresh latest-head CI matrix; this PR does not claim a passing unsharded local run. ## Risks The default applies only to an eligible persisted monitor. An explicit invalid continuation still fails validation. The added read occurs only when a yielded report omits its continuation. Admission rechecks current state after that read. An interrupted verification command can add one extra 30-second probe. Completed rejections stay closed. These probes do not change the pack, launch provider work, or replay external tools. Two inconclusive reads hold the original run. They do not schedule another reattachment with an unchanged provider-attempt counter. This PR’s pending Copilot v21 declaration binds the changed shared contract, resolver, direct driver, and production sidecar sources. The previous v20 declaration is immutable and remains decodable, but launch requires the new exact identity. Qualification remains pending. Task users lose the file-certification form. Board-authorized source-only repair remains available through the resolution API with the original evidence and stop-receipt requirements. This branch does not deploy or recover historical held runs. > This is a bug fix and UI polish within existing recovery behavior. ROADMAP.md was checked. ## Model Used OpenAI Codex, `gpt-6.1-sol`, xhigh reasoning, with tool use and code execution. The host does not expose the context-window limit. ## 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 (changed-area suites; the full local run limitation is recorded above) - [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>
334 lines
23 KiB
TypeScript
334 lines
23 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { act, type AnchorHTMLAttributes } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { ExecutionBlockerNotice } from "./ExecutionBlockerNotice";
|
|
import { agentsApi } from "../api/agents";
|
|
import { activityApi } from "../api/activity";
|
|
import { issuesApi } from "../api/issues";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
vi.mock("../lib/router", () => ({
|
|
Link: ({ to, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }) => <a href={to} {...props} />,
|
|
}));
|
|
vi.mock("../api/agents", () => ({ agentsApi: { retryFailedRun: vi.fn() } }));
|
|
vi.mock("../api/activity", () => ({ activityApi: { runsForIssue: vi.fn() } }));
|
|
vi.mock("../api/issues", () => ({ issuesApi: { recoveryActions: vi.fn(), listAttachments: vi.fn(), reconstructWorkspace: vi.fn() } }));
|
|
|
|
describe("stopped task recovery notice", () => {
|
|
let root: Root;
|
|
let container: HTMLDivElement;
|
|
let client: QueryClient;
|
|
const onRetried = vi.fn();
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
vi.mocked(issuesApi.recoveryActions).mockResolvedValue({ active: null, actions: [] });
|
|
vi.mocked(issuesApi.listAttachments).mockResolvedValue([]);
|
|
container = document.createElement("div"); document.body.append(container);
|
|
root = createRoot(container);
|
|
client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
|
vi.mocked(activityApi.runsForIssue).mockResolvedValue([{ runId: "failed-run", agentId: "agent", status: "failed" }] as never);
|
|
await act(async () => root.render(<QueryClientProvider client={client}>
|
|
<ExecutionBlockerNotice companyId="company" issueId="task" onRetried={onRetried} blocker={{
|
|
recoveryActionId: "recovery", runId: "failed-run", agentId: "agent", cause: "legacy_execution_requires_reconciliation",
|
|
nextAction: "Automatic recovery stopped. Recorded work is preserved; actions with unverified outcomes will not be repeated.",
|
|
}} />
|
|
</QueryClientProvider>));
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
|
|
});
|
|
afterEach(async () => { await act(async () => root.unmount()); client.clear(); container.remove(); });
|
|
it("keeps the recovery guidance and an inspection link alongside Retry", () => {
|
|
const notice = container.querySelector('[role="status"][aria-label="Task recovery"]')!;
|
|
expect(notice.textContent).toContain("Recorded work is preserved");
|
|
expect(notice.textContent).toContain("Retry");
|
|
expect(notice.classList.contains("task-context-notice")).toBe(true);
|
|
expect(notice.querySelector("a")?.getAttribute("href")).toBe("/agents/agent/runs/failed-run");
|
|
});
|
|
it.each([false, true])("explains cancelled runs and saved input; continue eligibility %s", async canContinue => {
|
|
await act(async () => root.render(<QueryClientProvider client={client}>
|
|
<ExecutionBlockerNotice companyId="company" issueId="task" onRetried={onRetried} blocker={{
|
|
recoveryActionId: "recovery", runId: "cancelled-run", agentId: "agent",
|
|
cause: "legacy_execution_requires_reconciliation", runStatus: "cancelled",
|
|
runError: "Provider cancelled execution", savedMessageCount: 2, canContinue,
|
|
nextAction: canContinue ? "Verify provider shutdown before continuing."
|
|
: "Inspect the run before sending a new message to request continuation.",
|
|
}} />
|
|
</QueryClientProvider>));
|
|
expect(container.textContent).toContain("Provider cancelled execution");
|
|
expect(container.textContent).toContain("2 saved messages are waiting");
|
|
expect(container.querySelector("a")?.getAttribute("href")).toContain("cancelled-run");
|
|
expect(container.querySelector("button")?.textContent ?? null).toBe(canContinue ? "Continue" : null);
|
|
if (!canContinue) expect(container.textContent).toContain("sending a new message to request continuation");
|
|
if (canContinue) {
|
|
vi.mocked(agentsApi.retryFailedRun).mockResolvedValue({} as never);
|
|
await act(async () => container.querySelector<HTMLButtonElement>("button")!.click());
|
|
expect(agentsApi.retryFailedRun).toHaveBeenCalledWith("agent", "cancelled-run", "company");
|
|
}
|
|
});
|
|
it("keeps removed-chat guidance without suggesting a message to an unavailable destination", async () => {
|
|
await act(async () => root.render(<QueryClientProvider client={client}>
|
|
<ExecutionBlockerNotice companyId="company" issueId="task" onRetried={onRetried} blocker={{
|
|
recoveryActionId: "recovery", runId: "cancelled-run", agentId: "agent",
|
|
cause: "legacy_execution_requires_reconciliation", runStatus: "cancelled", canContinue: false,
|
|
nextAction: "This chat connection was removed. Inspect the stopped run and create a new task to continue the work.",
|
|
}} />
|
|
</QueryClientProvider>));
|
|
expect(container.textContent).toContain("create a new task");
|
|
expect(container.textContent).not.toContain("sending a new message");
|
|
expect(container.querySelector("button")).toBeNull();
|
|
expect(container.querySelector("a")?.textContent).toBe("Inspect run");
|
|
});
|
|
it("keeps the required next action for other reconciliation causes", async () => {
|
|
await act(async () => root.render(<QueryClientProvider client={client}>
|
|
<ExecutionBlockerNotice companyId="company" issueId="task" onRetried={onRetried} blocker={{
|
|
recoveryActionId: "recovery", runId: "failed-run", agentId: "agent", cause: "action_outcome_unknown",
|
|
nextAction: "Verify the external action outcome before continuing.",
|
|
}} />
|
|
</QueryClientProvider>));
|
|
expect(container.textContent).toContain("Verify the external action outcome before continuing.");
|
|
expect(container.textContent).not.toContain("Automatic recovery of this task stopped.");
|
|
});
|
|
it.each(["native_continuation_requires_reconciliation", "native_session_cleanup_quarantined"])("links to the source run instead of offering a retry rejected by %s", async (cause) => {
|
|
await act(async () => root.render(<QueryClientProvider client={client}>
|
|
<ExecutionBlockerNotice companyId="company" issueId="task" onRetried={onRetried} blocker={{
|
|
recoveryActionId: "recovery", runId: "failed-run", agentId: "agent",
|
|
cause,
|
|
nextAction: "Inspect the original failure and reconcile the previous execution before continuing.",
|
|
}} />
|
|
</QueryClientProvider>));
|
|
expect(container.textContent).toContain("Work paused.");
|
|
expect(container.textContent).not.toContain("Retry");
|
|
const link = container.querySelector("a")!;
|
|
expect(link.textContent).toBe("Inspect run");
|
|
expect(link.getAttribute("href")).toBe("/agents/agent/runs/failed-run");
|
|
expect(agentsApi.retryFailedRun).not.toHaveBeenCalled();
|
|
});
|
|
it("keeps workspace repair guidance and inspection without offering the legacy Retry fallback", async () => {
|
|
await act(async () => root.render(<QueryClientProvider client={client}>
|
|
<ExecutionBlockerNotice companyId="company" issueId="task" issueStatus="blocked" canManage onRetried={onRetried} blocker={{
|
|
recoveryActionId: "repair", runId: "failed-run", agentId: "agent", cause: "legacy_execution_requires_reconciliation",
|
|
workspaceRepairRequired: true, canRecordWorkspaceRepair: true, canRetry: false, canContinue: false,
|
|
nextAction: "The original sandbox is retained. Recover the missing files and record workspace repair evidence.",
|
|
}} />
|
|
</QueryClientProvider>));
|
|
expect(container.textContent).toContain("The workspace needs operator attention");
|
|
// The hold also covers unsafe archives and local runs, so the notice must not promise retries or a kept sandbox.
|
|
expect(container.textContent).not.toContain("retrying");
|
|
expect(container.textContent).not.toContain("no work is lost");
|
|
expect(container.querySelector("details")?.textContent).toContain("Recover the missing files");
|
|
expect(container.querySelector("button")).toBeNull();
|
|
expect(container.querySelector("textarea, input")).toBeNull();
|
|
expect(container.querySelector("a")?.textContent).toBe("Inspect run");
|
|
expect(agentsApi.retryFailedRun).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([false, true])("preserves source reconstruction and its queued state: %s", async pending => {
|
|
await act(async () => root.render(<QueryClientProvider client={client}>
|
|
<ExecutionBlockerNotice companyId="company" issueId="task" canManage issueStatus="blocked" onRetried={onRetried} blocker={{
|
|
recoveryActionId: "recovery", runId: "failed-run", agentId: "agent", cause: "native_workspace_sync_out_unrecoverable",
|
|
workspaceRepairRequired: true, canReconstructWorkspace: !pending, workspaceReconstructionPending: pending,
|
|
workspaceRecoveryStatusVersion: 1, nextAction: pending ? "Continuation from saved sources is queued and waiting for execution checks." : "Inspect surviving source attachments.",
|
|
}} />
|
|
</QueryClientProvider>));
|
|
expect(container.textContent).toContain(pending ? "Continuation queued." : "Continue from saved sources");
|
|
expect(container.textContent).not.toContain(pending ? "Work paused." : "recovered workspace files");
|
|
expect(container.querySelector("summary")?.closest("details")?.open).toBe(false);
|
|
expect(issuesApi.reconstructWorkspace).not.toHaveBeenCalled();
|
|
if (pending) {
|
|
expect(container.querySelector("textarea")).toBeNull();
|
|
expect(issuesApi.listAttachments).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it.each(["running", "missed", "wrong-run", "replay-blocked", "delegated"])("uses observed recovery liveness, including for readers: %s", async state => {
|
|
const action = { id: "recovery", kind: "active_run_watchdog", status: "active", ownerType: "agent",
|
|
evidence: { runId: state === "wrong-run" ? "other-run" : "failed-run",
|
|
automaticRecovery: { replay: state === "replay-blocked" ? "blocked" : "held" } },
|
|
attemptCount: 1, maxAttempts: 3, outcome: state === "delegated" ? "delegated" : null,
|
|
wakePolicy: state === "delegated" ? null : { kind: "resume_native_run", runId: "failed-run", attempt: 1, maxAttempts: 3, notBefore: "2020-01-01T00:00:00Z" },
|
|
nativeRunActivity: state === "missed" ? null : { runId: "failed-run", status: "running" },
|
|
};
|
|
vi.mocked(issuesApi.recoveryActions).mockResolvedValue({ active: action, actions: [action] } as never);
|
|
await act(async () => root.render(<QueryClientProvider client={client}>
|
|
<ExecutionBlockerNotice companyId="company" issueId="live-task" issueStatus="blocked" canManage={false} onRetried={onRetried} blocker={{
|
|
recoveryActionId: "recovery", runId: "failed-run", agentId: "agent", workspaceRepairRequired: true,
|
|
cause: "native_continuation_requires_reconciliation", nextAction: "Operator inspection is required.",
|
|
}} />
|
|
</QueryClientProvider>));
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
|
|
expect(Boolean(container.querySelector('[aria-label="Task recovery"]'))).toBe(state !== "running");
|
|
});
|
|
|
|
it("retries the exact failed run and refreshes the task", async () => {
|
|
vi.mocked(agentsApi.retryFailedRun).mockResolvedValue({} as never);
|
|
await act(async () => container.querySelector<HTMLButtonElement>("button")!.click());
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
|
|
expect(agentsApi.retryFailedRun).toHaveBeenCalledWith("agent", "failed-run", "company");
|
|
expect(onRetried).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("reveals a missed retry when every recovery poll returns identical data", async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-10-18T00:00:00Z"));
|
|
try {
|
|
const action = { id: "recovery", kind: "active_run_watchdog", status: "active", ownerType: "agent", outcome: null,
|
|
evidence: { runId: "failed-run" }, attemptCount: 1, maxAttempts: 3,
|
|
wakePolicy: { kind: "resume_native_run", runId: "failed-run", attempt: 1, maxAttempts: 3,
|
|
notBefore: "2026-10-18T00:00:10Z" }, nativeRunActivity: null };
|
|
const response = { active: action, actions: [action] };
|
|
vi.mocked(issuesApi.recoveryActions).mockResolvedValue(response as never);
|
|
await act(async () => root.render(<QueryClientProvider client={client}>
|
|
<ExecutionBlockerNotice companyId="company" issueId="expiry-task" issueStatus="blocked" onRetried={onRetried} blocker={{
|
|
recoveryActionId: "recovery", runId: "failed-run", agentId: "agent",
|
|
cause: "native_continuation_requires_reconciliation", nextAction: "Inspect the missed recovery.",
|
|
}} />
|
|
</QueryClientProvider>));
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(1); });
|
|
expect(container.querySelector('[aria-label="Task recovery"]')).toBeNull();
|
|
const before = vi.mocked(issuesApi.recoveryActions).mock.calls.length;
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(41_000); });
|
|
expect(vi.mocked(issuesApi.recoveryActions).mock.calls.length).toBeGreaterThan(before);
|
|
expect(container.querySelector('[aria-label="Task recovery"]')).not.toBeNull();
|
|
expect(container.textContent).toContain("Inspect the missed recovery.");
|
|
} finally { vi.useRealTimers(); }
|
|
});
|
|
|
|
it("shows a recovery refresh error instead of trusting cached activity", async () => {
|
|
const action = { id: "recovery", kind: "active_run_watchdog", status: "active", ownerType: "agent", outcome: null,
|
|
evidence: { runId: "failed-run" }, attemptCount: 1, maxAttempts: 3,
|
|
wakePolicy: { kind: "resume_native_run", runId: "failed-run" },
|
|
nativeRunActivity: { runId: "failed-run", status: "running", workspaceOperationId: null } };
|
|
vi.mocked(issuesApi.recoveryActions).mockResolvedValue({ active: action, actions: [action] } as never);
|
|
await act(async () => root.render(<QueryClientProvider client={client}>
|
|
<ExecutionBlockerNotice companyId="company" issueId="read-error-task" issueStatus="blocked" onRetried={onRetried} blocker={{
|
|
recoveryActionId: "recovery", runId: "failed-run", agentId: "agent",
|
|
cause: "native_continuation_requires_reconciliation", nextAction: "Inspect the current run.",
|
|
}} />
|
|
</QueryClientProvider>));
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
|
|
expect(container.querySelector('[aria-label="Task recovery"]')).toBeNull();
|
|
vi.mocked(issuesApi.recoveryActions).mockRejectedValue(new Error("Recovery service unavailable"));
|
|
await act(async () => { await client.refetchQueries({ queryKey: queryKeys.issues.recoveryActions("read-error-task", "recovery", "failed-run") }); });
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
|
|
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Recovery service unavailable");
|
|
expect(container.textContent).toContain("Inspect the current run.");
|
|
});
|
|
|
|
it.each([
|
|
"The 'gpt-6.1-sol' model is not supported when using Codex with a ChatGPT account.",
|
|
"The selected model is not supported by the current ChatGPT connection. Choose a supported model or a compatible AI connection.",
|
|
])("shows the model rejection and repair action without expanding run logs: %s", async (runError) => {
|
|
vi.mocked(activityApi.runsForIssue).mockResolvedValue([{ runId: "model-run", agentId: "agent",
|
|
status: "failed", errorCode: "native_provider_model_rejected" }] as never);
|
|
await act(async () => root.render(<QueryClientProvider client={client}>
|
|
<ExecutionBlockerNotice companyId="company" issueId="model-task" onRetried={onRetried} blocker={{
|
|
recoveryActionId: "recovery", runId: "model-run", agentId: "agent",
|
|
cause: "native_continuation_requires_reconciliation", canRetry: true,
|
|
runError,
|
|
nextAction: "Inspect the original failure before continuing.",
|
|
}} />
|
|
</QueryClientProvider>));
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
|
|
expect(container.textContent).toContain("Model unavailable.");
|
|
expect(container.textContent).toContain(runError);
|
|
expect(container.textContent).toContain("clear the task's model override, then retry");
|
|
expect(container.textContent).toContain("Inspect the original failure before continuing.");
|
|
expect(container.querySelector("button")?.textContent).toBe("Retry");
|
|
});
|
|
|
|
it.each(["native_continuation_requires_reconciliation", "uncertain_external_action"])("offers Retry for a server-admitted native failure: %s", async cause => {
|
|
await act(async () => root.render(<QueryClientProvider client={client}>
|
|
<ExecutionBlockerNotice companyId="company" issueId="task" onRetried={onRetried} blocker={{
|
|
recoveryActionId: "recovery", runId: "failed-run", agentId: "agent", cause,
|
|
canRetry: true,
|
|
nextAction: "Automatic recovery stopped. Try again or send a new message to continue.",
|
|
}} />
|
|
</QueryClientProvider>));
|
|
const button = container.querySelector<HTMLButtonElement>("button");
|
|
expect(button?.textContent).toBe("Retry");
|
|
vi.mocked(agentsApi.retryFailedRun).mockResolvedValue({} as never);
|
|
await act(async () => button!.click());
|
|
expect(agentsApi.retryFailedRun).toHaveBeenCalledWith("agent", "failed-run", "company");
|
|
});
|
|
it("shows a failed Retry in the same container and allows another attempt", async () => {
|
|
vi.mocked(agentsApi.retryFailedRun).mockRejectedValue(new Error("Environment cleanup is still running."));
|
|
await act(async () => container.querySelector<HTMLButtonElement>("button")!.click());
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
|
|
expect(container.querySelector('[role="alert"]')?.textContent).toBe("Environment cleanup is still running.");
|
|
expect(container.querySelector<HTMLButtonElement>("button")!.disabled).toBe(false);
|
|
expect(onRetried).not.toHaveBeenCalled();
|
|
});
|
|
it("shows a settled hold's decision even when there is no active recovery action", async () => {
|
|
vi.mocked(issuesApi.recoveryActions).mockResolvedValue({ active: null, actions: [{
|
|
id: "recovery", status: "resolved", resolutionNote: "The last tool outcome could not be verified.",
|
|
evidence: { runId: "cancelled-run", automaticRecovery: { replay: "blocked" } },
|
|
}] as never });
|
|
await act(async () => root.render(<QueryClientProvider client={client}>
|
|
<ExecutionBlockerNotice companyId="company" issueId="task" issueStatus="blocked" canManage onRetried={onRetried} blocker={{
|
|
recoveryActionId: "recovery", runId: "cancelled-run", agentId: "agent", runStatus: "cancelled",
|
|
cause: "legacy_execution_requires_reconciliation", nextAction: "Send a new message after inspecting the run.",
|
|
}} />
|
|
</QueryClientProvider>));
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
|
|
expect(issuesApi.recoveryActions).toHaveBeenCalledWith("task");
|
|
expect(container.querySelector("details")?.textContent).toContain("The last tool outcome could not be verified.");
|
|
expect(container.querySelector("details")?.textContent).toContain("stopped automatic retries");
|
|
expect(container.querySelector("button")).toBeNull();
|
|
expect(agentsApi.retryFailedRun).not.toHaveBeenCalled();
|
|
});
|
|
it("does not show a different run's historical recovery decision", async () => {
|
|
vi.mocked(issuesApi.recoveryActions).mockResolvedValue({ active: null, actions: [{
|
|
id: "recovery", status: "resolved", resolutionNote: "Old decision",
|
|
evidence: { runId: "another-run", automaticRecovery: { replay: "blocked" } },
|
|
}] as never });
|
|
await act(async () => root.render(<QueryClientProvider client={client}>
|
|
<ExecutionBlockerNotice companyId="company" issueId="task" issueStatus="blocked" canManage onRetried={onRetried} blocker={{
|
|
recoveryActionId: "recovery", runId: "cancelled-run", agentId: "agent",
|
|
cause: "legacy_execution_requires_reconciliation", nextAction: "Inspect the current run.",
|
|
}} />
|
|
</QueryClientProvider>));
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
|
|
expect(container.querySelector("details")).toBeNull();
|
|
expect(container.textContent).not.toContain("Old decision");
|
|
});
|
|
it("reports a recovery history read failure without hiding the current guidance", async () => {
|
|
vi.mocked(issuesApi.recoveryActions).mockRejectedValue(new Error("History unavailable"));
|
|
await act(async () => root.render(<QueryClientProvider client={client}>
|
|
<ExecutionBlockerNotice companyId="company" issueId="task" issueStatus="blocked" canManage onRetried={onRetried} blocker={{
|
|
recoveryActionId: "recovery", runId: "cancelled-run", agentId: "agent",
|
|
cause: "legacy_execution_requires_reconciliation", nextAction: "Inspect the current run.",
|
|
}} />
|
|
</QueryClientProvider>));
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
|
|
expect(container.querySelector('[role="alert"]')?.textContent).toContain("History unavailable");
|
|
expect(container.textContent).toContain("Inspect the current run.");
|
|
});
|
|
it("refreshes a decision while the same stopped run remains open", async () => {
|
|
vi.mocked(issuesApi.recoveryActions).mockClear();
|
|
vi.useFakeTimers();
|
|
try {
|
|
const action = { id: "recovery", status: "active", resolutionNote: "Inspection pending", evidence: { runId: "cancelled-run" } };
|
|
vi.mocked(issuesApi.recoveryActions).mockResolvedValue({ active: null, actions: [action] } as never);
|
|
await act(async () => root.render(<QueryClientProvider client={client}>
|
|
<ExecutionBlockerNotice companyId="company" issueId="task" issueStatus="blocked" canManage onRetried={onRetried} blocker={{
|
|
recoveryActionId: "recovery", runId: "cancelled-run", agentId: "agent",
|
|
cause: "legacy_execution_requires_reconciliation", nextAction: "Inspect the current run.",
|
|
}} />
|
|
</QueryClientProvider>));
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(1); });
|
|
expect(container.textContent).toContain("Inspection pending");
|
|
vi.mocked(issuesApi.recoveryActions).mockResolvedValue({ active: null, actions: [{ ...action,
|
|
status: "resolved", resolutionNote: "Preserved work; automatic replay stopped", evidence: {
|
|
runId: "cancelled-run", automaticRecovery: { replay: "blocked" },
|
|
},
|
|
}] } as never);
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(5_001); });
|
|
expect(issuesApi.recoveryActions).toHaveBeenCalledTimes(2);
|
|
expect(container.textContent).toContain("Preserved work; automatic replay stopped");
|
|
expect(container.textContent).not.toContain("Inspection pending");
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|