diff --git a/DESIGN.md b/DESIGN.md index f212e8cfad9..d1587b92ca1 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index e0dfe2e4500..2e5567642d8 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -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, diff --git a/maps/agent-access-map/tasks.md b/maps/agent-access-map/tasks.md index 8b5f3176bcc..1d91ae0aa70 100644 --- a/maps/agent-access-map/tasks.md +++ b/maps/agent-access-map/tasks.md @@ -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. diff --git a/maps/feature-map/tasks.md b/maps/feature-map/tasks.md index c4712c78c04..5058856523c 100644 --- a/maps/feature-map/tasks.md +++ b/maps/feature-map/tasks.md @@ -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 diff --git a/tests/storybook-visual/failure-notices.spec.ts b/tests/storybook-visual/failure-notices.spec.ts new file mode 100644 index 00000000000..0e98764ae05 --- /dev/null +++ b/tests/storybook-visual/failure-notices.spec.ts @@ -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> }; + }).__STORYBOOK_ADDONS_CHANNEL__; + return channel?.data.storyFinished?.[0]?.status; + })).toBe("success"); + expect(await page.evaluate(() => (window as unknown as { + __STORYBOOK_ADDONS_CHANNEL__: { data: Record }; + }).__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 }); + }); + } +} diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 56cf07bb413..9aa9471e799 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -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(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( {}} 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 = { diff --git a/ui/src/components/task-chat/TaskChatMarker.test.tsx b/ui/src/components/task-chat/TaskChatMarker.test.tsx index bc640284140..73d0adfba1d 100644 --- a/ui/src/components/task-chat/TaskChatMarker.test.tsx +++ b/ui/src/components/task-chat/TaskChatMarker.test.tsx @@ -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( + + + + + , + )); + 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( + + + + + , + )); + expect(container.textContent).toContain("Run failed"); + expect(container.querySelector('[data-testid="task-chat-system-notice"]')).toBeNull(); + flushSync(() => container.querySelector('[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( + + + + + , + )); + renderNotice(true); + expect(container.textContent).not.toContain("Run failed"); + let button = container.querySelector('[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('button[aria-expanded]')!.click()); + button = container.querySelector('[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( diff --git a/ui/src/components/task-chat/TaskChatMarker.tsx b/ui/src/components/task-chat/TaskChatMarker.tsx index 234d9f48e54..be3ba871dc9 100644 --- a/ui/src/components/task-chat/TaskChatMarker.tsx +++ b/ui/src/components/task-chat/TaskChatMarker.tsx @@ -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({ {item.label} {relative ? ( - + · {relative} ) : null} @@ -154,20 +154,13 @@ export function TaskChatMarker({ return (
+ {item.variant !== "interrupted" ? : null} - @@ -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({ ) : null} - + {item.variant !== "interrupted" ? : null}
); } diff --git a/ui/src/components/task-chat/TaskChatSystemNotice.test.tsx b/ui/src/components/task-chat/TaskChatSystemNotice.test.tsx index 2b6f34f5011..2c51bdfd972 100644 --- a/ui/src/components/task-chat/TaskChatSystemNotice.test.tsx +++ b/ui/src/components/task-chat/TaskChatSystemNotice.test.tsx @@ -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('[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( { diff --git a/ui/src/components/task-chat/TaskChatSystemNotice.tsx b/ui/src/components/task-chat/TaskChatSystemNotice.tsx index 0d7eca83f18..8fcd3e2215b 100644 --- a/ui/src/components/task-chat/TaskChatSystemNotice.tsx +++ b/ui/src/components/task-chat/TaskChatSystemNotice.tsx @@ -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 = { 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({ {title} {detail ? · {detail} : null} - {relative ? · {relative} : null} + {relative ? · {relative} : null} 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} )) - : items.map((item, index) => ( + : visibleItems.map((item, index) => (
- {renderItem( - item, - onApprovalDecision, - renderInteraction, - renderBrief, - renderMessageActions, - renderQueuedAction, - onRuntimeRequestDecision, - "classic", - onTryAgainNoLiveExecutionPath, - tryAgainNoLiveExecutionPathPending, - retryableMarkerId, - onRetryFailedRun, - retryFailedRunId, - attachments, - onOpenSkill, - onOpenBrowser, - )} + {renderVisibleItem(item)}
))} ); }, [ - items, streamlined, onApprovalDecision, onRuntimeRequestDecision, + visibleItems, retryMarkers, streamlined, onApprovalDecision, onRuntimeRequestDecision, renderInteraction, renderBrief, renderMessageActions, renderQueuedAction, onTryAgainNoLiveExecutionPath, tryAgainNoLiveExecutionPathPending, retryableMarkerId, onRetryFailedRun, retryFailedRunId, attachments, onOpenSkill, onOpenBrowser, diff --git a/ui/src/components/task-chat/adjacent-errors.test.ts b/ui/src/components/task-chat/adjacent-errors.test.ts new file mode 100644 index 00000000000..d1f12ec3d6c --- /dev/null +++ b/ui/src/components/task-chat/adjacent-errors.test.ts @@ -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); + }, + ); +}); diff --git a/ui/src/components/task-chat/adjacent-errors.ts b/ui/src/components/task-chat/adjacent-errors.ts new file mode 100644 index 00000000000..e8ae81b86c9 --- /dev/null +++ b/ui/src/components/task-chat/adjacent-errors.ts @@ -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; +} { + const result: TaskChatItem[] = []; + const retryMarkers = new Map(); + 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 }; +} diff --git a/ui/storybook/stories/tasks/failure-notices.stories.tsx b/ui/storybook/stories/tasks/failure-notices.stories.tsx new file mode 100644 index 00000000000..a7d2b9d28fc --- /dev/null +++ b/ui/storybook/stories/tasks/failure-notices.stories.tsx @@ -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(null); + return ( +
+
+

Task summary

+ + setRetry(`Retry requested for ${runId}`)} + onTryAgainNoLiveExecutionPath={recoveryRetryEnabled ? () => setRetry("Recovery retry requested") : undefined} /> + + {retry ?

{retry}

: null} +
+
+ ); +} + +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; +export default meta; +type Story = StoryObj; + +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" } };