mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agent Chat answers questions about company records and links each record it cites > - Chat guidance gives link formats for tasks, approvals, runs and agent profiles > - Cases (an experiment) have their own page at `/<prefix>/cases/<identifier>`, but the guidance does not name it > - So an agent that answers a question about a case's fact history names the case but cannot link it > - This pull request adds a Cases directive to chat guidance, only while the Cases experiment is on > - The benefit is that a person can open the case, where its facts and audit history are, from the reply ## Linked Issues or Issue Description **What happened** In a chat eval with Cases enabled, a person asked who changed a case's diagnosis. The agent read the case audit and the task's document revisions and answered correctly. It wrote "case COM-C1" as plain text. It linked only the task. The guidance in the chat prompt has no link format for a case, and the agent does not guess routes. **Expected behavior** When a reply concerns a case (its facts, fact-change history or handoffs), the agent links the case page as a Markdown link, for example `[RUN-C1](/<prefix>/cases/RUN-C1)`, and also links the tasks it cites. When the Cases experiment is off, the prompt does not mention cases. **Steps to reproduce** 1. Enable the Cases experiment. 2. Create a case with a primary task, and change one of its facts. 3. In Agent Chat, ask "Who changed the diagnosis, and what did it say before?" 4. Before this change, the reply names the case identifier but has no link to the case page. ## What Changed - `server/src/services/agent-conversations.ts`: new `CASES_CHAT_DIRECTIVE`. It says what a case is, names the `/<prefix>/cases/<case identifier>` page, and asks for a Markdown link when a reply concerns a case. - `server/src/services/heartbeat/task-markdown.ts`: new `casesEnabled` input. A conversation prompt includes the directive under "Cases:" only when it is true. - `server/src/services/heartbeat/task-context.ts`: sets `casesEnabled` from the `enableCases` experimental setting, the same way as `statusCardsEnabled`. - `server/src/services/heartbeat/task-markdown.test.ts`: new test. The directive appears for a standing chat with Cases on. It does not appear with Cases off or for a non-chat task. The test also checks that the board route `cases/:caseIdentifier` exists in `ui/src/App.tsx`. ## Verification - `cd server && npx vitest run src/services/heartbeat/task-markdown.test.ts` passes (18 tests). - `npx tsc --noEmit -p server` passes. - The `cases.case-history` chat eval uses this guidance. Its rerun is on branch `chat-eval/case-unclear-9`. ## Risks - Low risk. The change adds prompt text only when the Cases experiment is on. It does not change any API or permission. - The prompt for chats on instances with Cases enabled is slightly longer. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), 1M context, extended thinking, with tool use (shell, file edit, GitHub CLI). ## 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
212 lines
11 KiB
TypeScript
212 lines
11 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { describe, expect, it } from "vitest";
|
|
import { buildPaperclipTaskMarkdown } from "./task-markdown.js";
|
|
|
|
const issue = {
|
|
id: "task-1",
|
|
identifier: "TEST-1",
|
|
title: "Inspect the evidence",
|
|
};
|
|
|
|
describe("heartbeat task markdown rendering", () => {
|
|
it.each([true, false])("keeps native task clarification in the current task on full and resumed assignments (description=%s)", (includeDescription) => {
|
|
const markdown = buildPaperclipTaskMarkdown({
|
|
issue: { ...issue, description: "Write a blog post about our launch." },
|
|
nativeRunner: true,
|
|
includeDescription,
|
|
});
|
|
|
|
expect(markdown).toContain("Task clarification directive:");
|
|
expect(markdown).toContain("Inspect the available task description, plan, conversation, prior answers, and relevant references before asking");
|
|
expect(markdown).toContain("ask one focused question at a time in this task");
|
|
expect(markdown).toContain('interactionKind: "questions", payload.questions');
|
|
expect(markdown).toContain('resolverPolicy: "human_only", continuationPolicy: "wake_assignee"');
|
|
expect(markdown).toContain("stable idempotencyKey");
|
|
expect(markdown).toContain("do not send the user to a separate chat or create a replacement task");
|
|
expect(markdown).toContain("Reuse an existing relevant pending question");
|
|
expect(markdown).toContain("an unanswered question is not consent");
|
|
expect(markdown).toContain("A human-dismissed request is deferred to history");
|
|
expect(markdown).toContain("Do not invent material user intent or success criteria");
|
|
});
|
|
|
|
it("preserves execution of a clear accepted plan without imposing clarification", () => {
|
|
const markdown = buildPaperclipTaskMarkdown({
|
|
issue: { ...issue, workMode: "planning" },
|
|
nativeRunner: true,
|
|
acceptedPlanContinuation: true,
|
|
taskPlan: { documentId: "plan", revisionId: "revision", revisionNumber: 1, body: "Write a 300-word launch post for customers using the supplied brief." },
|
|
});
|
|
|
|
expect(markdown).toContain("If the outcome and success criteria are clear enough to act, proceed without a setup checklist or ritual questions");
|
|
expect(markdown).toContain("When a missing decision materially changes the outcome");
|
|
expect(markdown).toContain("Implement the accepted plan on this issue");
|
|
expect(markdown).toContain("Write a 300-word launch post for customers using the supplied brief.");
|
|
});
|
|
|
|
it.each([
|
|
{ nativeRunner: false, coordinatorWorkspace: true, conversationAgentId: null },
|
|
{ nativeRunner: undefined, coordinatorWorkspace: false, conversationAgentId: null },
|
|
{ nativeRunner: true, coordinatorWorkspace: false, conversationAgentId: "coordinator" },
|
|
{ nativeRunner: true, coordinatorWorkspace: true, conversationAgentId: "coordinator" },
|
|
])("does not add native task clarification to adapter or standing-chat assignments: %j", ({ conversationAgentId, ...input }) => {
|
|
expect(buildPaperclipTaskMarkdown({ issue: { ...issue, conversationAgentId }, ...input }))
|
|
.not.toContain("Task clarification directive:");
|
|
});
|
|
|
|
it("shows inferred task proposals only for opted-in native conversations while preserving explicit creation", () => {
|
|
const issue = { id: "chat", identifier: null, title: "Chat", conversationAgentId: "coordinator" };
|
|
const classic = buildPaperclipTaskMarkdown({ issue, nativeRunner: true });
|
|
expect(classic).not.toContain("Coordinator task proposals:");
|
|
const optedIn = buildPaperclipTaskMarkdown({ issue, nativeRunner: true, coordinatorWorkspace: true });
|
|
expect(optedIn).toContain('interactionKind: "suggest_tasks"');
|
|
expect(optedIn).toContain("acceptance creates the task atomically");
|
|
expect(optedIn).toContain("If the user explicitly authorized task creation, use create_task directly");
|
|
expect(buildPaperclipTaskMarkdown({ issue, nativeRunner: false, coordinatorWorkspace: true }))
|
|
.not.toContain("Coordinator task proposals:");
|
|
});
|
|
it("tells a standing chat about the Conference Room only while the experiment is enabled", () => {
|
|
const chat = { id: "chat", identifier: "RUN-1", title: "Chat", conversationAgentId: "ceo" };
|
|
const enabled = buildPaperclipTaskMarkdown({ issue: chat, nativeRunner: true, conferenceRoomEnabled: true });
|
|
expect(enabled).toContain("Conference Room:");
|
|
expect(enabled).toContain("[Open the Conference Room](/<prefix>/board-chat)");
|
|
expect(enabled).toContain("does not carry over this conversation's history");
|
|
expect(buildPaperclipTaskMarkdown({ issue: chat, nativeRunner: true })).not.toContain("board-chat");
|
|
expect(buildPaperclipTaskMarkdown({ issue: { ...chat, conversationAgentId: null }, nativeRunner: true, conferenceRoomEnabled: true }))
|
|
.not.toContain("board-chat");
|
|
});
|
|
it("tells a standing chat what a status card is, how to create and refresh one, and to reply now with its link, only while the experiment is enabled", () => {
|
|
const chat = { id: "chat", identifier: "RUN-1", title: "Chat", conversationAgentId: "ceo" };
|
|
const enabled = buildPaperclipTaskMarkdown({ issue: chat, nativeRunner: true, statusCardsEnabled: true })!;
|
|
expect(enabled).toContain("Status Cards:");
|
|
expect(enabled).toContain("A status card is a saved Paperclip feature, not a document or comment");
|
|
expect(enabled).toContain("POST /api/companies/{companyId}/status-cards");
|
|
expect(enabled).toContain("POST /api/status-cards/{id}/refresh");
|
|
expect(enabled).toContain("Do not sleep or poll the card. Report the outcome the response gives");
|
|
expect(enabled).toContain("enqueued false otherwise means no run started, so say why from card.state");
|
|
expect(enabled).toContain("[Launch blockers](/<prefix>/status/<cardId>)");
|
|
// The named pages are the board's real routes.
|
|
const app = readFileSync(new URL("../../../../ui/src/App.tsx", import.meta.url), "utf8");
|
|
expect(enabled).toContain("Status page at /<prefix>/status (");
|
|
expect(app).toMatch(/path="status"\s+element=\{<StatusCardsExperimentalGate>/);
|
|
expect(app).toMatch(/path="status\/:cardId"\s+element=\{<StatusCardsExperimentalGate>/);
|
|
expect(buildPaperclipTaskMarkdown({ issue: chat, nativeRunner: true })).not.toContain("Status Cards:");
|
|
expect(buildPaperclipTaskMarkdown({ issue: { ...chat, conversationAgentId: null }, nativeRunner: true, statusCardsEnabled: true }))
|
|
.not.toContain("Status Cards:");
|
|
});
|
|
it("tells a standing chat to link a case's page by its identifier, only while the Cases experiment is enabled", () => {
|
|
const chat = { id: "chat", identifier: "RUN-1", title: "Chat", conversationAgentId: "ceo" };
|
|
const enabled = buildPaperclipTaskMarkdown({ issue: chat, nativeRunner: true, casesEnabled: true })!;
|
|
expect(enabled).toContain("Cases:");
|
|
expect(enabled).toContain("/<prefix>/cases/<case identifier>");
|
|
expect(enabled).toContain("[RUN-C1](/<prefix>/cases/RUN-C1)");
|
|
// The named page is the board's real route.
|
|
const app = readFileSync(new URL("../../../../ui/src/App.tsx", import.meta.url), "utf8");
|
|
expect(app).toMatch(/path="cases\/:caseIdentifier"/);
|
|
expect(buildPaperclipTaskMarkdown({ issue: chat, nativeRunner: true })).not.toContain("Cases:");
|
|
expect(buildPaperclipTaskMarkdown({ issue: { ...chat, conversationAgentId: null }, nativeRunner: true, casesEnabled: true }))
|
|
.not.toContain("Cases:");
|
|
});
|
|
it("returns null without a task or a nonempty wake comment", () => {
|
|
expect(buildPaperclipTaskMarkdown({ issue: null })).toBeNull();
|
|
expect(buildPaperclipTaskMarkdown({
|
|
issue: null,
|
|
wakeComments: [{ id: "empty", body: " \n " }],
|
|
})).toBeNull();
|
|
});
|
|
|
|
it("renders comment-only context in order without changing the input", () => {
|
|
const input = {
|
|
issue: null,
|
|
wakeComments: [
|
|
Object.freeze({ id: "first", body: " First request. " }),
|
|
Object.freeze({ id: "empty", body: " " }),
|
|
Object.freeze({ id: "last", body: "\nLatest request.\n" }),
|
|
],
|
|
};
|
|
const before = structuredClone(input);
|
|
const markdown = buildPaperclipTaskMarkdown(input)!;
|
|
|
|
expect(markdown).toContain("Pending wake comments (oldest to newest):");
|
|
expect(markdown.indexOf('("first")')).toBeLessThan(markdown.indexOf('("last")'));
|
|
expect(markdown).toContain("\nFirst request.\n");
|
|
expect(markdown).toContain("\nLatest request.\n");
|
|
expect(markdown).not.toContain('("empty")');
|
|
expect(input).toEqual(before);
|
|
expect(buildPaperclipTaskMarkdown(input)).toBe(markdown);
|
|
});
|
|
|
|
it("quotes task scalars and keeps embedded code fences inside the description", () => {
|
|
const description = "First line.\n````text\nQuoted instruction.\n````\nLast line.";
|
|
const title = 'A "quoted" title\nwith a second line';
|
|
const markdown = buildPaperclipTaskMarkdown({
|
|
issue: { ...issue, title, description },
|
|
});
|
|
|
|
expect(markdown).toContain(`- Title: ${JSON.stringify(title)}`);
|
|
expect(markdown).toContain(`Issue description:\n\`\`\`\`\`text\n${description}\n\`\`\`\`\``);
|
|
});
|
|
|
|
it("keeps the nearest six ancestors and reports omitted ancestor context", () => {
|
|
const markdown = buildPaperclipTaskMarkdown({
|
|
issue,
|
|
ancestors: Array.from({ length: 7 }, (_, index) => ({
|
|
id: `ancestor-${index + 1}`,
|
|
title: `Ancestor title ${index + 1}`,
|
|
})),
|
|
});
|
|
|
|
expect(markdown).toContain("- Parent: ancestor-1 Ancestor title 1");
|
|
expect(markdown).toContain("- Ancestor 6: ancestor-6 Ancestor title 6");
|
|
expect(markdown).not.toContain("ancestor-7");
|
|
expect(markdown).toContain("[ancestor context truncated after 6 entries]");
|
|
});
|
|
|
|
it.each([true, false])("retains attachment-only wakes with hidden comment bodies (native=%s)", (nativeRunner) => {
|
|
const contentPath = "/api/attachments/attachment-1/content";
|
|
const markdown = buildPaperclipTaskMarkdown({
|
|
issue: null,
|
|
nativeRunner,
|
|
includeWakeComments: false,
|
|
wakeComments: [{
|
|
id: "comment-file",
|
|
body: " ",
|
|
attachments: [{
|
|
id: "attachment-1",
|
|
filename: "evidence.txt",
|
|
contentType: "text/plain",
|
|
byteSize: 42,
|
|
contentPath,
|
|
}],
|
|
}],
|
|
});
|
|
|
|
expect(markdown).toContain('Attachments on wake comment "comment-file":');
|
|
expect(markdown).toContain('"filename":"evidence.txt"');
|
|
expect(markdown).toContain("Attachment directive:");
|
|
if (nativeRunner) {
|
|
expect(markdown).not.toContain(contentPath);
|
|
expect(markdown).toContain("read_task_attachment");
|
|
} else {
|
|
expect(markdown).toContain(contentPath);
|
|
expect(markdown).toContain("PAPERCLIP_API_KEY");
|
|
}
|
|
});
|
|
|
|
it("keeps a rejected plan from granting execution through an older acceptance", () => {
|
|
const markdown = buildPaperclipTaskMarkdown({
|
|
issue: { ...issue, workMode: "planning" },
|
|
acceptedPlanContinuation: true,
|
|
interaction: { kind: "request_confirmation", status: "accepted" },
|
|
acceptedPlan: { revisionId: "old-approved-revision", revisionNumber: 1 },
|
|
planReview: { status: "rejected", reason: "Revise the scope.\n```\nQuoted feedback.\n```" },
|
|
});
|
|
|
|
expect(markdown).toContain("Rejected plan review directive:");
|
|
expect(markdown).toContain("Make the plan only. Do not write code or perform implementation work.");
|
|
expect(markdown).toContain("This is not approval to implement or hand off execution tasks.");
|
|
expect(markdown).toContain("````text\nRevise the scope.");
|
|
expect(markdown).not.toContain("Implement the accepted plan");
|
|
expect(markdown).not.toContain("old-approved-revision");
|
|
});
|
|
});
|