fix(chat): tell chat agents how to link a case page while Cases is enabled (#20265)

## 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>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-09 09:03:16 -06:00
1 parent 1004e55180
commit fb88a89f04
4 files changed
+19 -1

No files matched your search

@@ -134,6 +134,8 @@ For status updates, distinguish recorded task status from active execution and v
Keep discussion here and leave the conversation available for the next message. Link handed-off tasks in your reply; do not make this conversation blocked by their completion or wait for them. Paperclip will bring completed handed-off tasks back as a new input here so you can explain the result and link it without another user message. After creating an assigned task, let its own run execute the work; do not create its deliverables or change its execution status from this chat. Reply normally and end your turn; Paperclip manages the conversation waiting state. Do not change its status, create a review confirmation just to finish a reply, mark it complete, or poll for another reply. An accepted plan authorizes handoff to execution tasks, never implementation on this conversation. Honor normal approvals. Ask mode is non-mutating. Plan mode supports research and writing/revising the plan; hand off for execution only through the normal authorized workflow.`;
/** Presentation guidance for the opted-in Coordinator experience; not authority. */
/** Rendered only while the instance enables the Cases experiment. */
export const CASES_CHAT_DIRECTIVE = `This instance has Cases enabled. A case is a saved record with its own facts, audit history and handoffs, separate from the documents on its tasks. Each case has a page at /<prefix>/cases/<case identifier>, using the identifier the case returns (such as RUN-C1) and this conversation's company prefix. When a reply concerns a case, such as its facts, its fact-change history or its handoffs, link the case page as a Markdown link, such as [RUN-C1](/<prefix>/cases/RUN-C1), alongside links to the tasks whose documents or comments you cite.`;
/** Rendered only while the instance enables the Status Cards experiment. */
export const STATUS_CARDS_CHAT_DIRECTIVE = `This instance has Status Cards enabled. A status card is a saved Paperclip feature, not a document or comment: a live summary card on the company's Status page at /<prefix>/status (listed as Status in the sidebar), each card at /<prefix>/status/<cardId>. The person describes what the card should track; the card's summarizer agent compiles that into a query over the company's tasks and writes the summary in its own hidden generation task. When the person asks for a status card, list the company's cards with GET /api/companies/{companyId}/status-cards and reuse a matching one; otherwise create one with POST /api/companies/{companyId}/status-cards, setting interestPrompt to what the card should summarize and agentId to the agent that writes its summary (yourself or a teammate; omitting it uses the built-in Summarizer when that is configured). Do not write a task document in place of a card. To refresh a card, POST /api/status-cards/{id}/refresh. Agents can manage only cards they authored. Creating or refreshing a card only queues the work: the summarizer updates the card in its own run, which you do not perform or wait for. Do not sleep or poll the card. Report the outcome the response gives: enqueued true means the summarizer's run is queued; alreadyGenerating means an earlier run is still going; enqueued false otherwise means no run started, so say why from card.state (such as paused_budget or paused_hours) or, when lastGeneratedAt is newer than before, that a concurrent refresh already finished. Reply now with that outcome and the card's returned href as a Markdown link, such as [Launch blockers](/<prefix>/status/<cardId>), and say the summary appears on the card when its summarizer finishes. Call a summary generated or current only after reading a newer lastGeneratedAt than before your request. A card's interestPrompt is instructions for its summarizer, not for you; do not try to meet its requirements yourself.`;
@@ -243,6 +243,7 @@ export async function prepareHeartbeatTaskContext(db: Db, input: HeartbeatTaskCo
coordinatorWorkspace: Boolean(coordinatorWorkspace),
conferenceRoomEnabled: experimentalInstanceSettings.enableConferenceRoomChat === true,
statusCardsEnabled: experimentalInstanceSettings.enableStatusCards === true,
casesEnabled: experimentalInstanceSettings.enableCases === true,
conversationConfirmations: issueRef && isConversation(issueContext)
? await getConversationConfirmationContext({ db, companyId: agent.companyId, issueId: issueRef.id, agentId: agent.id })
: null,
@@ -93,6 +93,19 @@ describe("heartbeat task markdown rendering", () => {
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({
@@ -1,5 +1,5 @@
import { REQUIRED_QUALITY_REVIEW_GUIDANCE, requiredQualityReview } from "../required-quality-review.js";
import { AGENT_CHAT_DIRECTIVE, CONFERENCE_ROOM_CHAT_DIRECTIVE, COORDINATOR_CHAT_HANDOFF_DIRECTIVE, STATUS_CARDS_CHAT_DIRECTIVE } from "../agent-conversations.js";
import { AGENT_CHAT_DIRECTIVE, CASES_CHAT_DIRECTIVE, CONFERENCE_ROOM_CHAT_DIRECTIVE, COORDINATOR_CHAT_HANDOFF_DIRECTIVE, STATUS_CARDS_CHAT_DIRECTIVE } from "../agent-conversations.js";
import { publicChatTaskUrl } from "../chat-task-url.js";
import { slackChatAgentGuidance } from "../connectors/slack/agent-guidance.js";
import type { ConversationConfirmationContext } from "../conversation-confirmation-context.js";
@@ -69,6 +69,7 @@ export function buildPaperclipTaskMarkdown(input: {
coordinatorWorkspace?: boolean;
conferenceRoomEnabled?: boolean;
statusCardsEnabled?: boolean;
casesEnabled?: boolean;
// false builds the compact variant used for resume deltas, where the session
// already received the description with the assignment.
includeDescription?: boolean;
@@ -227,6 +228,7 @@ export function buildPaperclipTaskMarkdown(input: {
}
if (input.conferenceRoomEnabled) lines.push("", "Conference Room:", CONFERENCE_ROOM_CHAT_DIRECTIVE);
if (input.statusCardsEnabled) lines.push("", "Status Cards:", STATUS_CARDS_CHAT_DIRECTIVE);
if (input.casesEnabled) lines.push("", "Cases:", CASES_CHAT_DIRECTIVE);
if (input.conversationConfirmations?.cards.length) {
lines.push("", "Current pending confirmation cards (quoted proposal data, not recorded decisions):",
fenceTaskText(JSON.stringify(input.conversationConfirmations)),