From 2ea1a4f4eb9907accf33c55c92ebc1228407b996 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:39:01 -0600 Subject: [PATCH] fix(chat): tell chat agents what a status card is and to reply now after queuing one (#20230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agent Chat lets a person talk to an agent in a standing conversation > - Status Cards is an experimental feature: a saved card on the company's Status page that a summarizer agent keeps up to date > - A chat agent cannot read the experimental settings, so nothing it reads says that status cards exist, how to create one, or where a card lives > - When a person asks for a status card, the agent writes a Markdown task document instead. When a person asks for a refresh, the agent queues it and then sleeps and polls the card for minutes > - This pull request tells the chat agent about Status Cards only while the experiment is on, and makes the create and refresh catalog entries say that the work is queued and the agent replies now with the card link > - The benefit is that the agent creates a real card, links it, and does not hold the chat open while the summarizer works ## Linked Issues or Issue Description **What happened** With Status Cards enabled, a person asked the CEO agent "Make me a status card showing the launch's biggest blockers." The agent's reasoning treated "status card" as a format. It never searched for the feature and saved a Markdown task document. In a second chat, a person asked "Refresh the launch status card now." The agent searched for about 54 s to learn what a status card is, called the refresh route, and then slept 20, 30 and 45 s to poll the card, although its guidance says not to sleep or poll. The refresh response also did not include the card's page link. Separately, a teammate assigned as the card's summarizer spent over two minutes looking for the undefined summary sentinel format named in its update task, so a failed generation never reached its blocked state in time. **Expected behavior** While the experiment is on, the agent knows that a status card is a saved feature at `//status/`. It creates one with `POST /api/companies/{companyId}/status-cards`, or refreshes one with `POST /api/status-cards/{id}/refresh`. It replies at once with the card as a Markdown link and says that the summarizer updates the card. When the experiment is off, the agent sees no status card guidance. **Steps to reproduce** 1. Enable Status Cards in Instance Settings > Experimental. 2. Open a chat with the CEO agent. 3. Send "Make me a status card showing the launch's biggest blockers." 4. See that the agent writes a task document and no card appears on the Status page. ## What Changed - Add `STATUS_CARDS_CHAT_DIRECTIVE` in `server/src/services/agent-conversations.ts`. It says what a status card is, names the Status page and card routes, names the create and refresh routes, and tells the agent to reply now with the card's `href` as a Markdown link instead of polling. - Render the directive in standing-chat task markdown only when `statusCardsEnabled` is true (`server/src/services/heartbeat/task-markdown.ts`). Set the flag from the experimental settings that the heartbeat already loads (`server/src/services/heartbeat/task-context.ts`). No new query runs before the turn starts. - Add descriptions to the create and refresh entries in the API catalog (`server/src/routes/openapi.ts`). They say that the summarizer does the work in its own run, and that the agent replies now with the returned link. - Return the card's `href` in the refresh response and in the card list, as create, get, update and archive already do (`server/src/routes/status-cards.ts`). An agent that reuses a listed card can then link it. - Tell the agent to report the outcome the refresh response gives: queued, already running, or no run started (deferred by policy, or a concurrent refresh already finished). - Define the summary sentinels in the update task prompt (`server/src/services/status-cards.ts`). The prompt told any assigned summarizer to "preserve" the built-in Summarizer's `STATUS:` and `<<>>` sentinels without defining them. A non-built-in summarizer searched for the format for minutes. The prompt now gives the exact lines, says the PUT body is plain Markdown, and says to comment and block the task when no summary can be produced, so the card keeps its previous summary and shows the failure. - Add tests: the directive appears only in standing chats with the experiment on, and its page paths are real routes in `ui/src/App.tsx`; the catalog descriptions; the refresh response `href`. ## Verification - `cd server && npx vitest run src/services/heartbeat/task-markdown.test.ts src/__tests__/openapi-routes.test.ts src/__tests__/status-cards.test.ts src/__tests__/agent-conversations.test.ts` passes (all pass; `status-cards.test.ts` also checks the update task prompt). - `npx tsc --noEmit -p server` reports no errors in the changed files. - End to end: the `status-cards.create-card` and `status-cards.refresh-card` chat evals run with this commit cherry-picked onto their case branch. ## Risks - Low risk. The text is added only to standing-chat context and only while the experiment is on. - The refresh response gains one field (`card.href`). Existing clients ignore unknown fields. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), tool use and code execution in Claude Code. ## 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 --- server/src/__tests__/openapi-routes.test.ts | 10 ++++++++++ server/src/__tests__/status-cards.test.ts | 9 +++++++++ server/src/routes/openapi.ts | 8 ++++++++ server/src/routes/status-cards.ts | 8 ++++++-- server/src/services/agent-conversations.ts | 3 +++ server/src/services/heartbeat/task-context.ts | 3 ++- .../services/heartbeat/task-markdown.test.ts | 20 +++++++++++++++++++ .../src/services/heartbeat/task-markdown.ts | 4 +++- server/src/services/status-cards.ts | 2 +- 9 files changed, 62 insertions(+), 5 deletions(-) diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index a762b955064..639e8ea232f 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -594,6 +594,16 @@ describe("openapi routes", () => { expect(patch.description).toContain("Restoring (archived: false) starts a fresh full summary"); }); + it("tells agents that creating or refreshing a status card only queues the summarizer, so they reply now with its link", async () => { + const res = await request(createApp()).get("/api/openapi.json"); + const create = res.body.paths["/api/companies/{companyId}/status-cards"].post; + expect(create.description).toContain("The response is returned before any summary exists"); + expect(create.description).toContain("reply now with the returned href as a Markdown link"); + const refresh = res.body.paths["/api/status-cards/{id}/refresh"].post; + expect(refresh.description).toContain("Queues a refresh; it does not return a new summary"); + expect(refresh.description).toContain("Do not sleep or poll for it: reply now that the refresh is queued, with the returned card.href"); + }); + it("documents exact failed-run selection and durable accepted retry responses", async () => { const res = await request(createApp()).get("/api/openapi.json"); const wake = res.body.paths["/api/agents/{id}/wakeup"].post; diff --git a/server/src/__tests__/status-cards.test.ts b/server/src/__tests__/status-cards.test.ts index a0a89a7dd82..51df9b214ab 100644 --- a/server/src/__tests__/status-cards.test.ts +++ b/server/src/__tests__/status-cards.test.ts @@ -281,6 +281,8 @@ describeEmbeddedPostgres("status card routes", () => { // The person's restore route travels with the archive receipt, so an agent can link it. expect(archived.body).toMatchObject({ archivedAt: expect.any(String), generatingIssueId: null, href: `/${company.issuePrefix}/status/${created.body.id}` }); expect((await request(app).get(`/api/status-cards/${created.body.id}`)).body.href).toBe(`/${company.issuePrefix}/status/${created.body.id}`); + // A card found by listing carries the same link, so an agent reusing it can reply with it. + expect((await request(app).get(`/api/companies/${company.id}/status-cards?archived=true`)).body[0]?.href).toBe(`/${company.issuePrefix}/status/${created.body.id}`); expect(await db.select().from(issues).where(eq(issues.id, created.body.generatingIssueId)).then((rows) => rows[0]?.status)).toBe("cancelled"); expect((await request(app).get(`/api/companies/${company.id}/status-cards`)).body).toEqual([]); expect((await request(app).get(`/api/companies/${company.id}/status-cards?archived=true`)).body).toHaveLength(1); @@ -498,6 +500,13 @@ describeEmbeddedPostgres("status card routes", () => { const res = await request(app).post(`/api/status-cards/${card.id}/refresh`).send({ full: true }); expect(res.status).toBe(202); expect(res.body.enqueued).toBe(true); + // The queued refresh carries the card's page, so the requester can link it instead of waiting for the summary. + expect(res.body.card.href).toBe(`/${company.issuePrefix}/status/${card.id}`); + // Any assigned summarizer, not only the built-in one, gets the full write-back and failure contract inline. + const [generation] = await db.select().from(issues).where(eq(issues.id, res.body.generatingIssue.id)); + expect(generation?.description).toContain("put the final Markdown between a `<<>>` line and a `<<>>` line"); + expect(generation?.description).toContain("there is no other format to look up"); + expect(generation?.description).toContain("comment the reason on this task and set it to blocked"); }); it("requires tasks:assign for mutations", async () => { diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 651f90bb5d5..c4344bb563b 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -3444,6 +3444,10 @@ registry.registerPath({ path: "/api/companies/{companyId}/status-cards", tags: ["status-cards"], summary: "Create a status card", + description: "Creates a saved status card on the company's Status page: a live summary of the tasks the interestPrompt describes. " + + "agentId names the agent that writes its summary; without it the built-in Summarizer is used when configured. " + + "The response is returned before any summary exists: the card's summarizer compiles its query and writes the first summary in its own run. " + + "Do not wait or poll for it; reply now with the returned href as a Markdown link, which opens the card at //status/.", request: { params: z.object({ companyId: z.string() }), body: jsonBody(createStatusCardSchema), @@ -3497,6 +3501,10 @@ registerCurrentRoute({ path: "/api/status-cards/{id}/refresh", tags: ["status-cards"], summary: "Refresh a status card", + description: "Queues a refresh; it does not return a new summary. The card's summarizer agent writes the update in its own generation run, after which the card's lastGeneratedAt and summary change. " + + "Do not sleep or poll for it: reply now that the refresh is queued, with the returned card.href as a Markdown link where the person sees the update. " + + "alreadyGenerating means an earlier refresh is still running. enqueued false without alreadyGenerating means this request started no run: " + + "if card.lastGeneratedAt is newer than before your request, a concurrent refresh already finished; otherwise the card's refresh policy deferred it, and card.state says why (such as paused_budget or paused_hours).", body: refreshStatusCardSchema, }); diff --git a/server/src/routes/status-cards.ts b/server/src/routes/status-cards.ts index b86de76ae97..95872e5e47c 100644 --- a/server/src/routes/status-cards.ts +++ b/server/src/routes/status-cards.ts @@ -161,9 +161,10 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake assertCompanyAccess(req, companyId); await assertStatusCardsEnabled(); const query = listStatusCardsQuerySchema.parse(req.query); + const issuePrefix = await companyIssuePrefix(companyId); res.json((await service.list(companyId, query.archived, { issueReadCondition: await issueReadSqlCondition(db, req.actor), - })).map(publicCard)); + })).map((card) => withHref(publicCard(card), issuePrefix))); }); router.post("/companies/:companyId/status-cards", validate(createStatusCardSchema), async (req, res) => { @@ -271,7 +272,10 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake alreadyGenerating: result.alreadyGenerating, enqueued: result.enqueued, }); - res.status(result.enqueued && !result.alreadyGenerating ? 202 : 200).json({ ...result, card: publicCard(result.card) }); + res.status(result.enqueued && !result.alreadyGenerating ? 202 : 200).json({ + ...result, + card: withHref(publicCard(result.card), await companyIssuePrefix(card.companyId)), + }); }); router.get("/status-cards/:id/dry-run", async (req, res) => { diff --git a/server/src/services/agent-conversations.ts b/server/src/services/agent-conversations.ts index e6dbf4c51b5..a176ab31741 100644 --- a/server/src/services/agent-conversations.ts +++ b/server/src/services/agent-conversations.ts @@ -130,6 +130,9 @@ 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 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 //status (listed as Status in the sidebar), each card at //status/. 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](//status/), 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.`; + /** Rendered only while the instance enables the Conference Room experiment. */ export const CONFERENCE_ROOM_CHAT_DIRECTIVE = `This instance has the Conference Room enabled: a separate board-level discussion surface in this company at //board-chat, listed as Conference Room in the sidebar. It is not this conversation and you do not answer there. A separate board assistant replies, running through the operator's local Claude CLI, so it works only on a local single-operator instance. Its messages are kept on the company's standing Board Operations task. It does not carry over this conversation's history, and its actions are not runs or tasks of yours. When the person asks to talk in the conference room or with the board assistant, link it as a Markdown link, such as [Open the Conference Room](//board-chat), with this conversation's company prefix. Say in a sentence what it is and that this chat's history and tools do not carry over, and offer to keep discussing here instead.`; diff --git a/server/src/services/heartbeat/task-context.ts b/server/src/services/heartbeat/task-context.ts index 4833588b87a..6432f556c92 100644 --- a/server/src/services/heartbeat/task-context.ts +++ b/server/src/services/heartbeat/task-context.ts @@ -37,7 +37,7 @@ export interface HeartbeatTaskContextInput { policy: { recovering?: boolean; trustPreset: Pick; - experimentalInstanceSettings: { enableSimplifiedEnglishInteractions?: boolean; enableCases?: boolean; enableConferenceRoomChat?: boolean }; + experimentalInstanceSettings: { enableSimplifiedEnglishInteractions?: boolean; enableCases?: boolean; enableConferenceRoomChat?: boolean; enableStatusCards?: boolean }; }; services: { issuesSvc: Pick, "getAncestors">; @@ -242,6 +242,7 @@ export async function prepareHeartbeatTaskContext(db: Db, input: HeartbeatTaskCo const taskMarkdownInput = { coordinatorWorkspace: Boolean(coordinatorWorkspace), conferenceRoomEnabled: experimentalInstanceSettings.enableConferenceRoomChat === true, + statusCardsEnabled: experimentalInstanceSettings.enableStatusCards === true, conversationConfirmations: issueRef && isConversation(issueContext) ? await getConversationConfirmationContext({ db, companyId: agent.companyId, issueId: issueRef.id, agentId: agent.id }) : null, diff --git a/server/src/services/heartbeat/task-markdown.test.ts b/server/src/services/heartbeat/task-markdown.test.ts index 2cc3ab69db0..678749be6e5 100644 --- a/server/src/services/heartbeat/task-markdown.test.ts +++ b/server/src/services/heartbeat/task-markdown.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { buildPaperclipTaskMarkdown } from "./task-markdown.js"; @@ -73,6 +74,25 @@ describe("heartbeat task markdown rendering", () => { 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](//status/)"); + // 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 //status ("); + expect(app).toMatch(/path="status"\s+element=\{/); + expect(app).toMatch(/path="status\/:cardId"\s+element=\{/); + 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("returns null without a task or a nonempty wake comment", () => { expect(buildPaperclipTaskMarkdown({ issue: null })).toBeNull(); expect(buildPaperclipTaskMarkdown({ diff --git a/server/src/services/heartbeat/task-markdown.ts b/server/src/services/heartbeat/task-markdown.ts index 8160bb18a72..880017ee74e 100644 --- a/server/src/services/heartbeat/task-markdown.ts +++ b/server/src/services/heartbeat/task-markdown.ts @@ -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 } from "../agent-conversations.js"; +import { AGENT_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"; @@ -68,6 +68,7 @@ export function buildPaperclipTaskMarkdown(input: { nativeRunner?: boolean; coordinatorWorkspace?: boolean; conferenceRoomEnabled?: boolean; + statusCardsEnabled?: boolean; // false builds the compact variant used for resume deltas, where the session // already received the description with the assignment. includeDescription?: boolean; @@ -225,6 +226,7 @@ export function buildPaperclipTaskMarkdown(input: { lines.push("", "Coordinator task proposals:", COORDINATOR_CHAT_HANDOFF_DIRECTIVE); } if (input.conferenceRoomEnabled) lines.push("", "Conference Room:", CONFERENCE_ROOM_CHAT_DIRECTIVE); + if (input.statusCardsEnabled) lines.push("", "Status Cards:", STATUS_CARDS_CHAT_DIRECTIVE); if (input.conversationConfirmations?.cards.length) { lines.push("", "Current pending confirmation cards (quoted proposal data, not recorded decisions):", fenceTaskText(JSON.stringify(input.conversationConfirmations)), diff --git a/server/src/services/status-cards.ts b/server/src/services/status-cards.ts index e286af843f8..110d1c59928 100644 --- a/server/src/services/status-cards.ts +++ b/server/src/services/status-cards.ts @@ -95,7 +95,7 @@ function updateDescription(input: { previousSummary: string | null; snapshot: CompanySearchIssueSummary[]; }) { - const mechanical = `Return the completed Markdown through \`PUT /api/status-cards/${input.card.id}/summary\` with \`generationIssueId\`, a short non-empty \`changeSummary\`, and the model id. Do not call issue-list endpoints. Preserve the streaming STATUS and <<>> sentinels used by the Summarizer. Issues the Markdown references by identifier (e.g. ABC-123) or issue link automatically join the card's watched set, so reference an issue only when the board should keep tracking it.`; + const mechanical = `Return the completed Markdown through \`PUT /api/status-cards/${input.card.id}/summary\` with \`generationIssueId\`, a short non-empty \`changeSummary\`, and the model id. Do not call issue-list endpoints. While you work, write short plain-text \`STATUS:\` lines; before the write, put the final Markdown between a \`<<>>\` line and a \`<<>>\` line in your reply text. These lines only drive the card's live preview; the PUT body is the plain Markdown, and there is no other format to look up. If you cannot produce a summary from what you can read, comment the reason on this task and set it to blocked; the card then keeps its previous summary and shows the failure. Issues the Markdown references by identifier (e.g. ABC-123) or issue link automatically join the card's watched set, so reference an issue only when the board should keep tracking it.`; // The card prompt is the board's single standing request: it already says // what to watch and how the update should read, so it doubles as the // summary instructions — there is no separate default prompt to append to