diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 82885e56f1..7fc4c02be6 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -708,6 +708,63 @@ describe("runChildProcess", () => { }); describe("renderPaperclipWakePrompt", () => { + it("preserves and renders the issue description in structured wake payloads", () => { + const payload = { + reason: "issue_assigned", + issue: { + id: "issue-1", + identifier: "PAP-15271", + title: "Preserve the task brief", + description: "Update launch-card.svg and change the CTA to Try Team free.", + descriptionTruncated: false, + status: "in_progress", + }, + commentWindow: { + requestedCount: 0, + includedCount: 0, + missingCount: 0, + }, + comments: [], + fallbackFetchNeeded: false, + }; + + expect(JSON.parse(stringifyPaperclipWakePayload(payload) ?? "{}")).toMatchObject({ + issue: { + description: "Update launch-card.svg and change the CTA to Try Team free.", + descriptionTruncated: false, + }, + }); + expect(renderPaperclipWakePrompt(payload)).toContain( + "Issue description:\n```text\nUpdate launch-card.svg and change the CTA to Try Team free.\n```", + ); + }); + + it("omits whitespace-only issue descriptions from structured wake prompts", () => { + const payload = { + reason: "issue_assigned", + issue: { + id: "issue-1", + identifier: "PAP-15271", + title: "Preserve the task brief", + description: " \n\t", + descriptionTruncated: false, + status: "in_progress", + }, + commentWindow: { + requestedCount: 0, + includedCount: 0, + missingCount: 0, + }, + comments: [], + fallbackFetchNeeded: false, + }; + + expect(JSON.parse(stringifyPaperclipWakePayload(payload) ?? "{}")).toMatchObject({ + issue: { description: null }, + }); + expect(renderPaperclipWakePrompt(payload)).not.toContain("Issue description:"); + }); + it("keeps the default local-agent prompt action-oriented", () => { expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Start actionable work in this heartbeat"); expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("do not stop at a plan"); diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 3d4192901b..cd6a605f54 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -438,6 +438,8 @@ type PaperclipWakeIssue = { id: string | null; identifier: string | null; title: string | null; + description: string | null; + descriptionTruncated: boolean; status: string | null; workMode: string | null; priority: string | null; @@ -727,6 +729,8 @@ function normalizePaperclipWakeIssue(value: unknown): PaperclipWakeIssue | null const id = asString(issue.id, "").trim() || null; const identifier = asString(issue.identifier, "").trim() || null; const title = asString(issue.title, "").trim() || null; + const rawDescription = typeof issue.description === "string" ? issue.description : null; + const description = rawDescription?.trim() ? rawDescription : null; const status = asString(issue.status, "").trim() || null; const workMode = asString(issue.workMode, "").trim() || null; const priority = asString(issue.priority, "").trim() || null; @@ -735,6 +739,8 @@ function normalizePaperclipWakeIssue(value: unknown): PaperclipWakeIssue | null id, identifier, title, + description, + descriptionTruncated: asBoolean(issue.descriptionTruncated, false), status, workMode, priority, @@ -1488,6 +1494,12 @@ export function renderPaperclipWakePrompt( if (normalized.issue?.priority) { lines.push(`- issue priority: ${normalized.issue.priority}`); } + if (normalized.issue?.description !== null && normalized.issue?.description !== undefined) { + lines.push("", "Issue description:", markdownFencedText(normalized.issue.description)); + if (normalized.issue.descriptionTruncated) { + lines.push("[issue description truncated; fetch the issue for the full brief]"); + } + } if (normalized.checkboxSelection) { if (normalized.checkboxSelection.prompt) { lines.push(`- checkbox prompt: ${normalized.checkboxSelection.prompt}`); diff --git a/server/src/__tests__/heartbeat-agent-session-message.test.ts b/server/src/__tests__/heartbeat-agent-session-message.test.ts index c5fe12d0fa..5fc0e5602b 100644 --- a/server/src/__tests__/heartbeat-agent-session-message.test.ts +++ b/server/src/__tests__/heartbeat-agent-session-message.test.ts @@ -3,6 +3,42 @@ import { renderPaperclipWakePrompt } from "@paperclipai/adapter-utils/server-uti import { buildPaperclipWakePayload } from "../services/heartbeat.js"; describe("agent session wake messages", () => { + it("includes the issue brief and requires fallback fetch when a long description is truncated", async () => { + const description = [ + "Update launch-card.svg and change the CTA to Try Team free.", + "x".repeat(13_000), + ].join("\n"); + + const wakePayload = await buildPaperclipWakePayload({ + db: {} as never, + companyId: "company-1", + contextSnapshot: { + wakeReason: "issue_assigned", + issueId: "issue-1", + }, + issueSummary: { + id: "issue-1", + identifier: "PAP-15271", + title: "Preserve the task brief", + description, + status: "in_progress", + priority: "high", + workMode: "standard", + }, + }); + + expect(wakePayload?.issue).toMatchObject({ + description: expect.stringContaining("launch-card.svg"), + descriptionTruncated: true, + }); + expect(wakePayload?.issue?.description).toContain("Try Team free"); + expect(wakePayload?.issue?.description).toHaveLength(12_000); + expect(wakePayload).toMatchObject({ + truncated: true, + fallbackFetchNeeded: true, + }); + }); + it("turns the canonical session-message context into adapter prompt input", async () => { const wakePayload = await buildPaperclipWakePayload({ db: {} as never, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index f8f7f403f1..04f20dfb0d 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -325,6 +325,7 @@ const MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS = 10 * 60 * 1000; const MAX_INLINE_WAKE_COMMENTS = 8; const MAX_INLINE_WAKE_COMMENT_BODY_CHARS = 4_000; const MAX_INLINE_WAKE_COMMENT_BODY_TOTAL_CHARS = 12_000; +const MAX_INLINE_WAKE_ISSUE_DESCRIPTION_CHARS = 12_000; const MAX_AGENT_SESSION_MESSAGE_CHARS = 12_000; const execFile = promisify(execFileCallback); const EXECUTION_PATH_HEARTBEAT_RUN_STATUSES = ["queued", "running", "scheduled_retry"] as const; @@ -4412,6 +4413,7 @@ export async function buildPaperclipWakePayload(input: { id: string; identifier: string | null; title: string; + description: string | null; status: string; priority: string; workMode: string; @@ -4436,6 +4438,7 @@ export async function buildPaperclipWakePayload(input: { id: issues.id, identifier: issues.identifier, title: issues.title, + description: issues.description, status: issues.status, priority: issues.priority, workMode: issues.workMode, @@ -4481,6 +4484,12 @@ export async function buildPaperclipWakePayload(input: { ); const commentsById = new Map(commentRows.map((comment) => [comment.id, comment])); + const issueDescription = issueSummary?.description ?? null; + const issueDescriptionTruncated = + issueDescription !== null && issueDescription.length > MAX_INLINE_WAKE_ISSUE_DESCRIPTION_CHARS; + const inlineIssueDescription = issueDescriptionTruncated + ? issueDescription.slice(0, MAX_INLINE_WAKE_ISSUE_DESCRIPTION_CHARS) + : issueDescription; const comments: Array> = []; let remainingBodyChars = MAX_INLINE_WAKE_COMMENT_BODY_TOTAL_CHARS; let truncated = false; @@ -4607,7 +4616,7 @@ export async function buildPaperclipWakePayload(input: { interactionId, }) : null; - const payloadTruncated = truncated || planReviewContext?.truncated === true; + const payloadTruncated = truncated || issueDescriptionTruncated || planReviewContext?.truncated === true; const recoveryActionId = readNonEmptyString(input.contextSnapshot.recoveryActionId); const recoveryCause = readNonEmptyString(input.contextSnapshot.recoveryCause); const recoveryAction = recoveryActionId @@ -4652,6 +4661,8 @@ export async function buildPaperclipWakePayload(input: { id: issueSummary.id, identifier: issueSummary.identifier, title: issueSummary.title, + description: inlineIssueDescription, + descriptionTruncated: issueDescriptionTruncated, status: issueSummary.status, priority: issueSummary.priority, workMode: issueSummary.workMode, @@ -12149,6 +12160,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) id: issueRef.id, identifier: issueRef.identifier, title: issueRef.title, + description: issueContext?.description ?? null, status: issueRef.status, priority: issueRef.priority, workMode: issueRef.workMode,