From 97ad08935d5ff5d1e52a0e50eeed131a5e858ea6 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Nov 2026 05:22:58 -0600 Subject: [PATCH] fix(status-cards): link listed cards and report the real refresh outcome in chat The card list now carries each card's href, so an agent that reuses an existing card can link it. The chat directive tells the agent to report what the refresh response says (queued, already running, deferred by policy, or a concurrent refresh that already finished) instead of always saying queued. Co-Authored-By: Claude Opus 5.5 --- server/src/__tests__/status-cards.test.ts | 2 ++ server/src/routes/status-cards.ts | 3 ++- server/src/services/agent-conversations.ts | 2 +- server/src/services/heartbeat/task-markdown.test.ts | 3 ++- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/server/src/__tests__/status-cards.test.ts b/server/src/__tests__/status-cards.test.ts index c32f3f4a233..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); diff --git a/server/src/routes/status-cards.ts b/server/src/routes/status-cards.ts index 57424571853..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) => { diff --git a/server/src/services/agent-conversations.ts b/server/src/services/agent-conversations.ts index 3e67d195cec..815746b95de 100644 --- a/server/src/services/agent-conversations.ts +++ b/server/src/services/agent-conversations.ts @@ -131,7 +131,7 @@ Keep discussion here and leave the conversation available for the next message. /** 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.`; +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-markdown.test.ts b/server/src/services/heartbeat/task-markdown.test.ts index 78d393324fb..678749be6e5 100644 --- a/server/src/services/heartbeat/task-markdown.test.ts +++ b/server/src/services/heartbeat/task-markdown.test.ts @@ -81,7 +81,8 @@ describe("heartbeat task markdown rendering", () => { 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("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");