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:
parent
b3b07ae44e
commit
d3c004d1b8
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue