mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
## 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>
310 lines
10 KiB
TypeScript
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");
|
|
});
|
|
});
|