fix(chat): tell chat agents what a status card is and to reply now after queuing one

While the Status Cards experiment is on, a standing chat now learns that a
status card is a saved feature on /<prefix>/status, how to create and refresh
one, and that both only queue the summarizer's run, so it replies now with the
card link instead of writing a document or polling the card. The create and
refresh catalog entries say the same, and the refresh response now carries the
card's href.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 committed 2026-11-09 05:26:44 -06:00
1 parent 6bf26629f4
commit fb39622dac
8 files changed
+50 -3

No files matched your search

@@ -604,6 +604,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;
@@ -498,6 +498,8 @@ 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}`);
});
it("requires tasks:assign for mutations", async () => {
+7
View File
@@ -3432,6 +3432,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 /<prefix>/status/<cardId>.",
request: {
params: z.object({ companyId: z.string() }),
body: jsonBody(createStatusCardSchema),
@@ -3485,6 +3489,9 @@ 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 the card's refresh policy deferred it, and card.state says why (such as paused_budget or paused_hours).",
body: refreshStatusCardSchema,
});
+4 -1
View File
@@ -271,7 +271,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) => {
@@ -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 /<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. Reply now with what you queued 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.`;
/** 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 /<prefix>/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](/<prefix>/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.`;
@@ -37,7 +37,7 @@ export interface HeartbeatTaskContextInput {
policy: {
recovering?: boolean;
trustPreset: Pick<TrustPresetResolution, "kind">;
experimentalInstanceSettings: { enableSimplifiedEnglishInteractions?: boolean; enableCases?: boolean; enableConferenceRoomChat?: boolean };
experimentalInstanceSettings: { enableSimplifiedEnglishInteractions?: boolean; enableCases?: boolean; enableConferenceRoomChat?: boolean; enableStatusCards?: boolean };
};
services: {
issuesSvc: Pick<ReturnType<typeof issueService>, "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,
@@ -1,3 +1,4 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { buildPaperclipTaskMarkdown } from "./task-markdown.js";
@@ -73,6 +74,24 @@ 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. Reply now");
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("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 } 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)),