diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 88f3e872e4..55ae840b6a 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -14,6 +14,7 @@ import { buildPaperclipEnv, buildRuntimeToolsEnv, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, + DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE, materializePaperclipSkillCopy, PAPERCLIP_OPERATIONAL_SKILL_KEY, refreshPaperclipWorkspaceEnvForExecution, @@ -82,6 +83,9 @@ describe("runtime connection tool delivery", () => { expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain( CONNECTION_INTENT_AGENT_GUIDANCE, ); + expect(DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE).toContain(CONNECTION_INTENT_AGENT_GUIDANCE); + expect(DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE).not.toContain("Execution contract:"); + expect(DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE).not.toContain("child issues"); }); }); @@ -893,6 +897,30 @@ describe("runChildProcess", () => { }); describe("renderPaperclipWakePrompt", () => { + it("leaves conversation disposition and accepted-plan handoff to the injected chat policy", () => { + const payload = { + reason: "issue_commented", + issue: { id: "chat", workMode: "planning", status: "in_progress" }, + interactionKind: "request_confirmation", + interactionStatus: "accepted", + comments: [], + commentWindow: { requestedCount: 0, includedCount: 0, missingCount: 0 }, + fallbackFetchNeeded: false, + }; + const ordinary = renderPaperclipWakePrompt(payload, { resumedSession: true }); + expect(ordinary).toContain("Execution contract:"); + expect(ordinary).toContain("Create child issues from the approved plan"); + for (const resumedSession of [false, true]) { + const chat = renderPaperclipWakePrompt(payload, { + resumedSession, conversationMode: true, includeExecutionContract: true, + }); + expect(chat).not.toContain("Execution contract:"); + expect(chat).not.toContain("clear final disposition"); + expect(chat).not.toContain("Create child issues"); + expect(chat).not.toContain("you may create child implementation issues"); + } + }); + it("preserves and renders the issue description in structured wake payloads", () => { const payload = { reason: "issue_assigned", diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 4e49fed2a4..38a297ad75 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -209,6 +209,18 @@ export const DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE = [ CONNECTION_INTENT_AGENT_GUIDANCE, ].join("\n"); +// Chat behavior is supplied centrally by the server's task-context markdown. +// Keep the ordinary task's completion/delegation contract out of this template. +export const DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE = [ + "You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip conversation using the supplied chat mode directive.", + "Use available tools and assigned skills as needed; respect budget, pause/cancel, approval gates, and company boundaries.", + "Prefer the smallest verification that proves the action. Use PAPERCLIP_SCRATCH_DIR / PAPERCLIP_RUN_SCRATCH_DIR for temporary scratch files.", + "After 2 consecutive failures of the same control-plane write, stop retrying that write for the rest of the turn. Report the failure honestly; never claim an unconfirmed mutation succeeded.", + "Never create probe or throwaway issue-thread interactions. Every interaction must carry a real, answerable prompt; withdraw one you no longer need.", + "", + CONNECTION_INTENT_AGENT_GUIDANCE, +].join("\n"); + export const WATCHDOG_DEFAULT_MANDATE = [ "You are running as a task watchdog, not as the original deliverable worker.", "Your mission is to keep the watched issue tree moving by verifying stopped work, not by trusting agent claims.", @@ -1552,6 +1564,9 @@ export function renderPaperclipWakePrompt( options: { resumedSession?: boolean; includeExecutionContract?: boolean; + // Conversation policy arrives in the server-owned task markdown. Generic + // task disposition and child-delegation instructions conflict with it. + conversationMode?: boolean; // Set by adapters whose prompt already carries the task-context markdown // (the authoritative, uncapped brief) so the description is not delivered // twice in one prompt. @@ -1564,7 +1579,8 @@ export function renderPaperclipWakePrompt( // 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 includeExecutionContract = options.conversationMode !== true && + (resumedSession || options.includeExecutionContract === true); const hasWakeCommentBatch = normalized.comments.length > 0 || normalized.includedCount > 0 || normalized.requestedCount > 0; const executionStage = normalized.executionStage; @@ -1749,7 +1765,7 @@ export function renderPaperclipWakePrompt( lines.push(`- checkbox selection ids: ${selectedOptionIds}`); lines.push(`- checkbox selection options: ${selectedOptions}`); } - if (normalized.issue?.workMode === "planning" && !normalized.taskWatchdog) { + if (normalized.issue?.workMode === "planning" && !normalized.taskWatchdog && options.conversationMode !== true) { const hasWakeComments = normalized.comments.length > 0; const acceptedPlanContinuation = !hasWakeComments && diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 02230de87a..f9456716e3 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -50,6 +50,7 @@ import { shapePaperclipWorkspaceEnvForExecution, stringifyPaperclipWakePayload, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, + DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE, } from "@paperclipai/adapter-utils/server-utils"; import { buildSkillLibraryManifestMarkdown } from "@paperclipai/adapter-utils/skill-library-manifest"; import { @@ -428,7 +429,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0, diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 7a09fc0489..efd9942ddd 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -45,9 +45,11 @@ import { readPaperclipIssueWorkModeFromContext, renderTemplate, renderPaperclipWakePrompt, + selectPaperclipTaskMarkdown, isPaperclipRecoveryWakePayload, stringifyPaperclipWakePayload, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, + DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE, joinPromptSections, } from "@paperclipai/adapter-utils/server-utils"; import { @@ -587,7 +589,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? renderTemplate(bootstrapPromptTemplate, templateData).trim() : ""; - const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) }); + const taskContextNote = selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId) }); + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { + resumedSession: Boolean(sessionId), + conversationMode: context.conversationMode === true, + suppressIssueDescription: taskContextNote.length > 0, + }); const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0; const promptInstructionsPrefix = shouldUseResumeDeltaPrompt ? "" : instructionsPrefix; instructionsChars = promptInstructionsPrefix.length; @@ -1188,6 +1197,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { const script = `#!/usr/bin/env node @@ -1210,7 +1212,7 @@ process.exit(1); } }); - it("uses a compact wake delta instead of the full heartbeat prompt when resuming a session", async () => { + it.each([{ conversationMode: false, resumedSession: true }, { conversationMode: true, resumedSession: true }, { conversationMode: true, resumedSession: false }])("retains current task policy (conversation=$conversationMode, resumed=$resumedSession)", async ({ conversationMode, resumedSession }) => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-resume-wake-")); const workspace = path.join(root, "workspace"); const commandPath = path.join(root, "codex"); @@ -1224,6 +1226,14 @@ process.exit(1); process.env.HOME = root; await seedSharedCodexAuth(root); + const policy = conversationMode + ? buildPaperclipTaskMarkdown({ + issue: { id: "issue-1", title: "Chat", workMode: "planning", conversationAgentId: "agent-1" }, + interaction: { kind: "request_confirmation", status: "rejected" }, + planReview: { status: "rejected", reason: "Revise the final note." }, + includeDescription: false, + }) + : "Current ordinary task policy"; let invocationPrompt = ""; let invocationNotes: string[] = []; let promptMetrics: Record = {}; @@ -1240,7 +1250,7 @@ process.exit(1); runtime: { sessionId: null, sessionParams: { - sessionId: "codex-session-1", + sessionId: resumedSession ? "codex-session-1" : null, cwd: workspace, }, sessionDisplayId: null, @@ -1254,9 +1264,12 @@ process.exit(1); env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath, }, - promptTemplate: "Follow the paperclip heartbeat.", + promptTemplate: conversationMode ? undefined : "Follow the paperclip heartbeat.", }, context: { + conversationMode, + paperclipTaskMarkdown: `Full description that must not replay\n${policy}`, + paperclipTaskMarkdownCompact: policy, issueId: "issue-1", taskId: "issue-1", wakeReason: "issue_commented", @@ -1304,18 +1317,35 @@ process.exit(1); expect(result.errorMessage).toBeNull(); const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload; - expect(capture.argv).toEqual(expect.arrayContaining(["resume", "codex-session-1", "-"])); - expect(capture.prompt).toContain("## Paperclip Resume Delta"); + if (resumedSession) expect(capture.argv).toEqual(expect.arrayContaining(["resume", "codex-session-1", "-"])); + else expect(capture.argv).not.toContain("resume"); + expect(capture.prompt).toContain(resumedSession ? "## Paperclip Resume Delta" : "## Paperclip Wake Payload"); expect(capture.prompt).toContain("Do not switch to another issue until you have handled this wake."); expect(capture.prompt).toContain("Second comment"); + expect(capture.prompt).toContain(policy); + expect(invocationPrompt).toContain(policy); + if (resumedSession) expect(capture.prompt).not.toContain("Full description that must not replay"); + else expect(capture.prompt).toContain("Full description that must not replay"); + expect(promptMetrics.taskContextChars).toBe(resumedSession ? policy.length : `Full description that must not replay\n${policy}`.length); + if (conversationMode) { + expect(invocationPrompt).toContain(AGENT_CHAT_DIRECTIVE); + expect(invocationPrompt).toContain("baseRevisionId set to that latestRevisionId"); + expect(capture.prompt).not.toContain("Execution contract:"); + expect(capture.prompt).not.toContain("Use child issues"); + } else { + expect(capture.prompt).toContain("Execution contract:"); + } expect(capture.prompt).not.toContain("Follow the paperclip heartbeat."); - expect(capture.prompt).not.toContain("You are managed instructions."); - expect(invocationPrompt).toContain("## Paperclip Resume Delta"); - expect(invocationNotes).toContain( - "Skipped stdin instruction reinjection because an existing Codex session is being resumed with a wake delta.", - ); - expect(promptMetrics.instructionsChars).toBe(0); - expect(promptMetrics.heartbeatPromptChars).toBe(0); + if (resumedSession) { + expect(capture.prompt).not.toContain("You are managed instructions."); + expect(invocationPrompt).toContain("## Paperclip Resume Delta"); + expect(invocationNotes).toContain("Skipped stdin instruction reinjection because an existing Codex session is being resumed with a wake delta."); + expect(promptMetrics.instructionsChars).toBe(0); + expect(promptMetrics.heartbeatPromptChars).toBe(0); + } else { + expect(capture.prompt).toContain("You are managed instructions."); + expect(promptMetrics.heartbeatPromptChars).toBeGreaterThan(0); + } } finally { if (previousHome === undefined) delete process.env.HOME; else process.env.HOME = previousHome; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index f4cb769a15..53526fa691 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -20248,7 +20248,7 @@ export function heartbeatService( ...nativeExecution, task: { ...nativeExecution.task, - prompt: `${nativeExecution.task.prompt}\n\n${renderPaperclipWakePrompt({ executionContinuation }, { resumedSession: true })}`, + prompt: `${nativeExecution.task.prompt}\n\n${renderPaperclipWakePrompt({ executionContinuation }, { resumedSession: true, conversationMode: context.conversationMode === true })}`, }, }); } @@ -20376,6 +20376,7 @@ export function heartbeatService( `# ${issueRef.identifier ?? issueRef.id}: ${issueRef.title}`, wakePayload: context.paperclipWake, resumedSession: previousNativeRun !== null, + conversationMode: context.conversationMode === true, agentId: agent.id, workspace: { // Projectless paperclip_runner tasks still have a resolved local cwd. Bind that diff --git a/server/src/services/native-runtime/native-execution-input.ts b/server/src/services/native-runtime/native-execution-input.ts index 58c1b10b21..97b5325bad 100644 --- a/server/src/services/native-runtime/native-execution-input.ts +++ b/server/src/services/native-runtime/native-execution-input.ts @@ -35,6 +35,7 @@ export function buildNativeExecutionInput(input: { */ wakePayload?: unknown; resumedSession?: boolean; + conversationMode?: boolean; agentId: string; workspace: { id: string; @@ -89,6 +90,7 @@ export function buildNativeExecutionInput(input: { : null; const wakePrompt = renderPaperclipWakePrompt(input.wakePayload, { resumedSession: input.resumedSession === true, + conversationMode: input.conversationMode === true, suppressIssueDescription: input.taskPrompt.trim().length > 0, }); const taskPrompt = [wakePrompt, input.taskPrompt.trim()] diff --git a/server/src/services/native-runtime/native-session-resume.test.ts b/server/src/services/native-runtime/native-session-resume.test.ts index 72533ea00e..3f2b1d5342 100644 --- a/server/src/services/native-runtime/native-session-resume.test.ts +++ b/server/src/services/native-runtime/native-session-resume.test.ts @@ -405,7 +405,7 @@ describe("buildNativeExecutionInput wake projection", () => { .not.toMatch(/OPENAI_API_KEY|ANTHROPIC_API_KEY|AWS_SECRET_ACCESS_KEY|PAPERCLIP_API_KEY/); }); - it("places child completion summaries in the closed provider prompt", () => { + it.each([false, true])("projects wake context with the appropriate execution contract (conversation=%s)", (conversationMode) => { const input = buildNativeExecutionInput({ companyId, runId: currentRunId, @@ -439,6 +439,7 @@ describe("buildNativeExecutionInput wake projection", () => { checkedOutByHarness: true, }, resumedSession: true, + conversationMode, agentId, workspace: { id: currentRunId, @@ -463,6 +464,8 @@ describe("buildNativeExecutionInput wake projection", () => { runtimeContext: nativeRuntimeContextFixture(), }); + expect(input.task.prompt.includes("Execution contract:")).toBe(!conversationMode); + expect(input.task.prompt.includes("Use child issues")).toBe(!conversationMode); expect(input.task.prompt).toContain("## Paperclip Resume Delta"); expect(input.task.prompt).toContain("reason: issue_children_completed"); expect(input.task.prompt).toContain("DOT-147 Build utility (done)");