diff --git a/server/src/__tests__/status-card-compile-prompt.test.ts b/server/src/__tests__/status-card-compile-prompt.test.ts new file mode 100644 index 00000000000..2faf1336314 --- /dev/null +++ b/server/src/__tests__/status-card-compile-prompt.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { writeStatusCardQuerySchema } from "@paperclipai/shared"; +import { compileDescription } from "../services/status-cards.js"; + +type CompileCard = Parameters[0]; + +const card = { + id: "11111111-1111-4111-8111-111111111111", + companyId: "22222222-2222-4222-8222-222222222222", + interestPrompt: "Blocked launch work updated this week", +} as CompileCard; + +describe("status-card compile prompt", () => { + const generationIssueId = "33333333-3333-4333-8333-333333333333"; + const description = compileDescription(card, generationIssueId, "hash"); + + // The compile task can be assigned to any summarizer agent (built-in or a + // per-card override), and no skill is provisioned for it. Pointing the run at + // a skill it cannot read made weaker models spend the whole run searching + // for it instead of writing the query. + it("does not depend on a skill the summarizer run is not given", () => { + expect(description).not.toContain("status-card-query"); + expect(description).not.toMatch(/\buse the [^.\n]*\bskill\b/i); + }); + + it("inlines the query format and the full write-back sequence", () => { + for (const field of ["scope", "q", "status", "priority", "assigneeAgentId", "projectId", "labelId", "updatedWithin", "sort", "limit", "offset"]) { + expect(description).toContain(`\`${field}\``); + } + expect(description).toContain(`GET /api/companies/${card.companyId}/projects`); + expect(description).toContain(`GET /api/companies/${card.companyId}/labels`); + expect(description).toContain(`PUT /api/status-cards/${card.id}/query`); + expect(description).toContain(`GET /api/status-cards/${card.id}/dry-run`); + expect(description).toContain(`PUT /api/status-cards/${card.id}/summary`); + }); + + it("gives a copy-pasteable example query body and a parseable generation payload", () => { + const example = description.match(/```text\n([\s\S]*?)\n```/)?.[1]; + expect(example).toBeDefined(); + const body = writeStatusCardQuerySchema.parse(JSON.parse(example!)); + expect(body.generationIssueId).toBe(generationIssueId); + const payload = description.match(/```json\n([\s\S]*?)\n```/)?.[1]; + expect(JSON.parse(payload!)).toMatchObject({ operation: "compile", statusCardId: card.id }); + }); +}); diff --git a/server/src/services/status-cards.ts b/server/src/services/status-cards.ts index 669e097c532..e286af843f8 100644 --- a/server/src/services/status-cards.ts +++ b/server/src/services/status-cards.ts @@ -22,7 +22,7 @@ import type { WriteStatusCardQuery, WriteStatusCardSummary, } from "@paperclipai/shared"; -import { companySearchQuerySchema, STATUS_CARD_AGENT_MAX_CARDS } from "@paperclipai/shared"; +import { companySearchQuerySchema, ISSUE_PRIORITIES, ISSUE_STATUSES, STATUS_CARD_AGENT_MAX_CARDS } from "@paperclipai/shared"; import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; import { logger } from "../middleware/logger.js"; import { readBuiltInAgentMarker } from "../lib/built-in-agent-metadata.js"; @@ -119,11 +119,47 @@ function updateDescription(input: { return `Update this Paperclip status card.\n\n${UNTRUSTED_PROMPT_RULE}\n\n${task}${promptBlock}\n\n${mechanical}\n\n## Previous summary\n\n${untrustedPromptBlock("previous-summary", input.previousSummary ?? null)}\n\n## Changed issues\n\n${untrustedPromptBlock("changed-issues", input.changes.map(({ issueId, identifier, title, from, to, changeKind }) => ({ issueId, identifier, title, from, to, changeKind })))}\n\n${input.kind === "full" ? `## Bounded snapshot\n\n${untrustedPromptBlock("bounded-snapshot", input.snapshot.map(({ id, identifier, title, status }) => ({ id, identifier, title, status })))}` : ""}\n\n\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\``; } -function compileDescription(card: StatusCardRow, generationIssueId: string | null, hash: string) { +/** + * Query-compilation guidance inlined into every compile task. The compile task + * can be assigned to any company agent (the built-in Summarizer or a per-card + * override), and nothing provisions the `status-card-query` catalog skill to + * that agent, so the task must carry everything the run needs instead of + * pointing it at a skill it may not have. + */ +function compileQueryGuidance(card: StatusCardRow, generationIssueId: string | null) { + // The persisted task always carries its own issue id (the description is + // rewritten once the issue exists), so the example body is copy-pasteable. + const exampleGenerationIssueId = generationIssueId ?? ""; + return `Everything needed for this task is in this description; no extra skill is required, so do not search for one. + +## Query format + +\`queries\` is an array of 1–10 company-search query objects with union semantics: an issue matching any query belongs to the card. Prefer one narrow query; add another only for genuinely distinct populations. Each object accepts: + +- \`scope\`: always \`"issues"\`. +- \`q\`: optional short free text, only for concepts no structured filter covers. Never paste the whole prompt into it. +- \`status\`: array of ${ISSUE_STATUSES.map((status) => `\`${status}\``).join(", ")}. +- \`priority\`: array of ${ISSUE_PRIORITIES.map((priority) => `\`${priority}\``).join(", ")}. +- \`assigneeAgentId\` / \`assigneeUserId\`: one resolved assignee id. +- \`projectId\` / \`labelId\`: one resolved UUID each. Resolve names with \`GET /api/companies/${card.companyId}/projects\` and \`GET /api/companies/${card.companyId}/labels\`; never put a name in an id field. Use separate query objects for multiple projects or labels. +- \`updatedWithin\`: a bounded window such as \`24h\`, \`7d\`, \`4w\`, or \`3m\`. Add it whenever the prompt implies recency (recent, this week, lately, current). +- \`sort\`: \`relevance\`, \`updated\`, \`created\`, or \`priority\`. +- \`limit\`: 1–50, normally 20. \`offset\`: 0. + +Preserve the prompt's intent; do not broaden it. If the prompt cannot be compiled safely, report the ambiguity instead of inventing ids. + +Example \`PUT /api/status-cards/${card.id}/query\` body: + +\`\`\`text +{"queries":[{"scope":"issues","q":"launch","status":["in_progress","blocked","in_review"],"updatedWithin":"7d","sort":"updated","limit":20,"offset":0}],"title":"Launch work updated this week","changeSummary":"Compiled the prompt into one recent active-work query.","generationIssueId":${JSON.stringify(exampleGenerationIssueId)}} +\`\`\``; +} + +export function compileDescription(card: StatusCardRow, generationIssueId: string | null, hash: string) { const payload = compilePayload(card, generationIssueId, hash); return `Compile this status-card interest prompt into structured Paperclip company-search queries, then continue in the same run and write the first full summary. -Use the bundled \`status-card-query\` skill. Resolve named projects and labels to ids. Keep queries narrow, cap limits, and preserve union semantics across the query array. +${compileQueryGuidance(card, generationIssueId)} ${UNTRUSTED_PROMPT_RULE} @@ -134,7 +170,7 @@ ${untrustedPromptBlock("interest-prompt", card.interestPrompt)} ## Required write-back sequence 1. \`PUT /api/status-cards/${card.id}/query\` with \`queries\`, an auto-title, a non-empty \`changeSummary\`, and \`generationIssueId\`. -2. Execute the compiled scope and write the first full Markdown summary with \`PUT /api/status-cards/${card.id}/summary\` using the same \`generationIssueId\`. Do not create or wait for a second task. +2. Execute the compiled scope with \`GET /api/status-cards/${card.id}/dry-run\`, then write the first full Markdown summary with \`PUT /api/status-cards/${card.id}/summary\` (\`markdown\`, \`title\`, a non-empty \`changeSummary\`, the same \`generationIssueId\`, and the model id). Do not create or wait for a second task. Both writes must happen from this assigned issue run.