diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 7c7071030b..0b59b30919 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -727,7 +727,7 @@ describe("renderPaperclipWakePrompt", () => { ); }); - it("adds the execution contract to scoped wake prompts", () => { + it("leaves the execution contract to the heartbeat template on fresh scoped wake prompts", () => { const prompt = renderPaperclipWakePrompt({ reason: "issue_assigned", issue: { @@ -746,11 +746,101 @@ describe("renderPaperclipWakePrompt", () => { }); expect(prompt).toContain("## Paperclip Wake Payload"); - expect(prompt).toContain("Execution contract: take concrete action in this heartbeat"); - expect(prompt).toContain("clear final disposition"); - expect(prompt).toContain("evidence, not valid liveness paths by themselves"); - expect(prompt).toContain("Use child issues for long or parallel delegated work instead of polling"); - expect(prompt).toContain("named unblock owner/action"); + expect(prompt).not.toContain("Execution contract:"); + expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Execution contract:"); + }); + + it("adds the execution contract to resume delta prompts and opted-in fresh prompts", () => { + const payload = { + reason: "issue_assigned", + issue: { + id: "issue-1", + identifier: "PAP-1580", + title: "Update prompts", + status: "in_progress", + }, + commentWindow: { + requestedCount: 0, + includedCount: 0, + missingCount: 0, + }, + comments: [], + fallbackFetchNeeded: false, + }; + + for (const prompt of [ + renderPaperclipWakePrompt(payload, { resumedSession: true }), + renderPaperclipWakePrompt(payload, { includeExecutionContract: true }), + ]) { + expect(prompt).toContain("Execution contract: take concrete action in this heartbeat"); + expect(prompt).toContain("clear final disposition"); + expect(prompt).toContain("evidence, not valid liveness paths by themselves"); + expect(prompt).toContain("Use child issues for long or parallel delegated work instead of polling"); + expect(prompt).toContain("named unblock owner/action"); + } + }); + + it("keeps exactly one execution contract in a composed fresh heartbeat prompt", () => { + const wakePrompt = renderPaperclipWakePrompt({ + reason: "issue_assigned", + issue: { + id: "issue-1", + identifier: "PAP-1580", + title: "Update prompts", + status: "in_progress", + }, + commentWindow: { + requestedCount: 0, + includedCount: 0, + missingCount: 0, + }, + comments: [], + fallbackFetchNeeded: false, + }); + const composed = [wakePrompt, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE].join("\n\n"); + expect(composed.match(/Execution contract/g)).toHaveLength(1); + }); + + it("trims comment-batch boilerplate on fresh wakes with zero pending comments", () => { + const base = { + reason: "issue_assigned", + issue: { + id: "issue-1", + identifier: "PAP-1580", + title: "Update prompts", + status: "in_progress", + }, + commentWindow: { + requestedCount: 0, + includedCount: 0, + missingCount: 0, + }, + comments: [], + fallbackFetchNeeded: false, + }; + + const zeroCommentPrompt = renderPaperclipWakePrompt(base); + expect(zeroCommentPrompt).not.toContain("acknowledge the latest comment"); + expect(zeroCommentPrompt).not.toContain("Only fetch the API thread"); + expect(zeroCommentPrompt).not.toContain("- pending comments:"); + expect(zeroCommentPrompt).not.toContain("- latest comment id:"); + expect(zeroCommentPrompt).toContain("- fallback fetch needed: no"); + + const commentPrompt = renderPaperclipWakePrompt({ + ...base, + reason: "issue_commented", + commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 }, + comments: [{ id: "comment-1", body: "Please fix", authorType: "user" }], + latestCommentId: "comment-1", + }); + expect(commentPrompt).toContain("acknowledge the latest comment"); + expect(commentPrompt).toContain("Only fetch the API thread"); + expect(commentPrompt).toContain("- pending comments: 1/1"); + expect(commentPrompt).toContain("- latest comment id: comment-1"); + + const fallbackPrompt = renderPaperclipWakePrompt({ ...base, fallbackFetchNeeded: true }); + expect(fallbackPrompt).toContain("Only fetch the API thread"); + expect(fallbackPrompt).toContain("- fallback fetch needed: yes"); }); it("renders the execution workspace branch guard only on non-resumed sessions", () => { diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 5a4c1e04cf..5c5a9c5ee7 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -1263,11 +1263,17 @@ export function readPaperclipIssueWorkModeFromContext(value: unknown): string | export function renderPaperclipWakePrompt( value: unknown, - options: { resumedSession?: boolean } = {}, + options: { resumedSession?: boolean; includeExecutionContract?: boolean } = {}, ): string { const normalized = normalizePaperclipWakePayload(value); if (!normalized) return ""; const resumedSession = options.resumedSession === true; + // The heartbeat prompt template already carries the execution contract on + // fresh sessions; only resume deltas (which replace the template) and + // template-less adapters need the wake-payload copy. + const includeExecutionContract = resumedSession || options.includeExecutionContract === true; + const hasWakeCommentBatch = + normalized.comments.length > 0 || normalized.includedCount > 0 || normalized.requestedCount > 0; const executionStage = normalized.executionStage; const principalLabel = (principal: PaperclipWakeExecutionPrincipal | null) => { if (!principal || !principal.type) return "unknown"; @@ -1294,6 +1300,23 @@ export function renderPaperclipWakePrompt( } }; + const executionContractLines = includeExecutionContract + ? [ + "Execution contract: take concrete action in this heartbeat when the issue is actionable; do not stop at a plan unless planning was requested. Leave durable progress and then give the issue a clear final disposition before ending the heartbeat: `done`, `in_review` with a real reviewer/approval/interaction path, `blocked` with first-class blockers or a named unblock owner/action, delegated follow-up issues with blockers, or `in_progress` only when a live continuation path exists. Use child issues for long or parallel delegated work instead of polling. Comments, documents, screenshots, work products, and `Remaining` bullets are evidence, not valid liveness paths by themselves.", + "", + ] + : []; + const wakeSummaryLines = [ + `- reason: ${normalized.reason ?? "unknown"}`, + `- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`, + ...(hasWakeCommentBatch + ? [ + `- pending comments: ${normalized.includedCount}/${normalized.requestedCount}`, + `- latest comment id: ${normalized.latestCommentId ?? "unknown"}`, + ] + : []), + `- fallback fetch needed: ${normalized.fallbackFetchNeeded ? "yes" : "no"}`, + ]; const lines = resumedSession ? [ "## Paperclip Resume Delta", @@ -1303,30 +1326,24 @@ export function renderPaperclipWakePrompt( "Focus on the new wake delta below and continue the current task without restating the full heartbeat boilerplate.", "Fetch the API thread only when `fallbackFetchNeeded` is true or you need broader history than this batch.", "", - "Execution contract: take concrete action in this heartbeat when the issue is actionable; do not stop at a plan unless planning was requested. Leave durable progress and then give the issue a clear final disposition before ending the heartbeat: `done`, `in_review` with a real reviewer/approval/interaction path, `blocked` with first-class blockers or a named unblock owner/action, delegated follow-up issues with blockers, or `in_progress` only when a live continuation path exists. Use child issues for long or parallel delegated work instead of polling. Comments, documents, screenshots, work products, and `Remaining` bullets are evidence, not valid liveness paths by themselves.", - "", - `- reason: ${normalized.reason ?? "unknown"}`, - `- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`, - `- pending comments: ${normalized.includedCount}/${normalized.requestedCount}`, - `- latest comment id: ${normalized.latestCommentId ?? "unknown"}`, - `- fallback fetch needed: ${normalized.fallbackFetchNeeded ? "yes" : "no"}`, + ...executionContractLines, + ...wakeSummaryLines, ] : [ "## Paperclip Wake Payload", "", "Treat this wake payload as the highest-priority change for the current heartbeat.", "This heartbeat is scoped to the issue below. Do not switch to another issue until you have handled this wake.", - "Before generic repo exploration or boilerplate heartbeat updates, acknowledge the latest comment and explain how it changes your next action.", + ...(hasWakeCommentBatch + ? ["Before generic repo exploration or boilerplate heartbeat updates, acknowledge the latest comment and explain how it changes your next action."] + : []), "Use this inline wake data first before refetching the issue thread.", - "Only fetch the API thread when `fallbackFetchNeeded` is true or you need broader history than this batch.", + ...(hasWakeCommentBatch || normalized.fallbackFetchNeeded + ? ["Only fetch the API thread when `fallbackFetchNeeded` is true or you need broader history than this batch."] + : []), "", - "Execution contract: take concrete action in this heartbeat when the issue is actionable; do not stop at a plan unless planning was requested. Leave durable progress and then give the issue a clear final disposition before ending the heartbeat: `done`, `in_review` with a real reviewer/approval/interaction path, `blocked` with first-class blockers or a named unblock owner/action, delegated follow-up issues with blockers, or `in_progress` only when a live continuation path exists. Use child issues for long or parallel delegated work instead of polling. Comments, documents, screenshots, work products, and `Remaining` bullets are evidence, not valid liveness paths by themselves.", - "", - `- reason: ${normalized.reason ?? "unknown"}`, - `- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`, - `- pending comments: ${normalized.includedCount}/${normalized.requestedCount}`, - `- latest comment id: ${normalized.latestCommentId ?? "unknown"}`, - `- fallback fetch needed: ${normalized.fallbackFetchNeeded ? "yes" : "no"}`, + ...executionContractLines, + ...wakeSummaryLines, ]; if (normalized.issue?.status) { diff --git a/packages/adapters/openclaw-gateway/src/server/execute.ts b/packages/adapters/openclaw-gateway/src/server/execute.ts index 91bef0081e..ab84aa7de0 100644 --- a/packages/adapters/openclaw-gateway/src/server/execute.ts +++ b/packages/adapters/openclaw-gateway/src/server/execute.ts @@ -1084,7 +1084,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise { expect(capture.prompt).toContain("## Paperclip Wake Payload"); expect(capture.prompt).toContain("Do not switch to another issue until you have handled this wake."); expect(capture.prompt).toContain("- issue: PAP-1201 Fix gallery opening for inline images"); - expect(capture.prompt).toContain("- pending comments: 0/0"); + expect(capture.prompt).not.toContain("- pending comments:"); + expect(capture.prompt).not.toContain("acknowledge the latest comment"); + expect(capture.prompt).not.toContain("Execution contract:"); expect(capture.prompt).toContain("- issue status: in_progress"); expect(capture.prompt).toContain("- checkout: already claimed by the harness for this run"); expect(capture.prompt).toContain("The harness already checked out this issue for the current run.");