mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 04:02:18 +01:00
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>
341 lines
12 KiB
TypeScript
341 lines
12 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { flushSync } from "react-dom";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { ThemeProvider } from "@/context/ThemeContext";
|
|
import { TaskChatSystemNotice } from "./TaskChatSystemNotice";
|
|
import type { TaskChatMessageItem } from "./task-chat-model";
|
|
|
|
describe("TaskChatSystemNotice (PAP-443)", () => {
|
|
let container: HTMLDivElement;
|
|
let root: Root | null = null;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
flushSync(() => root?.unmount());
|
|
root = null;
|
|
container.remove();
|
|
});
|
|
|
|
const recoveryBody =
|
|
"Paperclip stopped before dispatching the adapter because required secret/env bindings are missing. " +
|
|
"Latest retry failure: `configuration_incomplete`. Moving it to `blocked` with a source-scoped recovery action.";
|
|
|
|
function renderNotice(
|
|
overrides: Partial<TaskChatMessageItem> = {},
|
|
props: {
|
|
onTryAgainNoLiveExecutionPath?: () => Promise<void> | void;
|
|
tryAgainNoLiveExecutionPathPending?: boolean;
|
|
} = {},
|
|
) {
|
|
const item: TaskChatMessageItem = {
|
|
id: "sys-1",
|
|
kind: "message",
|
|
author: "system",
|
|
text: recoveryBody,
|
|
createdAtIso: new Date(Date.now() - 5 * 60_000).toISOString(),
|
|
...overrides,
|
|
};
|
|
flushSync(() =>
|
|
root!.render(
|
|
<ThemeProvider>
|
|
<TaskChatSystemNotice item={item} {...props} />
|
|
</ThemeProvider>,
|
|
),
|
|
);
|
|
}
|
|
|
|
function toggleButton() {
|
|
return container.querySelector<HTMLButtonElement>(
|
|
'[data-testid="task-chat-system-notice"] button[aria-expanded]',
|
|
)!;
|
|
}
|
|
|
|
it("collapses to a humanized one-liner with relative time and hides the raw body", () => {
|
|
renderNotice();
|
|
const button = toggleButton();
|
|
expect(button.getAttribute("aria-expanded")).toBe("false");
|
|
expect(button.textContent).toContain("Task paused — a secret/config binding is missing");
|
|
expect(button.textContent).toContain("5m ago");
|
|
expect(container.textContent).not.toContain("source-scoped recovery action");
|
|
expect(
|
|
container.querySelector('[data-testid="task-chat-system-notice-details"]'),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("expands on click to the full markdown body and metadata sections", () => {
|
|
renderNotice({
|
|
metadata: {
|
|
version: 1,
|
|
sections: [
|
|
{ title: "Failure", rows: [{ type: "code", label: "Code", code: "configuration_incomplete" }] },
|
|
],
|
|
},
|
|
});
|
|
flushSync(() => toggleButton().click());
|
|
|
|
expect(toggleButton().getAttribute("aria-expanded")).toBe("true");
|
|
const details = container.querySelector('[data-testid="task-chat-system-notice-details"]');
|
|
expect(details).not.toBeNull();
|
|
expect(details!.textContent).toContain("required secret/env bindings are missing");
|
|
expect(details!.textContent).toContain("Failure");
|
|
expect(details!.textContent).toContain("configuration_incomplete");
|
|
|
|
// Collapses back — presentation-only fold, nothing lost.
|
|
flushSync(() => toggleButton().click());
|
|
expect(
|
|
container.querySelector('[data-testid="task-chat-system-notice-details"]'),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("links source-run metadata when the comment carries its run agent", () => {
|
|
renderNotice({
|
|
metadata: {
|
|
version: 1,
|
|
sections: [
|
|
{
|
|
title: "Run",
|
|
rows: [{ type: "run_link", label: "Source run", runId: "run-1", agentId: "agent-1", title: "failed" }],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
flushSync(() => toggleButton().click());
|
|
|
|
expect(container.querySelector('a[href="/agents/agent-1/runs/run-1"]')).not.toBeNull();
|
|
});
|
|
|
|
it("respects presentation.detailsDefaultOpen", () => {
|
|
renderNotice({
|
|
presentation: {
|
|
kind: "system_notice",
|
|
tone: "warning",
|
|
title: "Run recovery",
|
|
detailsDefaultOpen: true,
|
|
},
|
|
});
|
|
expect(toggleButton().getAttribute("aria-expanded")).toBe("true");
|
|
expect(toggleButton().textContent).toContain("Run recovery");
|
|
expect(
|
|
container.querySelector('[data-testid="task-chat-system-notice-details"]'),
|
|
).not.toBeNull();
|
|
});
|
|
|
|
it("shows workspace-ready comments as a compact row with expandable workspace metadata", () => {
|
|
renderNotice({
|
|
text: [
|
|
"## Workspace Ready",
|
|
"",
|
|
"- Strategy: `git_worktree`",
|
|
"- Branch: `fix/workspace-ready-notice`",
|
|
"- CWD: `/worktrees/workspace-ready-notice`",
|
|
].join("\n"),
|
|
presentation: {
|
|
kind: "system_notice",
|
|
tone: "info",
|
|
title: "Workspace ready · fix/workspace-ready-notice",
|
|
detailsDefaultOpen: false,
|
|
density: "compact",
|
|
},
|
|
metadata: {
|
|
version: 1,
|
|
sections: [
|
|
{
|
|
title: "Workspace",
|
|
rows: [
|
|
{ type: "key_value", label: "Strategy", value: "git_worktree" },
|
|
{
|
|
type: "key_value",
|
|
label: "Branch",
|
|
value: "fix/workspace-ready-notice",
|
|
},
|
|
{ type: "key_value", label: "CWD", value: "/worktrees/workspace-ready-notice" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(toggleButton().getAttribute("aria-expanded")).toBe("false");
|
|
expect(container.querySelector('[data-testid="task-chat-system-notice"]')?.className).toContain(
|
|
"items-start",
|
|
);
|
|
expect(toggleButton().textContent).toContain(
|
|
"Workspace ready · fix/workspace-ready-notice",
|
|
);
|
|
expect(container.textContent).not.toContain("git_worktree");
|
|
|
|
flushSync(() => toggleButton().click());
|
|
|
|
const details = container.querySelector('[data-testid="task-chat-system-notice-details"]');
|
|
expect(details?.textContent).toContain("Workspace");
|
|
expect(details?.textContent?.match(/git_worktree/g)).toHaveLength(1);
|
|
expect(details?.textContent?.match(/fix\/workspace-ready-notice/g)).toHaveLength(1);
|
|
expect(details?.textContent).toContain("/worktrees/workspace-ready-notice");
|
|
expect(details?.querySelector(".paperclip-markdown")).toBeNull();
|
|
});
|
|
|
|
it("shows Try again while folded and invokes it without expanding the notice", async () => {
|
|
const onTryAgain = vi.fn();
|
|
renderNotice(
|
|
{
|
|
text: "Paperclip retried continuation, but it still has no live execution path.",
|
|
presentation: {
|
|
kind: "system_notice",
|
|
tone: "danger",
|
|
title: "No live execution path",
|
|
detailsDefaultOpen: false,
|
|
},
|
|
},
|
|
{ onTryAgainNoLiveExecutionPath: onTryAgain },
|
|
);
|
|
|
|
const tryAgain = container.querySelector<HTMLButtonElement>(
|
|
'[data-testid="task-chat-no-live-path-try-again"]',
|
|
);
|
|
expect(tryAgain?.textContent).toBe("Try again");
|
|
flushSync(() => tryAgain!.click());
|
|
await Promise.resolve();
|
|
|
|
expect(onTryAgain).toHaveBeenCalledTimes(1);
|
|
expect(toggleButton().getAttribute("aria-expanded")).toBe("false");
|
|
expect(
|
|
container
|
|
.querySelector('[data-testid="task-chat-system-notice"]')
|
|
?.classList.contains("items-start"),
|
|
).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(
|
|
{
|
|
text: "Paperclip retried continuation, but it still has no live execution path.",
|
|
presentation: {
|
|
kind: "system_notice",
|
|
tone: "danger",
|
|
title: "No live execution path",
|
|
detailsDefaultOpen: false,
|
|
},
|
|
},
|
|
{ onTryAgainNoLiveExecutionPath: vi.fn() },
|
|
);
|
|
|
|
flushSync(() => toggleButton().click());
|
|
|
|
const details = container.querySelector(
|
|
'[data-testid="task-chat-system-notice-details"]',
|
|
);
|
|
expect(
|
|
details?.querySelector('[data-testid="task-chat-no-live-path-try-again"]'),
|
|
).not.toBeNull();
|
|
expect(
|
|
container.querySelectorAll('[data-testid="task-chat-no-live-path-try-again"]'),
|
|
).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(
|
|
{
|
|
text: "Paperclip retried continuation, but it still has no live execution path.",
|
|
presentation: {
|
|
kind: "system_notice",
|
|
tone: "danger",
|
|
title: "No live execution path",
|
|
detailsDefaultOpen: false,
|
|
},
|
|
},
|
|
{
|
|
onTryAgainNoLiveExecutionPath: vi.fn(),
|
|
tryAgainNoLiveExecutionPathPending: true,
|
|
},
|
|
);
|
|
|
|
const pending = container.querySelector<HTMLButtonElement>(
|
|
'[data-testid="task-chat-no-live-path-try-again"]',
|
|
);
|
|
expect(pending?.disabled).toBe(true);
|
|
expect(pending?.textContent).toBe("Trying again...");
|
|
|
|
renderNotice(
|
|
{
|
|
text: recoveryBody,
|
|
presentation: {
|
|
kind: "system_notice",
|
|
tone: "danger",
|
|
title: "Configuration incomplete",
|
|
detailsDefaultOpen: false,
|
|
},
|
|
},
|
|
{ onTryAgainNoLiveExecutionPath: vi.fn() },
|
|
);
|
|
expect(
|
|
container.querySelector('[data-testid="task-chat-no-live-path-try-again"]'),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("keeps workspace-ready events as a compact expandable notice", () => {
|
|
renderNotice({
|
|
text: "Workspace ready. The isolated worktree is available at `/tmp/paperclip/worktrees/PAP-91`.",
|
|
metadata: null,
|
|
});
|
|
|
|
const button = toggleButton();
|
|
expect(button.getAttribute("aria-expanded")).toBe("false");
|
|
expect(button.textContent).toContain("System update");
|
|
expect(button.textContent).toContain("Workspace ready.");
|
|
expect(button.querySelector("code")).toBeNull();
|
|
expect(container.textContent).not.toContain("/tmp/paperclip/worktrees/PAP-91");
|
|
|
|
flushSync(() => button.click());
|
|
expect(container.textContent).toContain("/tmp/paperclip/worktrees/PAP-91");
|
|
});
|
|
|
|
it("ignores malformed metadata while preserving expandable raw detail", () => {
|
|
renderNotice({
|
|
text: "Workspace ready. Runtime metadata could not be decoded.",
|
|
metadata: { version: 1, sections: "malformed" } as unknown as TaskChatMessageItem["metadata"],
|
|
});
|
|
|
|
expect(() => flushSync(() => toggleButton().click())).not.toThrow();
|
|
expect(container.querySelector('[data-testid="task-chat-system-notice-details"]')).not.toBeNull();
|
|
expect(container.textContent).toContain("Runtime metadata could not be decoded");
|
|
});
|
|
});
|