From fb39622dac115c82231fb833d3c75b606b689643 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Nov 2026 03:48:38 -0600 Subject: [PATCH] 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 //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 --- server/src/__tests__/openapi-routes.test.ts | 10 ++++++++++ server/src/__tests__/status-cards.test.ts | 2 ++ server/src/routes/openapi.ts | 7 +++++++ server/src/routes/status-cards.ts | 5 ++++- server/src/services/agent-conversations.ts | 3 +++ server/src/services/heartbeat/task-context.ts | 3 ++- .../services/heartbeat/task-markdown.test.ts | 19 +++++++++++++++++++ .../src/services/heartbeat/task-markdown.ts | 4 +++- 8 files changed, 50 insertions(+), 3 deletions(-) diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 65f0691b273..ba2e5f14f52 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -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; diff --git a/server/src/__tests__/status-cards.test.ts b/server/src/__tests__/status-cards.test.ts index a0a89a7dd82..a80da72f207 100644 --- a/server/src/__tests__/status-cards.test.ts +++ b/server/src/__tests__/status-cards.test.ts @@ -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 () => { diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 6ebfd100041..a3249e94bd1 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -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 //status/.", 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, }); diff --git a/server/src/routes/status-cards.ts b/server/src/routes/status-cards.ts index b86de76ae97..57424571853 100644 --- a/server/src/routes/status-cards.ts +++ b/server/src/routes/status-cards.ts @@ -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) => { diff --git a/server/src/services/agent-conversations.ts b/server/src/services/agent-conversations.ts index a69d6686022..3e67d195cec 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. Reply now with what you queued 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..78d393324fb 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,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](//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)),