mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
Simplify failure banners and show only the latest error (#16982)
Remove dashed failure dividers and show only the latest adjacent run or recovery error. Preserve permitted retries, pending state and unrelated notice boundaries. Add production-component Storybook examples and browser regression coverage. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
cb2d6e80d3
commit
b11010b0d4
14 files changed
+623
-79
No files matched your search
@@ -96,6 +96,14 @@ in the conversation. Time passing or a new human comment alone does not resolve
|
||||
an error. Stored notices need run or recovery provenance before they can be hidden;
|
||||
child-task relays and other unrelated system updates stay visible.
|
||||
|
||||
Interrupted runs use compact rows without divider rules, including
|
||||
older saved markers. When a run error and a no-live-execution-path notice are
|
||||
adjacent, show only the last error row. Keep its details and retry action. If a
|
||||
legacy run has a permitted retry, keep that action on the surviving notice,
|
||||
including its pending state. A
|
||||
message, input, session boundary or credential-access denial separates errors;
|
||||
it must not disappear as part of this grouping. Run details retain full history.
|
||||
|
||||
Credential-sharing denials stay expanded as an error card in the task conversation.
|
||||
Name the agent and selected AI connection when known, and say which person lacks
|
||||
access. Use “you” only for that person’s own run. Link to the agent’s AI connection
|
||||
|
||||
@@ -289,6 +289,20 @@ pnpm build-storybook
|
||||
|
||||
These run the `@paperclipai/ui` Storybook on port `6006` and build the static output to `ui/storybook-static/`.
|
||||
|
||||
**Task chat → Failure notices** covers compact run errors and adjacent recovery
|
||||
notices in both presentation modes, with expanded details, fixture retry
|
||||
callbacks (including the legacy run retry when recovery retry is unavailable),
|
||||
unrelated system notices that quote error text, saved Automatic recovery blocked
|
||||
notices, light mode and mobile. Run the focused browser checks against an
|
||||
owned Storybook server:
|
||||
|
||||
```sh
|
||||
STORYBOOK_TEST_BASE_URL=http://localhost:6006 pnpm exec playwright test failure-notices.spec.ts --config=tests/storybook-visual/playwright.config.ts --project=stories
|
||||
```
|
||||
|
||||
The suite checks Storybook play and accessibility reports and saves screenshots.
|
||||
It does not compare visual baselines or dispatch real agent work.
|
||||
|
||||
**HTML artifacts** covers secure rendered/raw attachment previews, interactive
|
||||
reports, security probes, workspace previews, and opening a report from a task.
|
||||
See [HTML artifact previews](html-artifact-previews.md) for the security boundary,
|
||||
|
||||
@@ -326,6 +326,11 @@ Gaps: Private-task denial must be proved, not invented. Trace/report/export belo
|
||||
|
||||
Capability ID: `tasks/completion`.
|
||||
|
||||
Task chat may group adjacent run and recovery errors for display. The API
|
||||
retains all run and comment records. This presentation does not change retry,
|
||||
approval, checkout or company access. See the
|
||||
[presentation coverage](../feature-map/tasks.md#task-detail).
|
||||
|
||||
Review: `operation-reviewed`.
|
||||
|
||||
Separate status/report acceptance, versioned start, follow-up, checkout/release and deletion; accepted provider report is not final task state.
|
||||
|
||||
@@ -81,6 +81,22 @@ Manual: Filter by status and assignee, change grouping/sort, open a task, and re
|
||||
|
||||
Automated: [task detail](../../ui/src/pages/IssueDetail.test.tsx) covers component behavior, while [issue service](../../server/src/__tests__/issues-service.test.ts) covers server contracts.
|
||||
|
||||
Failure markers have no dashed divider rules. An adjacent run error and
|
||||
no-live-execution-path notice show only the last error, with its details and
|
||||
retry action, including a permitted legacy run retry carried onto a later
|
||||
recovery notice. Intervening messages, unrelated system notices that quote error
|
||||
text, and credential-access denials stay visible. Explicit non-recovery titles
|
||||
stop grouping. Saved stranded-recovery action metadata plus a known recovery
|
||||
statement also identify recovery notices with a default or source-run title.
|
||||
Only known recovery statements use the legacy text fallback.
|
||||
[Grouping tests](../../ui/src/components/task-chat/adjacent-errors.test.ts) and
|
||||
[marker tests](../../ui/src/components/task-chat/TaskChatMarker.test.tsx) cover
|
||||
the presentation. The [Failure notices stories](../../ui/storybook/stories/tasks/failure-notices.stories.tsx)
|
||||
mount the production thread view with fixture data. The focused
|
||||
[browser suite](../../tests/storybook-visual/failure-notices.spec.ts) checks
|
||||
play assertions, disclosure, retry callbacks, themes and phone width. These
|
||||
checks do not dispatch a real retry or change stored run history.
|
||||
|
||||
Automated: [paused assignee relevance](../../tests/e2e/completed-task-paused-assignee.spec.ts)
|
||||
uses a real disposable server and paused process agent, with no inference, to
|
||||
check both task interfaces. Done and cancelled tasks hide the paused-agent
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const stories = [
|
||||
"failed-run", "older-marker", "production-mode", "recovery-is-latest",
|
||||
"failure-is-latest", "expanded-recovery-and-retry", "expanded-failure-and-retry",
|
||||
"message-between-errors", "system-notice-between-errors", "production-system-notice-between-errors",
|
||||
"light", "mobile", "legacy-run-retry", "legacy-production-retry",
|
||||
"automatic-recovery-blocked", "production-automatic-recovery-blocked",
|
||||
];
|
||||
|
||||
for (const theme of ["dark", "light"]) {
|
||||
for (const story of stories) {
|
||||
test(`${story} [${theme}]`, async ({ page }, testInfo) => {
|
||||
if (story === "mobile") await page.setViewportSize({ width: 390, height: 844 });
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
const id = `tasks-failure-notices--${story}`;
|
||||
await page.goto(`/iframe.html?id=${id}&viewMode=story&globals=theme:${theme}`);
|
||||
// Storybook records this after play assertions and accessibility reports.
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const channel = (window as unknown as {
|
||||
__STORYBOOK_ADDONS_CHANNEL__?: { data: Record<string, Array<{ storyId?: string; status?: string }>> };
|
||||
}).__STORYBOOK_ADDONS_CHANNEL__;
|
||||
return channel?.data.storyFinished?.[0]?.status;
|
||||
})).toBe("success");
|
||||
expect(await page.evaluate(() => (window as unknown as {
|
||||
__STORYBOOK_ADDONS_CHANNEL__: { data: Record<string, unknown> };
|
||||
}).__STORYBOOK_ADDONS_CHANNEL__.data.playFunctionThrewException)).toBeUndefined();
|
||||
expect(errors).toEqual([]);
|
||||
await expect(page.locator("body.sb-show-errordisplay, main .border-dashed, main .h-px")).toHaveCount(0);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
if (["recovery-is-latest", "failure-is-latest", "light", "mobile"].includes(story)) {
|
||||
await expect(page.getByRole("button", { name: "Try again" })).toHaveCount(1);
|
||||
await expect(page.getByText(story === "failure-is-latest" ? "No live execution path" : "Run failed", { exact: true })).toHaveCount(0);
|
||||
}
|
||||
if (["expanded-recovery-and-retry", "automatic-recovery-blocked", "production-automatic-recovery-blocked"].includes(story)) {
|
||||
await expect(page.getByRole("status")).toHaveText("Recovery retry requested");
|
||||
}
|
||||
if (["legacy-run-retry", "legacy-production-retry"].includes(story)) {
|
||||
await expect(page.getByRole("status")).toHaveText("Retry requested for failed-run");
|
||||
await expect(page.getByText("Run failed", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Try again" })).toHaveCount(1);
|
||||
}
|
||||
if (story === "expanded-failure-and-retry") {
|
||||
await expect(page.getByRole("status")).toHaveText("Retry requested for failed-run");
|
||||
await expect(page.getByRole("link", { name: "View run" })).toBeVisible();
|
||||
}
|
||||
await page.screenshot({ path: testInfo.outputPath(`${story}-${theme}.png`), fullPage: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2971,12 +2971,21 @@ describe("TaskChatThread runtime transcript selection", () => {
|
||||
cause: "uncertain_provider_action", canRetry: false },
|
||||
{ adapterType: "claude_local", status: "cancelled", runtimeMode: "legacy" as const,
|
||||
cause: "legacy_execution_requires_reconciliation", canRetry: false },
|
||||
])("offers only explicit conversation retries: $runtimeMode/$adapterType/$status/$cause", (testCase) => {
|
||||
].flatMap(testCase => [false, true].map(withRecoveryNotice => ({ ...testCase, withRecoveryNotice }))))("offers only explicit conversation retries: $runtimeMode/$adapterType/$status/$cause (recovery notice=$withRecoveryNotice)", async (testCase) => {
|
||||
const retry = vi.fn();
|
||||
const recoveryRetry = vi.fn();
|
||||
renderThread({
|
||||
comments: [],
|
||||
comments: testCase.withRecoveryNotice ? [{
|
||||
id: "recovery-after-stop", companyId: "company", issueId: "issue", metadata: null,
|
||||
authorType: "system", authorAgentId: null, authorUserId: null,
|
||||
body: "Paperclip retried continuation, but it still has no live execution path.",
|
||||
presentation: { kind: "system_notice", title: "No live execution path", tone: "danger", detailsDefaultOpen: false },
|
||||
createdAt: new Date("2026-08-25T18:00:03.000Z"), updatedAt: new Date("2026-08-25T18:00:03.000Z"),
|
||||
}] : [],
|
||||
onAdd: async () => {},
|
||||
issueStatus: "blocked",
|
||||
onRetryFailedRun: vi.fn(),
|
||||
onRetryFailedRun: retry,
|
||||
onTryAgainNoLiveExecutionPath: recoveryRetry,
|
||||
linkedRuns: [{
|
||||
runId: "stopped-run",
|
||||
runtimeMode: testCase.runtimeMode,
|
||||
@@ -2995,10 +3004,21 @@ describe("TaskChatThread runtime transcript selection", () => {
|
||||
},
|
||||
}],
|
||||
});
|
||||
expect(Boolean(container.querySelector('[data-testid="task-chat-run-failed-try-again"]')))
|
||||
.toBe(testCase.canRetry);
|
||||
if (testCase.canRetry) expect(container.textContent).toContain("You can retry this message now.");
|
||||
const button = container.querySelector<HTMLButtonElement>(testCase.withRecoveryNotice
|
||||
? '[data-testid="task-chat-no-live-path-try-again"]'
|
||||
: '[data-testid="task-chat-run-failed-try-again"]');
|
||||
expect(Boolean(button)).toBe(testCase.canRetry);
|
||||
if (testCase.withRecoveryNotice) {
|
||||
expect(container.textContent).toContain("No live execution path");
|
||||
expect(container.textContent).not.toContain("Run failed");
|
||||
} else if (testCase.canRetry) expect(container.textContent).toContain("You can retry this message now.");
|
||||
else expect(container.textContent).not.toContain("You can retry this message now.");
|
||||
if (button) {
|
||||
flushSync(() => button.click());
|
||||
await Promise.resolve();
|
||||
expect(retry).toHaveBeenCalledExactlyOnceWith("stopped-run");
|
||||
}
|
||||
expect(recoveryRetry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(DIRECT_ADAPTER_TYPES)(
|
||||
@@ -3409,6 +3429,26 @@ describe("TaskChatThread no-live-execution-path recovery", () => {
|
||||
updatedAt: new Date("2026-08-26T12:00:00.000Z"),
|
||||
};
|
||||
|
||||
it.each((["native", "legacy"] as const).flatMap(runtimeMode => [true, false].flatMap(streamlined => [true, false].map(recoveryIsLatest => ({ runtimeMode, streamlined, recoveryIsLatest })))))
|
||||
("keeps the last adjacent error for $runtimeMode (streamlined=$streamlined, recoveryIsLatest=$recoveryIsLatest)", ({ runtimeMode, streamlined, recoveryIsLatest }) => {
|
||||
streamlinedState.enabled = streamlined;
|
||||
render(<TaskChatThread
|
||||
comments={[noLivePathComment]} onAdd={async () => {}} showComposer={false} issueStatus="blocked"
|
||||
onTryAgainNoLiveExecutionPath={async () => {}}
|
||||
linkedRuns={[{
|
||||
runId: "failed-run", status: "failed", runtimeMode, agentId: "agent-1", agentName: "Coder",
|
||||
adapterType: runtimeMode === "native" ? "paperclip_runner" : "claude_local",
|
||||
createdAt: "2026-08-26T11:58:00.000Z", startedAt: "2026-08-26T11:58:01.000Z",
|
||||
finishedAt: recoveryIsLatest ? "2026-08-26T11:59:00.000Z" : "2026-08-26T12:01:00.000Z",
|
||||
errorCode: "runner_exited",
|
||||
}]}
|
||||
/>);
|
||||
expect(container.textContent?.includes("Run failed")).toBe(!recoveryIsLatest);
|
||||
expect(container.textContent?.includes("No live execution path")).toBe(recoveryIsLatest);
|
||||
expect(container.querySelector(".border-dashed")).toBeNull();
|
||||
expect(container.querySelectorAll('button[data-testid$="try-again"]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("offers Try again only while the task is blocked", async () => {
|
||||
const onTryAgain = vi.fn();
|
||||
const props = {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ThemeProvider } from "@/context/ThemeContext";
|
||||
import { TaskChatMarker } from "./TaskChatMarker";
|
||||
import { TaskChatThreadView } from "./TaskChatThreadView";
|
||||
import { TaskChatPresentationProvider } from "./presentation-mode";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ to, children }: { to: string; children: React.ReactNode }) => (
|
||||
@@ -32,6 +33,66 @@ describe("TaskChatMarker", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it.each(["production", "streamlined"] as const)("renders old failure markers without divider rules in %s mode", (mode) => {
|
||||
flushSync(() => root!.render(
|
||||
<ThemeProvider>
|
||||
<TaskChatPresentationProvider mode={mode}>
|
||||
<TaskChatMarker item={{ id: "old", kind: "marker", variant: "interrupted", label: "Run failed", detail: "Saved diagnostic", runHref: "/agents/coder/runs/old" }} />
|
||||
</TaskChatPresentationProvider>
|
||||
</ThemeProvider>,
|
||||
));
|
||||
expect(container.querySelector(".border-dashed, .h-px")).toBeNull();
|
||||
expect(container.textContent).toContain("Run failed");
|
||||
expect(container.textContent).toContain("Saved diagnostic");
|
||||
});
|
||||
|
||||
it.each(["production", "streamlined"] as const)("shows only the last adjacent error and retains retry in %s mode", async (mode) => {
|
||||
const retry = vi.fn();
|
||||
flushSync(() => root!.render(
|
||||
<ThemeProvider>
|
||||
<TaskChatPresentationProvider mode={mode}>
|
||||
<TaskChatThreadView scroll={false} onRetryFailedRun={retry} items={[
|
||||
{ id: "notice", kind: "message", author: "system", text: "Paperclip retried continuation, but it still has no live execution path." },
|
||||
{ id: "failed", kind: "marker", variant: "interrupted", label: "Run failed", collapsible: true, runId: "latest", detail: "Latest failure details" },
|
||||
]} />
|
||||
</TaskChatPresentationProvider>
|
||||
</ThemeProvider>,
|
||||
));
|
||||
expect(container.textContent).toContain("Run failed");
|
||||
expect(container.querySelector('[data-testid="task-chat-system-notice"]')).toBeNull();
|
||||
flushSync(() => container.querySelector<HTMLButtonElement>('[data-testid="task-chat-run-failed-try-again"]')!.click());
|
||||
await Promise.resolve();
|
||||
expect(retry).toHaveBeenCalledWith("latest");
|
||||
});
|
||||
|
||||
it.each(["production", "streamlined"] as const)("keeps the permitted run retry and its pending state on a later recovery notice in %s mode", async (mode) => {
|
||||
const retry = vi.fn();
|
||||
const renderNotice = (pending: boolean) => flushSync(() => root!.render(
|
||||
<ThemeProvider>
|
||||
<TaskChatPresentationProvider mode={mode}>
|
||||
<TaskChatThreadView scroll={false} onRetryFailedRun={retry} retryFailedRunId={pending ? "latest" : null} items={[
|
||||
{ id: "failed", kind: "marker", variant: "interrupted", label: "Run failed", collapsible: true, runId: "latest" },
|
||||
{ id: "notice", kind: "message", author: "system", text: "Paperclip retried continuation, but it still has no live execution path." },
|
||||
]} />
|
||||
</TaskChatPresentationProvider>
|
||||
</ThemeProvider>,
|
||||
));
|
||||
renderNotice(true);
|
||||
expect(container.textContent).not.toContain("Run failed");
|
||||
let button = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-no-live-path-try-again"]')!;
|
||||
expect(button.disabled).toBe(true);
|
||||
expect(button.textContent).toBe("Trying again...");
|
||||
flushSync(() => button.click());
|
||||
expect(retry).not.toHaveBeenCalled();
|
||||
renderNotice(false);
|
||||
flushSync(() => container.querySelector<HTMLButtonElement>('button[aria-expanded]')!.click());
|
||||
button = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-no-live-path-try-again"]')!;
|
||||
expect(button.disabled).toBe(false);
|
||||
flushSync(() => button.click());
|
||||
await Promise.resolve();
|
||||
expect(retry).toHaveBeenCalledExactlyOnceWith("latest");
|
||||
});
|
||||
|
||||
it("renders a failed run as a timestamped disclosure without divider rules", () => {
|
||||
flushSync(() =>
|
||||
root!.render(
|
||||
|
||||
@@ -15,8 +15,8 @@ const VARIANT_ICON = {
|
||||
|
||||
/**
|
||||
* Lifecycle divider — renders a state boundary (session start, interruption,
|
||||
* turn boundary) as a recessed rule, never a bubble. Interruptions use a dashed
|
||||
* destructive rule to read as a distinct state.
|
||||
* turn boundary) as a recessed rule. Interruptions use a compact row without
|
||||
* divider rules, including older items that did not set `collapsible`.
|
||||
*/
|
||||
export function TaskChatMarker({
|
||||
item,
|
||||
@@ -84,7 +84,7 @@ export function TaskChatMarker({
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<span className="truncate font-medium">{item.label}</span>
|
||||
{relative ? (
|
||||
<span className="shrink-0 text-muted-foreground/70">
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
· {relative}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -154,20 +154,13 @@ export function TaskChatMarker({
|
||||
return (
|
||||
<div
|
||||
className="tc-enter-marker flex items-center gap-2 py-1 text-xs text-muted-foreground"
|
||||
role={streamlined ? "separator" : undefined}
|
||||
aria-label={streamlined ? item.label : undefined}
|
||||
role={streamlined && item.variant !== "interrupted" ? "separator" : undefined}
|
||||
aria-label={streamlined && item.variant !== "interrupted" ? item.label : undefined}
|
||||
>
|
||||
{item.variant !== "interrupted" ? <span className="h-px flex-1 bg-border" /> : null}
|
||||
<span
|
||||
className={cn(
|
||||
"h-px flex-1",
|
||||
interrupted
|
||||
? "border-t border-dashed border-destructive/50"
|
||||
: "bg-border",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-1.5",
|
||||
"flex min-w-0 flex-wrap items-center gap-1.5",
|
||||
interrupted && "text-destructive",
|
||||
)}
|
||||
>
|
||||
@@ -181,6 +174,7 @@ export function TaskChatMarker({
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="text-foreground"
|
||||
onClick={handleTryAgain}
|
||||
disabled={tryAgainPending}
|
||||
data-testid="task-chat-run-failed-try-again"
|
||||
@@ -189,14 +183,7 @@ export function TaskChatMarker({
|
||||
</Button>
|
||||
) : null}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"h-px flex-1",
|
||||
interrupted
|
||||
? "border-t border-dashed border-destructive/50"
|
||||
: "bg-border",
|
||||
)}
|
||||
/>
|
||||
{item.variant !== "interrupted" ? <span className="h-px flex-1 bg-border" /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -212,6 +212,28 @@ describe("TaskChatSystemNotice (PAP-443)", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([false, true])("preserves retry on a saved Automatic recovery blocked notice (pending: %s)", async (pending) => {
|
||||
const onTryAgain = vi.fn();
|
||||
renderNotice({
|
||||
text: "Paperclip automatically retried continuation for this assigned `in_progress` issue and the retry made progress, but it still has no live execution path. Moving it to `blocked` so it is visible for intervention.",
|
||||
presentation: { kind: "system_notice", title: "Automatic recovery blocked", tone: "danger", detailsDefaultOpen: false },
|
||||
metadata: { version: 1, sourceRunId: "run-1", sections: [{ title: "Recovery", rows: [
|
||||
{ type: "key_value", label: "Recovery action", value: "recovery-action-1" },
|
||||
] }] },
|
||||
}, { onTryAgainNoLiveExecutionPath: onTryAgain, tryAgainNoLiveExecutionPathPending: pending });
|
||||
const retry = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-no-live-path-try-again"]');
|
||||
expect(retry).not.toBeNull();
|
||||
expect(retry!.disabled).toBe(pending);
|
||||
if (!pending) {
|
||||
flushSync(() => retry!.click());
|
||||
await Promise.resolve();
|
||||
expect(onTryAgain).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
flushSync(() => toggleButton().click());
|
||||
expect(container.querySelectorAll('[data-testid="task-chat-no-live-path-try-again"]')).toHaveLength(1);
|
||||
expect(container.querySelector('[data-testid="task-chat-system-notice-details"] [data-testid="task-chat-no-live-path-try-again"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("moves Try again into the expanded notice footer", () => {
|
||||
renderNotice(
|
||||
{
|
||||
@@ -239,6 +261,15 @@ describe("TaskChatSystemNotice (PAP-443)", () => {
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ text: "Paperclip retried continuation, but it still has no live execution path.",
|
||||
presentation: { kind: "system_notice" as const, title: "Child task blocked", tone: "warning" as const, detailsDefaultOpen: false } },
|
||||
{ text: "Child task has no live execution path. Parent must review its result." },
|
||||
])("does not attach a parent recovery retry to an unrelated notice: $text", (item) => {
|
||||
renderNotice(item, { onTryAgainNoLiveExecutionPath: vi.fn() });
|
||||
expect(container.querySelector('[data-testid="task-chat-no-live-path-try-again"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the pending state and omits Try again from unrelated notices", () => {
|
||||
renderNotice(
|
||||
{
|
||||
|
||||
@@ -22,6 +22,7 @@ import { humanizeSystemNotice } from "@/lib/system-notice-humanizer";
|
||||
import { mapCommentMetadataToSystemNoticeSections } from "@/lib/system-notice-comment";
|
||||
import { timeAgo } from "@/lib/timeAgo";
|
||||
import type { TaskChatMessageItem } from "./task-chat-model";
|
||||
import { isNoLiveExecutionPathNotice } from "./adjacent-errors";
|
||||
|
||||
const TONE_ICON: Record<SystemNoticeTone, LucideIcon> = {
|
||||
neutral: Info,
|
||||
@@ -79,8 +80,7 @@ export function TaskChatSystemNotice({
|
||||
const relative = item.createdAtIso ? timeAgo(item.createdAtIso) : undefined;
|
||||
const showTryAgain =
|
||||
Boolean(onTryAgainNoLiveExecutionPath) &&
|
||||
(item.presentation?.title?.trim() === "No live execution path" ||
|
||||
item.text.toLowerCase().includes("no live execution path"));
|
||||
isNoLiveExecutionPathNotice(item);
|
||||
const handleTryAgain = () => {
|
||||
void Promise.resolve()
|
||||
.then(() => onTryAgainNoLiveExecutionPath?.())
|
||||
@@ -118,7 +118,7 @@ export function TaskChatSystemNotice({
|
||||
<ToneIcon className={cn("h-3.5 w-3.5 shrink-0", TONE_ICON_CLASS[tone])} aria-hidden />
|
||||
<span className="truncate font-medium">{title}</span>
|
||||
{detail ? <span className="hidden truncate sm:inline">· {detail}</span> : null}
|
||||
{relative ? <span className="shrink-0 text-muted-foreground/70">· {relative}</span> : null}
|
||||
{relative ? <span className="shrink-0 text-muted-foreground">· {relative}</span> : null}
|
||||
<ChevronDown
|
||||
className={cn("tc-notice-chevron h-3.5 w-3.5 shrink-0 transition-transform", open && "rotate-180")}
|
||||
aria-hidden
|
||||
|
||||
@@ -25,6 +25,7 @@ import { TaskMessageScroller } from "./TaskMessageScroller";
|
||||
import { TaskChatProtocolCard } from "./TaskChatProtocolCard";
|
||||
import { TaskChatProtocolActivityRow } from "./TaskChatProtocolActivityRow";
|
||||
import { TaskChatPlanPreviewCard } from "./TaskChatPlanPreviewCard";
|
||||
import { compactAdjacentErrors } from "./adjacent-errors";
|
||||
|
||||
const EMPTY_ATTACHMENTS: IssueAttachment[] = [];
|
||||
|
||||
@@ -317,43 +318,37 @@ export function TaskChatThreadView({
|
||||
onOpenBrowser,
|
||||
}: TaskChatThreadViewProps) {
|
||||
const streamlined = useStreamlinedTaskChatPresentation();
|
||||
const { items: visibleItems, retryMarkers } = useMemo(() => compactAdjacentErrors(items), [items]);
|
||||
const retryableMarkerId =
|
||||
onRetryFailedRun || onTryAgainNoLiveExecutionPath
|
||||
? [...items]
|
||||
? [...visibleItems]
|
||||
.reverse()
|
||||
.find(
|
||||
(item) =>
|
||||
item.kind === "marker" &&
|
||||
item.variant === "interrupted" &&
|
||||
(item.label === "Run failed" ||
|
||||
item.label === "Usage limit reached"),
|
||||
(item) => {
|
||||
const marker = item.kind === "marker" ? item : retryMarkers.get(item.id);
|
||||
return marker?.variant === "interrupted" &&
|
||||
(marker.label === "Run failed" || marker.label === "Usage limit reached");
|
||||
},
|
||||
)?.id
|
||||
: undefined;
|
||||
// Streaming tail and header updates must not rebuild settled markdown/tool trees.
|
||||
const history = useMemo(() => {
|
||||
const renderVisibleItem = (item: TaskChatItem) => {
|
||||
const marker = item.id === retryableMarkerId ? retryMarkers.get(item.id) : undefined;
|
||||
const runId = marker?.retryable !== false ? marker?.runId : undefined;
|
||||
const runRetry = !onTryAgainNoLiveExecutionPath && onRetryFailedRun && runId
|
||||
? () => onRetryFailedRun(runId)
|
||||
: undefined;
|
||||
return renderItem(
|
||||
item, onApprovalDecision, renderInteraction, renderBrief, renderMessageActions,
|
||||
renderQueuedAction, onRuntimeRequestDecision, "classic",
|
||||
runRetry ?? onTryAgainNoLiveExecutionPath,
|
||||
runRetry ? retryFailedRunId === runId : tryAgainNoLiveExecutionPathPending,
|
||||
retryableMarkerId, onRetryFailedRun, retryFailedRunId, attachments, onOpenSkill, onOpenBrowser,
|
||||
);
|
||||
};
|
||||
const renderedItems = streamlined
|
||||
? items
|
||||
.map((item) => ({
|
||||
item,
|
||||
content: renderItem(
|
||||
item,
|
||||
onApprovalDecision,
|
||||
renderInteraction,
|
||||
renderBrief,
|
||||
renderMessageActions,
|
||||
renderQueuedAction,
|
||||
onRuntimeRequestDecision,
|
||||
"classic",
|
||||
onTryAgainNoLiveExecutionPath,
|
||||
tryAgainNoLiveExecutionPathPending,
|
||||
retryableMarkerId,
|
||||
onRetryFailedRun,
|
||||
retryFailedRunId,
|
||||
attachments,
|
||||
onOpenSkill,
|
||||
onOpenBrowser,
|
||||
),
|
||||
}))
|
||||
? visibleItems.map((item) => ({ item, content: renderVisibleItem(item) }))
|
||||
.filter((entry) => entry.content !== null)
|
||||
: [];
|
||||
return (
|
||||
@@ -380,7 +375,7 @@ export function TaskChatThreadView({
|
||||
{content}
|
||||
</div>
|
||||
))
|
||||
: items.map((item, index) => (
|
||||
: visibleItems.map((item, index) => (
|
||||
<div
|
||||
key={
|
||||
item.kind === "message" ? (item.renderKey ?? item.id) : item.id
|
||||
@@ -397,30 +392,13 @@ export function TaskChatThreadView({
|
||||
"-mt-3",
|
||||
)}
|
||||
>
|
||||
{renderItem(
|
||||
item,
|
||||
onApprovalDecision,
|
||||
renderInteraction,
|
||||
renderBrief,
|
||||
renderMessageActions,
|
||||
renderQueuedAction,
|
||||
onRuntimeRequestDecision,
|
||||
"classic",
|
||||
onTryAgainNoLiveExecutionPath,
|
||||
tryAgainNoLiveExecutionPathPending,
|
||||
retryableMarkerId,
|
||||
onRetryFailedRun,
|
||||
retryFailedRunId,
|
||||
attachments,
|
||||
onOpenSkill,
|
||||
onOpenBrowser,
|
||||
)}
|
||||
{renderVisibleItem(item)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}, [
|
||||
items, streamlined, onApprovalDecision, onRuntimeRequestDecision,
|
||||
visibleItems, retryMarkers, streamlined, onApprovalDecision, onRuntimeRequestDecision,
|
||||
renderInteraction, renderBrief, renderMessageActions, renderQueuedAction,
|
||||
onTryAgainNoLiveExecutionPath, tryAgainNoLiveExecutionPathPending,
|
||||
retryableMarkerId, onRetryFailedRun, retryFailedRunId, attachments, onOpenSkill, onOpenBrowser,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { compactAdjacentErrors } from "./adjacent-errors";
|
||||
import type { TaskChatItem, TaskChatMarkerItem, TaskChatMessageItem } from "./task-chat-model";
|
||||
|
||||
const failed: TaskChatMarkerItem = {
|
||||
id: "failure", kind: "marker", variant: "interrupted", label: "Run failed", runId: "run-1",
|
||||
};
|
||||
const recovery: TaskChatMessageItem = {
|
||||
id: "recovery", kind: "message", author: "system", text: "Paperclip retried continuation, but it still has no live execution path.",
|
||||
presentation: { kind: "system_notice", title: "No live execution path", tone: "danger", detailsDefaultOpen: false },
|
||||
};
|
||||
|
||||
describe("compactAdjacentErrors", () => {
|
||||
it.each([
|
||||
[failed, recovery],
|
||||
[recovery, failed],
|
||||
])("keeps the later row and all of its details in either order", (first, last) => {
|
||||
const items = [first, last];
|
||||
expect(compactAdjacentErrors(items).items).toEqual([last]);
|
||||
expect(items).toEqual([first, last]);
|
||||
});
|
||||
|
||||
it("compacts a contiguous group only when both error sources are present", () => {
|
||||
const latest = { ...failed, id: "latest", detail: "Newest error", runId: "run-2" };
|
||||
expect(compactAdjacentErrors([failed, recovery, latest]).items).toEqual([latest]);
|
||||
expect(compactAdjacentErrors([failed, latest]).items).toEqual([failed, latest]);
|
||||
expect(compactAdjacentErrors([recovery, { ...recovery, id: "other" }]).items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("preserves the latest suppressed run retry without changing saved rows", () => {
|
||||
const latestFailure = { ...failed, id: "last-failure", runId: "last-run", retryable: false };
|
||||
const items = [failed, latestFailure, recovery];
|
||||
const compacted = compactAdjacentErrors(items);
|
||||
expect(compacted.items).toEqual([recovery]);
|
||||
expect(compacted.retryMarkers.get(recovery.id)).toBe(latestFailure);
|
||||
expect(items).toEqual([failed, latestFailure, recovery]);
|
||||
expect(compactAdjacentErrors([failed, { id: "human", kind: "message", author: "human", text: "Retry?" }, recovery]).retryMarkers.size).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
recovery.text,
|
||||
"Paperclip automatically retried dispatch for this assigned `todo` issue during terminal run recovery, but it still has no live execution path.",
|
||||
"Paperclip automatically retried continuation for this assigned `in_progress` issue and the retry made progress, but it still has no live execution path.",
|
||||
"Paperclip retried this issue's run after it ended without finishing, but the bounded retry budget is spent and it still has no live execution path.",
|
||||
])("recognizes an old unstructured recovery comment: %s", (text) => {
|
||||
const legacy = { ...recovery, text, presentation: null };
|
||||
expect(compactAdjacentErrors([failed, legacy]).items).toEqual([legacy]);
|
||||
});
|
||||
|
||||
it.each(["Automatic recovery blocked", "Error: usage limit reached"])(
|
||||
"recognizes a saved stranded-recovery notice with the producer title %s", (title) => {
|
||||
// buildStrandedRecoveryEscalationNotice saves this metadata even when
|
||||
// its default or source-run title replaces the recovery-family title.
|
||||
const notice: TaskChatMessageItem = {
|
||||
...recovery,
|
||||
text: "Paperclip automatically retried continuation for this assigned `in_progress` issue and the retry made progress, but it still has no live execution path. Moving it to `blocked` so it is visible for intervention.",
|
||||
presentation: { ...recovery.presentation!, title },
|
||||
metadata: { version: 1, sourceRunId: "run-1", sections: [{ title: "Recovery", rows: [
|
||||
{ type: "key_value", label: "Recovery action", value: "recovery-action-1" },
|
||||
{ type: "key_value", label: "Recovery owner", value: "Board decision required" },
|
||||
] }] },
|
||||
};
|
||||
const compacted = compactAdjacentErrors([failed, notice]);
|
||||
expect(compacted.items).toEqual([notice]);
|
||||
expect(compacted.retryMarkers.get(notice.id)).toBe(failed);
|
||||
expect(compactAdjacentErrors([notice, failed]).items).toEqual([failed]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ ...recovery, presentation: { ...recovery.presentation!, title: "Child task blocked" } },
|
||||
{ ...recovery, presentation: { ...recovery.presentation!, title: "" } },
|
||||
{ ...recovery, presentation: { ...recovery.presentation!, title: "Automatic recovery blocked" } },
|
||||
{ ...recovery, presentation: { ...recovery.presentation!, title: "Automatic recovery blocked" }, metadata: {
|
||||
version: 1 as const, sections: [{ title: "Recovery", rows: [{ type: "key_value" as const, label: "Recovery action", value: "" }] }],
|
||||
} },
|
||||
{ ...recovery, text: "Child task has no live execution path.", metadata: {
|
||||
version: 1 as const, sections: [{ title: "Recovery", rows: [{ type: "key_value" as const, label: "Recovery action", value: "child-action" }] }],
|
||||
}, presentation: { ...recovery.presentation!, title: "Child task blocked" } },
|
||||
{ ...recovery, presentation: null, text: "Child task has no live execution path. Parent must review its result." },
|
||||
{ ...recovery, presentation: null, text: "Child task report: Paperclip retried continuation, but it still has no live execution path." },
|
||||
])("preserves unrelated notices and their retry boundaries: $text ($presentation.title)", (boundary) => {
|
||||
const lastFailure = { ...failed, id: "last-failure", runId: "last-run" };
|
||||
for (const last of [recovery, lastFailure]) {
|
||||
const items = [failed, { ...boundary, id: "unrelated" }, last];
|
||||
const result = compactAdjacentErrors(items);
|
||||
expect(result.items).toEqual(items);
|
||||
expect(result.retryMarkers.size).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
const boundaries: TaskChatItem[] = [
|
||||
{ id: "human", kind: "message", author: "human", text: "Please try again." },
|
||||
{ id: "agent", kind: "message", author: "agent", text: "There is no live execution path." },
|
||||
{ id: "system", kind: "message", author: "system", text: "Child task completed." },
|
||||
{ id: "session", kind: "marker", variant: "session_start", label: "Session reset" },
|
||||
{ ...failed, id: "cancelled", tone: "neutral", label: "Stopped" },
|
||||
{ ...failed, id: "credential", credentialAccess: { agentName: "Coder", deniedUser: "you", settingsHref: "/agents/coder" } },
|
||||
{ id: "tool", kind: "tool", name: "Read file", status: "completed" },
|
||||
];
|
||||
it.each(boundaries.map((boundary) => [boundary.id, boundary] as const))(
|
||||
"keeps both errors across a %s row", (_, boundary) => {
|
||||
const items = [failed, boundary, recovery];
|
||||
expect(compactAdjacentErrors(items).items).toEqual(items);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { TaskChatItem, TaskChatMarkerItem, TaskChatMessageItem } from "./task-chat-model";
|
||||
|
||||
export function isNoLiveExecutionPathNotice(item: TaskChatMessageItem): boolean {
|
||||
if (item.author !== "system") return false;
|
||||
const recoveryStatement = /^Paperclip (?:automatically )?retried (?:dispatch|continuation|this issue's run)\b[^\r\n]*\bit still has no live execution path\b/i.test(item.text.trim());
|
||||
if (item.presentation) {
|
||||
if (item.presentation.title?.trim().toLowerCase() === "no live execution path") return true;
|
||||
// The stranded-recovery producer can replace the family title with
|
||||
// "Automatic recovery blocked" or a classified source-run failure.
|
||||
// Require both its recovery statement and saved action metadata so an
|
||||
// unrelated notice quoting that statement remains a group boundary.
|
||||
return recoveryStatement && !!item.metadata?.sections.some((section) =>
|
||||
section.title === "Recovery" && section.rows.some((row) =>
|
||||
row.type === "key_value" && row.label === "Recovery action" && row.value.trim().length > 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
// Older recovery comments lack presentation metadata. Recognize their own
|
||||
// recovery statement rather than any mention of the error phrase.
|
||||
return recoveryStatement;
|
||||
}
|
||||
|
||||
function errorKind(item: TaskChatItem): "run" | "recovery" | null {
|
||||
if (item.kind === "marker" && item.variant === "interrupted"
|
||||
&& item.tone !== "neutral" && !item.credentialAccess) return "run";
|
||||
if (item.kind === "message" && isNoLiveExecutionPathNotice(item)) return "recovery";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Keep the last row when adjacent run and recovery errors describe a stop.
|
||||
* The caller supplies conversation order; saved comments and run history stay intact.
|
||||
* Messages, inputs, session boundaries and actionable credential denials break a group.
|
||||
*/
|
||||
export function compactAdjacentErrors(items: readonly TaskChatItem[]): {
|
||||
items: TaskChatItem[];
|
||||
retryMarkers: Map<string, TaskChatMarkerItem>;
|
||||
} {
|
||||
const result: TaskChatItem[] = [];
|
||||
const retryMarkers = new Map<string, TaskChatMarkerItem>();
|
||||
for (let start = 0; start < items.length;) {
|
||||
const kind = errorKind(items[start]);
|
||||
if (!kind) {
|
||||
result.push(items[start++]);
|
||||
continue;
|
||||
}
|
||||
let end = start + 1;
|
||||
let mixed = false;
|
||||
while (end < items.length) {
|
||||
const next = errorKind(items[end]);
|
||||
if (!next) break;
|
||||
if (next !== kind) mixed = true;
|
||||
end++;
|
||||
}
|
||||
const latest = items[end - 1];
|
||||
if (mixed && errorKind(latest) === "recovery") {
|
||||
const marker = items.slice(start, end).reverse().find(
|
||||
(item): item is TaskChatMarkerItem => item.kind === "marker"
|
||||
&& (item.label === "Run failed" || item.label === "Usage limit reached"),
|
||||
);
|
||||
// Preserve the retry target separately from the surviving error's text.
|
||||
// The renderer still applies the parent's permission and pending state.
|
||||
if (marker) retryMarkers.set(latest.id, marker);
|
||||
}
|
||||
result.push(...(mixed ? [latest] : items.slice(start, end)));
|
||||
start = end;
|
||||
}
|
||||
return { items: result, retryMarkers };
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, waitFor, within } from "storybook/test";
|
||||
import { TaskChatThreadView } from "@/components/task-chat/TaskChatThreadView";
|
||||
import { TaskChatPresentationProvider, type TaskChatPresentationMode } from "@/components/task-chat/presentation-mode";
|
||||
import type { TaskChatItem, TaskChatMarkerItem, TaskChatMessageItem } from "@/components/task-chat/task-chat-model";
|
||||
|
||||
const failed: TaskChatMarkerItem = {
|
||||
id: "failed", kind: "marker", variant: "interrupted", label: "Run failed",
|
||||
detail: "The runner stopped before returning an answer (runner_exited).",
|
||||
collapsible: true, runId: "failed-run", runHref: "/agents/coder/runs/failed-run",
|
||||
createdAtIso: new Date(Date.now() - 3 * 60_000).toISOString(),
|
||||
};
|
||||
const recovery: TaskChatMessageItem = {
|
||||
id: "no-live-path", kind: "message", author: "system",
|
||||
text: "Paperclip retried continuation, but it still has no live execution path. The task is blocked. Your message is preserved.",
|
||||
presentation: { kind: "system_notice", title: "No live execution path", tone: "danger", detailsDefaultOpen: false },
|
||||
createdAtIso: new Date(Date.now() - 2 * 60_000).toISOString(),
|
||||
};
|
||||
const request: TaskChatMessageItem = {
|
||||
id: "request", kind: "message", author: "human", authorName: "Alex", isCurrentUser: true,
|
||||
text: "Please update the task summary.", timestamp: "12:00 PM",
|
||||
};
|
||||
|
||||
function FailureNotices({ items, mode = "streamlined", recoveryRetryEnabled = true }: { items: TaskChatItem[]; mode?: TaskChatPresentationMode; recoveryRetryEnabled?: boolean }) {
|
||||
const [retry, setRetry] = useState<string | null>(null);
|
||||
return (
|
||||
<main className="min-h-screen bg-background p-4 text-foreground md:p-8">
|
||||
<div className="mx-auto max-w-(--tc-shell-max-w)">
|
||||
<h1 className="text-xl font-semibold">Task summary</h1>
|
||||
<TaskChatPresentationProvider mode={mode}>
|
||||
<TaskChatThreadView items={[request, ...items]} scroll={false}
|
||||
onRetryFailedRun={(runId) => setRetry(`Retry requested for ${runId}`)}
|
||||
onTryAgainNoLiveExecutionPath={recoveryRetryEnabled ? () => setRetry("Recovery retry requested") : undefined} />
|
||||
</TaskChatPresentationProvider>
|
||||
{retry ? <p role="status" className="text-sm text-muted-foreground">{retry}</p> : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
id: "tasks-failure-notices",
|
||||
title: "Tasks and conversations/Task creation and lifecycle/Task chat/Failure notices",
|
||||
component: FailureNotices,
|
||||
args: { items: [failed] },
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
throwPlayFunctionExceptions: true,
|
||||
paperclip: {
|
||||
capabilities: ["tasks/discussion", "runs-adapters/runs"],
|
||||
status: "current", implementation: "production", boundary: "fixtures",
|
||||
notes: "Uses the shared production thread and failure markers. Covers older and current markers, either order of adjacent run/recovery errors, intervening messages and unrelated system notices with quoted error text, saved Automatic recovery blocked metadata, both presentation modes, permitted legacy run retries, light and mobile. Retry callbacks record a local receipt; no agent runs or API changes occur.",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof FailureNotices>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
async function expectNoRules(canvasElement: HTMLElement) {
|
||||
await expect(canvasElement.querySelector(".border-dashed, .h-px")).toBeNull();
|
||||
}
|
||||
|
||||
export const FailedRun: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByRole("button", { name: /Run failed/ })).toHaveAttribute("aria-expanded", "false");
|
||||
await expectNoRules(canvasElement);
|
||||
},
|
||||
};
|
||||
export const OlderMarker: Story = {
|
||||
args: { items: [{ ...failed, collapsible: false }] },
|
||||
play: async ({ canvasElement }) => {
|
||||
await waitFor(() => expect(within(canvasElement).getByText(failed.detail!, { exact: false })).toBeVisible());
|
||||
await expectNoRules(canvasElement);
|
||||
},
|
||||
};
|
||||
export const ProductionMode: Story = { args: { mode: "production" }, play: FailedRun.play };
|
||||
export const RecoveryIsLatest: Story = {
|
||||
args: { items: [failed, recovery] },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.queryByText("Run failed")).toBeNull();
|
||||
await expect(canvas.getByRole("button", { name: /No live execution path/ })).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(canvas.getAllByRole("button", { name: "Try again" })).toHaveLength(1);
|
||||
await expectNoRules(canvasElement);
|
||||
},
|
||||
};
|
||||
export const FailureIsLatest: Story = {
|
||||
args: { items: [recovery, { ...failed, createdAtIso: new Date(Date.now() - 60_000).toISOString() }] },
|
||||
play: async (context) => {
|
||||
await FailedRun.play?.(context);
|
||||
await expect(within(context.canvasElement).queryByText("No live execution path")).toBeNull();
|
||||
},
|
||||
};
|
||||
export const ExpandedRecoveryAndRetry: Story = {
|
||||
args: RecoveryIsLatest.args,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: /No live execution path/ }));
|
||||
await waitFor(() => expect(canvas.getByText(/Your message is preserved/)).toBeVisible());
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Try again" }));
|
||||
await expect(canvas.getByRole("status")).toHaveTextContent("Recovery retry requested");
|
||||
},
|
||||
};
|
||||
export const ExpandedFailureAndRetry: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: /Run failed/ }));
|
||||
await waitFor(() => expect(canvas.getByText(failed.detail!)).toBeVisible());
|
||||
await expect(canvas.getByRole("link", { name: "View run" }).getAttribute("href")).toMatch(/\/agents\/coder\/runs\/failed-run$/);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Try again" }));
|
||||
await expect(canvas.getByRole("status")).toHaveTextContent("Retry requested for failed-run");
|
||||
},
|
||||
};
|
||||
export const MessageBetweenErrors: Story = {
|
||||
args: { items: [failed, { ...request, id: "followup", text: "Please try again." }, recovery] },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await waitFor(() => expect(canvas.getByText("Run failed")).toBeVisible());
|
||||
await waitFor(() => expect(canvas.getByText("No live execution path")).toBeVisible());
|
||||
},
|
||||
};
|
||||
export const SystemNoticeBetweenErrors: Story = {
|
||||
args: { items: [failed, {
|
||||
id: "child-notice", kind: "message", author: "system",
|
||||
text: "Child task has no live execution path. Parent must review its result.",
|
||||
presentation: { kind: "system_notice", title: "Child task blocked", tone: "warning", detailsDefaultOpen: false },
|
||||
}, recovery] },
|
||||
play: async (context) => {
|
||||
await MessageBetweenErrors.play?.(context);
|
||||
const canvas = within(context.canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: /Child task blocked/ }));
|
||||
await waitFor(() => expect(canvas.getByText(/Parent must review its result/)).toBeVisible());
|
||||
await expect(context.canvasElement.querySelectorAll('[data-testid="task-chat-no-live-path-try-again"]')).toHaveLength(1);
|
||||
await expectNoRules(context.canvasElement);
|
||||
},
|
||||
};
|
||||
export const ProductionSystemNoticeBetweenErrors: Story = {
|
||||
...SystemNoticeBetweenErrors,
|
||||
args: { ...SystemNoticeBetweenErrors.args, mode: "production" },
|
||||
};
|
||||
export const Light: Story = { ...RecoveryIsLatest, globals: { theme: "light" } };
|
||||
export const Mobile: Story = { ...RecoveryIsLatest, globals: { viewport: { value: "mobile1", isRotated: false } } };
|
||||
|
||||
export const LegacyRunRetry: Story = {
|
||||
args: { items: [failed, recovery], recoveryRetryEnabled: false },
|
||||
play: async (context) => {
|
||||
await RecoveryIsLatest.play?.(context);
|
||||
const canvas = within(context.canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Try again" }));
|
||||
await expect(canvas.getByRole("status")).toHaveTextContent("Retry requested for failed-run");
|
||||
},
|
||||
};
|
||||
export const LegacyProductionRetry: Story = { ...LegacyRunRetry, args: { ...LegacyRunRetry.args, mode: "production" } };
|
||||
|
||||
export const AutomaticRecoveryBlocked: Story = {
|
||||
args: { items: [failed, {
|
||||
...recovery,
|
||||
text: "Paperclip automatically retried continuation for this assigned `in_progress` issue and the retry made progress, but it still has no live execution path. Moving it to `blocked` so it is visible for intervention.",
|
||||
presentation: { ...recovery.presentation!, title: "Automatic recovery blocked" },
|
||||
metadata: { version: 1, sourceRunId: "failed-run", sections: [{ title: "Recovery", rows: [
|
||||
{ type: "key_value", label: "Recovery action", value: "recovery-action-1" },
|
||||
{ type: "key_value", label: "Recovery owner", value: "Board decision required" },
|
||||
] }] },
|
||||
}] },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.queryByText("Run failed")).toBeNull();
|
||||
await userEvent.click(canvas.getByRole("button", { name: /Automatic recovery blocked/ }));
|
||||
await waitFor(() => expect(canvas.getByText("recovery-action-1")).toBeVisible());
|
||||
await expect(canvas.getByText(/retry made progress/)).toBeVisible();
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Try again" }));
|
||||
await expect(canvas.getByRole("status")).toHaveTextContent("Recovery retry requested");
|
||||
await expectNoRules(canvasElement);
|
||||
},
|
||||
};
|
||||
export const ProductionAutomaticRecoveryBlocked: Story = { ...AutomaticRecoveryBlocked, args: { ...AutomaticRecoveryBlocked.args, mode: "production" } };
|
||||
Reference in new issue
Block a user