Fix issue descriptions in structured wake payloads (#10151)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-24 15:40:39 -05:00 committed by GitHub
parent b3b07ae44e
commit d3c004d1b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 118 additions and 1 deletions

View File

@ -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");

View File

@ -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}`);

View File

@ -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,

View File

@ -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<Record<string, unknown>> = [];
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,