From d3c004d1b8536add73478aed292eb119b7d526a7 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:40:39 -0500 Subject: [PATCH] Fix issue descriptions in structured wake payloads (#10151) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Heartbeat wake payloads provide the scoped task context an agent needs before it can act safely > - Assignment wakes already loaded the issue description for task markdown, but the structured wake-payload builder dropped it > - Agents reading `PAPERCLIP_WAKE_PAYLOAD_JSON` could therefore see a missing brief while also being told no fallback fetch was needed > - Long descriptions also need a bounded representation so wake environments and prompts remain safe > - This pull request carries the description through the server and adapter contract, and marks truncated descriptions as requiring fallback fetch > - The benefit is that agents receive the actual brief instead of inventing requirements from the title ## Linked Issues or Issue Description Fixes: #5844 Fixes: #2882 Related prior attempts: #2883 and #8402. This change adds focused regression coverage and enforces the missing long-description fallback invariant. **Bug:** Issue-assignment wake payloads omitted the issue description from the structured payload even when the issue had a populated description. **Expected behavior:** The structured wake payload includes the issue description. If the description must be truncated for payload size, `fallbackFetchNeeded` is `true`. **Reproduction:** Assign an issue with a description to an agent and inspect `PAPERCLIP_WAKE_PAYLOAD_JSON`; before this change, `issue.description` was absent while `fallbackFetchNeeded` could remain `false`. **Affected version:** Reproduced on current `master` before this patch. **Deployment mode:** Adapter-backed heartbeat execution, including local Codex agents. ## What Changed - Include `issues.description` in the server wake-payload query and supplied issue summaries. - Bound inline descriptions at 12,000 characters and force fallback fetch when truncation occurs. - Preserve and render description metadata through shared adapter normalization and prompt rendering. - Add focused tests for long-description fallback and exact brief-string rendering. ## Verification - `pnpm exec vitest run server/src/__tests__/heartbeat-agent-session-message.test.ts packages/adapter-utils/src/server-utils.test.ts` — 81 tests passed. - `pnpm --filter @paperclipai/adapter-utils typecheck` — passed. - `pnpm --filter @paperclipai/server typecheck` — passed. - `git diff --check` — passed. ## Risks - Low risk: the payload shape is additive. - Very long descriptions are truncated at 12,000 characters; the payload explicitly requests a fallback fetch for the full brief. - Prompt size increases by the issue-description length for scoped wakes, bounded by the same limit. > This is a focused correctness fix and does not overlap with planned roadmap feature work. ## Model Used - OpenAI GPT-5.4 via Codex CLI, with reasoning, repository tool use, shell execution, and test execution. The runtime did not expose a context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../adapter-utils/src/server-utils.test.ts | 57 +++++++++++++++++++ packages/adapter-utils/src/server-utils.ts | 12 ++++ .../heartbeat-agent-session-message.test.ts | 36 ++++++++++++ server/src/services/heartbeat.ts | 14 ++++- 4 files changed, 118 insertions(+), 1 deletion(-) 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,