Files
PaperClipAI/ui/src/components/task-chat/TaskChatSystemNotice.test.tsx
T
DottaandPaperclip 063ba59ae3 Fix workspace-ready notice rendering (#12716)
## Thinking Path

> - Paperclip helps people manage AI agents and their work.
> - The task thread shows user, agent, and control-plane messages.
> - Workspace-ready comments keep agent attribution for audit and
authorization.
> - These comments also have an explicit system-notice presentation.
> - The client used attribution before the presentation contract.
> - As a result, workspace-ready comments appeared as agent bubbles.
> - This pull request gives the system-notice presentation priority.
> - The benefit is a compact workspace notice with expandable details.

## Linked Issues or Issue Description

**What happened?**

The task thread showed a workspace-ready control-plane comment as a
normal agent message. The comment kept agent attribution for audit and
authorization, so the client did not use its system-notice presentation.

**Expected behavior**

The task thread must show a comment with the `system_notice`
presentation as a compact system notice. The user must be able to expand
the notice to inspect its details.

**Steps to reproduce**

1. Open a task with a workspace that becomes ready.
2. Wait for the control plane to add the workspace-ready comment.
3. Observe that the task thread shows the comment as an agent bubble
instead of a system notice.

**Paperclip version or commit**

`master` at `8eaa5caa0`.

**Deployment mode**

Local dev (`pnpm dev`).

**Agent adapter(s) involved**

Not adapter-specific. This is a core UI bug.

## What Changed

- Give the `system_notice` presentation priority when the task-chat
adapter selects the message kind.
- Pass the notice presentation data to the task-chat model.
- Improve the compact and expanded workspace notice details.
- Add component tests, adapter tests, and Storybook cases for workspace
notices.

## Verification

- `pnpm exec vitest run
ui/src/components/task-chat/task-chat-adapter.test.ts
ui/src/components/task-chat/TaskChatSystemNotice.test.tsx` passes 15
tests.
- `pnpm check:token-gates` passes.
- `pnpm -r typecheck` passes.
- `pnpm build` passes.
- `pnpm test:run` passes 5,670 tests and reports four pre-existing
workspace-runtime failures. The same four failures reproduce when the
two unrelated server test files run alone. They do not use the changed
task-chat files.

## Risks

- Risk is low. Normal agent messages still use the agent presentation.
- A comment with an explicit system-notice presentation now uses the
compact notice renderer.
- Regression tests cover both paths.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI GPT-5 (`gpt-5`) through Codex. The agent used reasoning,
repository tools, and code execution. The runtime did not expose the
context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-11 13:15:31 -05:00

310 lines
10 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("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("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");
});
});