From 380040082e5a135be9bf896512b924fdeae2926c Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 11 Sep 2026 17:41:19 -0500 Subject: [PATCH] fix(adapters): preserve conversation policy across provider paths Co-Authored-By: Paperclip --- .../src/acpx-engine/execute.test.ts | 49 ++++++++++++++++++- .../adapter-utils/src/acpx-engine/execute.ts | 6 ++- .../adapter-utils/src/server-utils.test.ts | 28 +++++++++++ packages/adapter-utils/src/server-utils.ts | 30 +++++++++--- .../claude-local/src/server/execute.ts | 6 ++- .../adapters/claude-local/src/server/index.ts | 4 ++ .../codex-local/src/server/execute.ts | 15 +++++- .../cursor-cloud/src/server/execute.test.ts | 28 ++++++++++- .../cursor-cloud/src/server/execute.ts | 17 ++++++- .../cursor-local/src/server/execute.ts | 17 ++++++- .../gemini-local/src/server/execute.ts | 17 ++++++- .../adapters/grok-local/src/server/execute.ts | 17 ++++++- .../hermes/src/gateway/server/execute.test.ts | 34 +++++++++++++ .../hermes/src/gateway/server/execute.ts | 8 ++- .../adapters/hermes/src/server/execute.ts | 7 ++- .../src/server/prompt-rendering.test.ts | 20 ++++++++ .../adapters/kimi-local/src/server/execute.ts | 17 ++++++- .../src/server/execute-dispatch.test.ts | 33 ++++++++++++- .../openclaw-gateway/src/server/execute.ts | 19 +++++++ .../src/server/execute.remote.test.ts | 6 ++- .../opencode-local/src/server/execute.test.ts | 47 ++++++++++++++++++ .../opencode-local/src/server/execute.ts | 17 ++++++- .../src/server/test.remote.test.ts | 16 +++++- .../adapters/pi-local/src/server/execute.ts | 21 ++++++-- 24 files changed, 443 insertions(+), 36 deletions(-) diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index 1e3b8abd00..cd8fb3be91 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -161,6 +161,7 @@ async function runExecutor( config: Record, options: { context?: Record; + runtime?: Record; executionTransport?: Record; authToken?: string; executionTarget?: Record; @@ -194,7 +195,7 @@ async function runExecutor( id: "agent-1", companyId: "company-1", }, - runtime: {}, + runtime: options.runtime ?? {}, config, context: options.context ?? {}, executionTransport: options.executionTransport, @@ -592,6 +593,52 @@ describe("shared ACPX engine runtime behavior", () => { expect(promptMetrics?.runtimeNoteChars).toBeGreaterThan(0); }); + it.each([ + ["claude", false], ["codex", false], ["claude", true], ["codex", true], + ] as const)("keeps %s ACP conversation policy on fresh, resumed, and reset turns (custom=%s)", async (agent, custom) => { + const root = await makeTempRoot(); + const config = { agent, cwd: root, stateDir: path.join(root, "state"), mode: "persistent", + ...(custom ? { promptTemplate: "Custom agent instructions." } : {}), + }; + const chatDirective = "Chat mode: clarify goals and hand accepted plans off to ordinary project tasks."; + const context = { + conversationMode: true, + taskId: "chat-1", + paperclipTaskMarkdown: chatDirective, + paperclipTaskMarkdownCompact: chatDirective, + paperclipWake: { + reason: "issue_commented", + issue: { id: "chat-1", workMode: "planning", status: "in_progress" }, + interactionKind: "request_confirmation", + interactionStatus: "accepted", + comments: [], + commentWindow: { requestedCount: 0, includedCount: 0, missingCount: 0 }, + fallbackFetchNeeded: false, + }, + }; + const fresh = await runExecutor(config, { context }); + const resumed = await runExecutor(config, { + context, + runtime: { sessionParams: fresh.result.sessionParams }, + }); + expect(resumed.sessionInputs[0]?.resumeSessionId).toBe(fresh.result.sessionId); + const reset = await runExecutor(config, { context }); + expect(reset.sessionInputs[0]?.resumeSessionId).toBeUndefined(); + for (const { meta } of [fresh, resumed, reset]) { + const prompt = String(meta[0]?.prompt ?? ""); + expect(prompt).toContain(chatDirective); + expect(prompt).not.toContain("Execution contract:"); + expect(prompt).not.toContain("clear final disposition"); + expect(prompt).not.toContain("Create child issues"); + expect(prompt).not.toContain("Use child issues"); + } + expect(String(fresh.meta[0]?.prompt)).toContain(custom ? "Custom agent instructions." : "Continue your Paperclip conversation"); + expect(String(reset.meta[0]?.prompt)).toContain(custom ? "Custom agent instructions." : "Continue your Paperclip conversation"); + const ordinary = await runExecutor({ ...config, promptTemplate: "" }, { context: { ...context, conversationMode: false } }); + expect(String(ordinary.meta[0]?.prompt)).toContain("Execution contract:"); + expect(String(ordinary.meta[0]?.prompt)).toContain("Create child issues from the approved plan"); + }); + it("uses only the guarded external-chat contract for a default ACPX prompt", async () => { const { meta } = await runExecutor( { agent: "custom", agentCommand: "node ./fake-acp.js" }, diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index b4971bd718..d5a9b9eea9 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -45,6 +45,7 @@ import { } from "../workspace-restore-merge.js"; import { DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, + DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE, applyPaperclipWorkspaceEnv, asNumber, asString, @@ -2923,7 +2924,9 @@ async function buildPrompt(ctx: AdapterExecutionContext, resumedSession: boolean const hasCustomPromptTemplate = configuredPromptTemplate.trim().length > 0; const promptTemplate = hasCustomPromptTemplate ? configuredPromptTemplate - : DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE; + : context.conversationMode === true + ? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE + : DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE; const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : ""; let instructionsPrefix = ""; @@ -2967,6 +2970,7 @@ async function buildPrompt(ctx: AdapterExecutionContext, resumedSession: boolean const externalChatTurn = isPaperclipExternalChatTurn(context.paperclipWake); const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession, + conversationMode: context.conversationMode === true, // The task-context markdown is the authoritative brief on this lane; keep // the wake prompt's description copy out so the prompt carries it once. suppressIssueDescription: taskContextNote.length > 0, diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index bc866b21cc..c2b6e44887 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -15,6 +15,7 @@ import { buildPaperclipEnv, buildRuntimeToolsEnv, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, + DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE, isPaperclipExternalChatContractTurn, isPaperclipExternalChatQuestionResponseTurn, isPaperclipExternalChatTurn, @@ -86,6 +87,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"); }); }); @@ -908,6 +912,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"); + } + }); + const ordinaryExternalChatWake = { reason: "External chat message received", externalChatProvider: " GitHub ", diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 0ae46e98d6..465d0969c5 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -229,6 +229,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.", @@ -2180,6 +2192,9 @@ function renderPaperclipWakePromptBody( 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; nativeWakeReaderAvailable?: boolean; // Set by adapters whose prompt already carries the task-context markdown // (the authoritative, uncapped brief) so the description is not delivered @@ -2203,8 +2218,8 @@ function renderPaperclipWakePromptBody( // 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 || @@ -2499,7 +2514,7 @@ function renderPaperclipWakePromptBody( 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 && @@ -2646,7 +2661,7 @@ function renderPaperclipWakePromptBody( "", "Open plan comments to incorporate:", "These open plan annotations are user feedback. Resolved annotations were intentionally omitted.", - "Read this before revising the plan or creating child issues from an accepted plan.", + "Read this before revising the plan or acting on an accepted plan.", ); if (context.latestRevisionNumber || context.latestRevisionId) { lines.push( @@ -2654,9 +2669,10 @@ function renderPaperclipWakePromptBody( ); } if (context.interaction) { - lines.push( - `- interaction: ${context.interaction.kind ?? "unknown"} ${context.interaction.status ?? "unknown"}`, - ); + lines.push(`- interaction: ${context.interaction.kind ?? "unknown"} ${context.interaction.status ?? "unknown"}`); + if (context.interaction.status === "rejected") { + lines.push("The user requested changes to this plan. Revise it using the feedback below; this is not approval to implement or hand off execution tasks. In Ask mode, discuss the requested changes without mutating documents or tasks."); + } if (context.interaction.result) { const result = context.interaction.result; lines.push( 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/claude-local/src/server/index.ts b/packages/adapters/claude-local/src/server/index.ts index bb677eb6f1..7450244b2d 100644 --- a/packages/adapters/claude-local/src/server/index.ts +++ b/packages/adapters/claude-local/src/server/index.ts @@ -82,6 +82,7 @@ export const sessionCodec: AdapterSessionCodec = { const promptBundleKey = readNonEmptyString(record.promptBundleKey) ?? readNonEmptyString(record.prompt_bundle_key); + const mcpServerIdentity = readNonEmptyString(record.mcpServerIdentity); const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id); const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url); const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref); @@ -89,6 +90,7 @@ export const sessionCodec: AdapterSessionCodec = { sessionId, ...(cwd ? { cwd } : {}), ...(promptBundleKey ? { promptBundleKey } : {}), + ...(mcpServerIdentity ? { mcpServerIdentity } : {}), ...(workspaceId ? { workspaceId } : {}), ...(repoUrl ? { repoUrl } : {}), ...(repoRef ? { repoRef } : {}), @@ -105,6 +107,7 @@ export const sessionCodec: AdapterSessionCodec = { const promptBundleKey = readNonEmptyString(params.promptBundleKey) ?? readNonEmptyString(params.prompt_bundle_key); + const mcpServerIdentity = readNonEmptyString(params.mcpServerIdentity); const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id); const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url); const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref); @@ -112,6 +115,7 @@ export const sessionCodec: AdapterSessionCodec = { sessionId, ...(cwd ? { cwd } : {}), ...(promptBundleKey ? { promptBundleKey } : {}), + ...(mcpServerIdentity ? { mcpServerIdentity } : {}), ...(workspaceId ? { workspaceId } : {}), ...(repoUrl ? { repoUrl } : {}), ...(repoRef ? { repoRef } : {}), diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 70152abe3f..006d7ea05e 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; @@ -1202,6 +1211,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise sendRun), + send: vi.fn(async (_prompt: string, _options?: Record) => sendRun), [Symbol.asyncDispose]: vi.fn(async () => {}), }; } @@ -142,6 +142,32 @@ describe("cursor_cloud execute", () => { getRunMock.mockReset(); }); + it.each([false, true])("sends the central chat directive to Cursor Cloud (custom=%s)", async (custom) => { + const sdkAgent = createMockSdkAgent(); + createMock.mockResolvedValue(sdkAgent); + const ctx = createContext(); + if (!custom) delete ctx.config.promptTemplate; + const directive = "Chat directive: clarify goals and hand plans off to project tasks."; + ctx.context = { + ...ctx.context, + conversationMode: true, + paperclipTaskMarkdown: directive, + paperclipWake: { + reason: "issue_commented", + issue: { id: "issue-1", workMode: "planning", status: "in_progress" }, + interactionKind: "request_confirmation", + interactionStatus: "accepted", + }, + }; + const result = await execute(ctx); + expect(result.exitCode).toBe(0); + const prompt = String(sdkAgent.send.mock.calls[0]?.[0]); + expect(prompt).toContain(directive); + expect(prompt).toContain(custom ? "Do the work for" : "Continue your Paperclip conversation"); + expect(prompt).not.toContain("Execution contract:"); + expect(prompt).not.toContain("Create child issues"); + }); + it("creates a fresh Cursor agent and injects Paperclip env without CURSOR_API_KEY", async () => { const run = createMockRun({ agentId: "agent-fresh", diff --git a/packages/adapters/cursor-cloud/src/server/execute.ts b/packages/adapters/cursor-cloud/src/server/execute.ts index b3300e50b6..f157b55545 100644 --- a/packages/adapters/cursor-cloud/src/server/execute.ts +++ b/packages/adapters/cursor-cloud/src/server/execute.ts @@ -12,6 +12,7 @@ import { import type { AdapterExecutionContext, AdapterExecutionResult, AdapterInvocationMeta } from "@paperclipai/adapter-utils"; import { DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, + DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE, asBoolean, asString, buildPaperclipEnv, @@ -20,6 +21,7 @@ import { parseObject, readPaperclipIssueWorkModeFromContext, renderPaperclipWakePrompt, + selectPaperclipTaskMarkdown, isPaperclipRecoveryWakePayload, renderTemplate, stringifyPaperclipWakePayload, @@ -400,7 +402,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0, + }); const renderedBootstrapPrompt = !canReuseSession && bootstrapPromptTemplate.trim().length > 0 ? renderTemplate(bootstrapPromptTemplate, templateData).trim() @@ -426,6 +437,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? renderTemplate(bootstrapPromptTemplate, templateData).trim() : ""; - const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) }); + const taskContextNote = context.conversationMode === true + ? selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId) }) + : ""; + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { + conversationMode: context.conversationMode === true, + resumedSession: Boolean(sessionId), + suppressIssueDescription: taskContextNote.length > 0, + }); const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0; const renderedPrompt = shouldUseResumeDeltaPrompt || isPaperclipRecoveryWakePayload(context.paperclipWake) ? "" @@ -577,6 +588,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? renderTemplate(bootstrapPromptTemplate, templateData).trim() : ""; - const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) }); + const taskContextNote = context.conversationMode === true + ? selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId) }) + : ""; + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { + conversationMode: context.conversationMode === true, + resumedSession: Boolean(sessionId), + suppressIssueDescription: taskContextNote.length > 0, + }); const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0; const renderedPrompt = shouldUseResumeDeltaPrompt || isPaperclipRecoveryWakePayload(context.paperclipWake) ? "" @@ -567,6 +578,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0, + }); const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0; const renderedPrompt = shouldUseResumeDeltaPrompt || isPaperclipRecoveryWakePayload(context.paperclipWake) ? "" @@ -484,6 +495,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { expect(body.session_id).toBe("paperclip:company:company-1:agent:agent-1:issue:issue-1"); }); + it.each([false, true])("preserves chat handoff policy on gateway turns (resumed=%s)", async (resumed) => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => new Response(JSON.stringify( + String(input).endsWith("/v1/runs") + ? { run_id: "run-hermes-1", status: "started" } + : { status: "completed", output: "done" }, + ), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + const ctx = makeCtx({ apiBaseUrl: "http://127.0.0.1:8642", apiKey: "secret-key", timeoutSec: 5 }); + ctx.config.payloadTemplate = { input: "Custom gateway instruction." }; + const directive = "Chat directive: clarify goals and hand plans off to project tasks."; + ctx.context = { + conversationMode: true, + issueId: "issue-1", + paperclipTaskMarkdown: directive, + paperclipTaskMarkdownCompact: directive, + paperclipWake: { + reason: "issue_commented", + issue: { id: "issue-1", workMode: "planning", status: "in_progress" }, + interactionKind: "request_confirmation", + interactionStatus: "accepted", + }, + }; + if (resumed) ctx.runtime.sessionId = "prior-session"; + await execute(ctx); + const calls = fetchMock.mock.calls as Array<[RequestInfo | URL, RequestInit?]>; + const call = calls.find(([input]) => String(input).endsWith("/v1/runs")); + const prompt = JSON.parse(String(call?.[1]?.body)).input as string; + expect(prompt).toContain("Custom gateway instruction."); + expect(prompt).toContain(directive); + expect(prompt).not.toContain("Execution contract:"); + expect(prompt).not.toContain("clear final disposition"); + expect(prompt).not.toContain("Create child issues"); + }); + it("sends the task brief once on fresh runs and compacts it on stable-session resumes", async () => { const description = "Update launch-card.svg and change the CTA to Try Team free."; const fullTaskMarkdown = [ diff --git a/packages/adapters/hermes/src/gateway/server/execute.ts b/packages/adapters/hermes/src/gateway/server/execute.ts index fdb27ec6b6..0a30e7235c 100644 --- a/packages/adapters/hermes/src/gateway/server/execute.ts +++ b/packages/adapters/hermes/src/gateway/server/execute.ts @@ -274,6 +274,7 @@ function buildInput(ctx: AdapterExecutionContext, paperclipApiUrl: string | null Boolean(nonEmpty(ctx.runtime?.sessionId)); const taskMarkdown = nonEmpty(selectPaperclipTaskMarkdown(ctx.context, { resumedSession })); const wakePrompt = renderPaperclipWakePrompt(ctx.context.paperclipWake, { + conversationMode: ctx.context.conversationMode === true, // The task-context markdown is the authoritative brief on this lane; keep // the wake prompt's description copy out so the prompt carries it once. suppressIssueDescription: Boolean(taskMarkdown), @@ -293,7 +294,7 @@ function buildInput(ctx: AdapterExecutionContext, paperclipApiUrl: string | null ...(paperclipApiUrl ? [`- Paperclip API URL: ${paperclipApiUrl}`] : []), ...(issueWorkMode ? [`- Issue work mode: ${issueWorkMode}`] : []), "", - ...(isPaperclipRecoveryWakePayload(ctx.context.paperclipWake) + ...(ctx.context.conversationMode === true || isPaperclipRecoveryWakePayload(ctx.context.paperclipWake) ? [] : [ "Execution contract:", @@ -322,7 +323,10 @@ function buildInput(ctx: AdapterExecutionContext, paperclipApiUrl: string | null function buildRunBody(ctx: AdapterExecutionContext, sessionKey: string | null): Record { const paperclipApiUrl = nonEmpty(ctx.config.paperclipApiUrl); const payloadTemplate = parseObject(ctx.config.payloadTemplate); - const input = nonEmpty(payloadTemplate.input) ?? buildInput(ctx, paperclipApiUrl); + const configuredInput = nonEmpty(payloadTemplate.input); + const input = configuredInput && ctx.context.conversationMode === true + ? `${configuredInput}\n\n${buildInput(ctx, paperclipApiUrl)}` + : configuredInput ?? buildInput(ctx, paperclipApiUrl); const instructions = nonEmpty(ctx.config.instructions) ?? nonEmpty(payloadTemplate.instructions) ?? diff --git a/packages/adapters/hermes/src/server/execute.ts b/packages/adapters/hermes/src/server/execute.ts index 021ad08dd8..ea54e574e5 100644 --- a/packages/adapters/hermes/src/server/execute.ts +++ b/packages/adapters/hermes/src/server/execute.ts @@ -34,6 +34,7 @@ import { renderTemplate, ensureAbsoluteDirectory, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, + DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE, joinPromptSections, renderPaperclipWakePrompt, selectPaperclipTaskMarkdown, @@ -140,9 +141,10 @@ export function buildPrompt( config: Record, options: { resumedSession?: boolean } = {}, ): string { - const template = cfgString(config.promptTemplate) || HERMES_DEFAULT_PROMPT_TEMPLATE; - const context = (ctx as any).context || {}; + const template = cfgString(config.promptTemplate) || (context.conversationMode === true + ? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE + : HERMES_DEFAULT_PROMPT_TEMPLATE); const taskId = cfgString(context.taskId) || cfgString(context.issueId) || cfgString(ctx.config?.taskId); const taskTitle = cfgString(context.taskTitle) || cfgString(ctx.config?.taskTitle) || ""; const taskBody = cfgString(context.taskBody) || cfgString(ctx.config?.taskBody) || ""; @@ -166,6 +168,7 @@ export function buildPrompt( resumedSession: options.resumedSession === true, }); const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { + conversationMode: context.conversationMode === true, resumedSession: options.resumedSession === true, // The task-context markdown is the authoritative brief on this lane; keep // the wake prompt's description copy out so the prompt carries it once. diff --git a/packages/adapters/hermes/src/server/prompt-rendering.test.ts b/packages/adapters/hermes/src/server/prompt-rendering.test.ts index 1a5d1c9a8a..1cdfe21b7e 100644 --- a/packages/adapters/hermes/src/server/prompt-rendering.test.ts +++ b/packages/adapters/hermes/src/server/prompt-rendering.test.ts @@ -246,3 +246,23 @@ test("preserves custom prompt templates while exposing runtime and wake variable expect(prompt).toContain("Issue description:\n```text\nUse the wake payload as runtime authority.\n```"); expect(prompt).not.toContain("Paperclip runtime identity:"); }); + + +test.each([false, true])("conversation prompts preserve the handoff policy (resumed=%s)", (resumedSession) => { + const directive = "Chat directive: clarify goals and hand the plan off to project tasks."; + const ctx = baseContext({ + conversationMode: true, + paperclipTaskMarkdown: directive, + paperclipTaskMarkdownCompact: directive, + }); + ctx.context.paperclipWake.interactionKind = "request_confirmation"; + ctx.context.paperclipWake.interactionStatus = "accepted"; + for (const config of [{}, { promptTemplate: "Custom agent instruction." }]) { + const prompt = buildPrompt(ctx, config, { resumedSession }); + expect(prompt).toContain(directive); + expect(prompt).not.toContain("Execution contract:"); + expect(prompt).not.toContain("clear final disposition"); + expect(prompt).not.toContain("Create child issues"); + expect(prompt).not.toContain("--arg status done"); + } +}); diff --git a/packages/adapters/kimi-local/src/server/execute.ts b/packages/adapters/kimi-local/src/server/execute.ts index f9be53cc95..04c5984fff 100644 --- a/packages/adapters/kimi-local/src/server/execute.ts +++ b/packages/adapters/kimi-local/src/server/execute.ts @@ -39,9 +39,11 @@ import { parseObject, renderTemplate, renderPaperclipWakePrompt, + selectPaperclipTaskMarkdown, isPaperclipRecoveryWakePayload, stringifyPaperclipWakePayload, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, + DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE, } from "@paperclipai/adapter-utils/server-utils"; import { SANDBOX_INSTALL_COMMAND, @@ -210,7 +212,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? renderTemplate(bootstrapPromptTemplate, templateData).trim() : ""; - const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) }); + const taskContextNote = context.conversationMode === true + ? selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId) }) + : ""; + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { + conversationMode: context.conversationMode === true, + resumedSession: Boolean(sessionId), + suppressIssueDescription: taskContextNote.length > 0, + }); const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0; const renderedPrompt = shouldUseResumeDeltaPrompt || isPaperclipRecoveryWakePayload(context.paperclipWake) ? "" @@ -524,6 +535,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise ({ failConnectAttempts: 0, failAgentRequests: 0, events: [] as string[], + messages: [] as string[], })); vi.mock("ws", async () => { @@ -35,7 +36,8 @@ vi.mock("ws", async () => { } send(payload: string) { - const request = JSON.parse(payload) as { id: string; method: string }; + const request = JSON.parse(payload) as { id: string; method: string; params?: { message?: string } }; + if (request.method === "agent") websocketState.messages.push(request.params?.message ?? ""); websocketState.events.push(`send:${request.method}`); if (request.method === "agent" && websocketState.failAgentRequests > 0) { websocketState.failAgentRequests--; @@ -105,12 +107,41 @@ describe("openclaw_gateway execute dispatch boundary", () => { websocketState.failConnectAttempts = 0; websocketState.failAgentRequests = 0; websocketState.events = []; + websocketState.messages = []; }); afterEach(() => { vi.useRealTimers(); }); + it.each([false, true])("sends conversation policy without the issue-completion workflow (resumed=%s)", async (resumed) => { + const ctx = createContext(); + const directive = "Chat directive: clarify goals and hand plans off to project tasks."; + ctx.context = { + ...ctx.context, + conversationMode: true, + paperclipTaskMarkdown: directive, + paperclipTaskMarkdownCompact: directive, + paperclipWake: { + reason: "issue_commented", + issue: { id: "issue-1", workMode: "planning", status: "in_progress" }, + interactionKind: "request_confirmation", + interactionStatus: "accepted", + }, + }; + if (resumed) ctx.runtime.sessionId = "prior-session"; + const result = await execute(ctx); + expect(result.exitCode).toBe(0); + expect(websocketState.messages).toHaveLength(1); + const prompt = websocketState.messages[0]!; + expect(prompt).toContain(directive); + expect(prompt).toContain("X-Paperclip-Run-Id"); + expect(prompt).not.toContain("Execution contract:"); + expect(prompt).not.toContain("Create child issues"); + expect(prompt).not.toContain('"status":"done"'); + expect(prompt).not.toContain("GET /api/issues/{issueId}/comments"); + }); + it("reports dispatch after transport setup and before the remote agent request", async () => { const onDispatch = vi.fn(() => { websocketState.events.push("dispatch"); diff --git a/packages/adapters/openclaw-gateway/src/server/execute.ts b/packages/adapters/openclaw-gateway/src/server/execute.ts index 7d79bd0437..2e66896618 100644 --- a/packages/adapters/openclaw-gateway/src/server/execute.ts +++ b/packages/adapters/openclaw-gateway/src/server/execute.ts @@ -11,6 +11,7 @@ import { parseObject, readPaperclipIssueWorkModeFromContext, renderPaperclipWakePrompt, + selectPaperclipTaskMarkdown, stringifyPaperclipWakePayload, } from "@paperclipai/adapter-utils/server-utils"; import crypto, { randomUUID } from "node:crypto"; @@ -372,6 +373,7 @@ function buildWakeText( paperclipEnv: Record, structuredWakePrompt: string, claimedApiKeyPath: string, + conversationTaskMarkdown?: string, ): string { const orderedKeys = [ "PAPERCLIP_RUN_ID", @@ -396,6 +398,19 @@ function buildWakeText( const issueIdHint = payload.taskId ?? payload.issueId ?? ""; const apiBaseHint = paperclipEnv.PAPERCLIP_API_URL ?? ""; + if (conversationTaskMarkdown !== undefined) { + return [ + "Paperclip conversation turn for a cloud adapter.", + "Set these values in your run context:", + ...envLines, + `Load PAPERCLIP_API_KEY from ${claimedApiKeyPath} (the token saved after claim-api-key).`, + "Use Authorization: Bearer $PAPERCLIP_API_KEY on every API call and X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID on every mutation.", + "Follow the supplied chat mode directive. Keep this conversation available for the next message.", + structuredWakePrompt, + conversationTaskMarkdown, + ].join("\n\n"); + } + const lines = [ "Paperclip wake event for a cloud adapter.", "", @@ -1091,6 +1106,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { const cleanupDirs: string[] = []; const originalOpenCodeAllowAllModels = process.env.OPENCODE_ALLOW_ALL_MODELS; - beforeEach(() => { + beforeEach(async () => { + const configHome = await mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-test-config-")); + cleanupDirs.push(configHome); + vi.stubEnv("XDG_CONFIG_HOME", configHome); delete process.env.OPENCODE_ALLOW_ALL_MODELS; }); afterEach(async () => { vi.clearAllMocks(); + vi.unstubAllEnvs(); if (originalOpenCodeAllowAllModels === undefined) { delete process.env.OPENCODE_ALLOW_ALL_MODELS; } else { diff --git a/packages/adapters/opencode-local/src/server/execute.test.ts b/packages/adapters/opencode-local/src/server/execute.test.ts index 5158d98bac..a5c4d1d084 100644 --- a/packages/adapters/opencode-local/src/server/execute.test.ts +++ b/packages/adapters/opencode-local/src/server/execute.test.ts @@ -34,6 +34,53 @@ function probeResult(overrides: Record) { } describe("OpenCode local skill injection", () => { + let configHome: string; + + beforeEach(async () => { + configHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-test-config-")); + vi.stubEnv("XDG_CONFIG_HOME", configHome); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await fs.rm(configHome, { recursive: true, force: true }); + }); + + it.each([false, true])("keeps chat policy with a legacy OpenCode prompt (custom=%s)", async (custom) => { + const commandPath = path.join(configHome, "fake-opencode"); + await fs.writeFile(commandPath, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + runProcessMock.mockReset(); + runProcessMock.mockResolvedValue(probeResult({ stdout: JSON.stringify({ + type: "text", sessionID: "chat-session", part: { text: "Reply" }, + }) })); + const directive = "Chat directive: clarify goals and hand plans off to project tasks."; + let prompt = ""; + const result = await execute({ + runId: "chat-run", + agent: { id: "agent-1", companyId: "company-1", name: "OpenCode", adapterType: "opencode_local", adapterConfig: {} }, + runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: { + command: commandPath, cwd: configHome, model: "openai/gpt-5", env: { OPENCODE_ALLOW_ALL_MODELS: "1" }, + ...(custom ? { promptTemplate: "Custom agent instruction." } : {}), + }, + context: { + conversationMode: true, + paperclipTaskMarkdown: directive, + paperclipWake: { + reason: "issue_commented", issue: { id: "chat-1", status: "in_progress", workMode: "planning" }, + interactionKind: "request_confirmation", interactionStatus: "accepted", + }, + }, + onLog: async () => {}, + onMeta: async (meta) => { prompt = String(meta.prompt ?? ""); }, + }); + expect(result.exitCode).toBe(0); + expect(prompt).toContain(directive); + expect(prompt).toContain(custom ? "Custom agent instruction." : "Continue your Paperclip conversation"); + expect(prompt).not.toContain("Execution contract:"); + expect(prompt).not.toContain("Create child issues"); + }); + it("injects runtime skills into the configured child HOME", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-configured-home-")); const processHome = path.join(root, "process-home"); diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index dba49d84c8..83cef0de9c 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -40,9 +40,11 @@ import { refreshPaperclipWorkspaceEnvForExecution, renderTemplate, renderPaperclipWakePrompt, + selectPaperclipTaskMarkdown, isPaperclipRecoveryWakePayload, stringifyPaperclipWakePayload, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, + DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE, runChildProcess, isPaperclipSkillSourceMissing, readPaperclipRuntimeSkillEntries, @@ -229,7 +231,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? renderTemplate(bootstrapPromptTemplate, templateData).trim() : ""; - const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) }); + const taskContextNote = context.conversationMode === true + ? selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId) }) + : ""; + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { + conversationMode: context.conversationMode === true, + resumedSession: Boolean(sessionId), + suppressIssueDescription: taskContextNote.length > 0, + }); const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0; const renderedPrompt = shouldUseResumeDeltaPrompt || isPaperclipRecoveryWakePayload(context.paperclipWake) ? "" @@ -570,6 +581,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { import { testEnvironment } from "./test.js"; describe("opencode remote environment diagnostics", () => { - afterEach(() => { + let configHome: string; + + beforeEach(async () => { + configHome = await mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-test-config-")); + vi.stubEnv("XDG_CONFIG_HOME", configHome); + }); + + afterEach(async () => { vi.clearAllMocks(); + vi.unstubAllEnvs(); + await rm(configHome, { recursive: true, force: true }); }); it("stages remote runtime config assets for sandbox hello probes", async () => { diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index 08f80fb991..5ed953cc7c 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -45,9 +45,11 @@ import { removeMaintainerOnlySkillSymlinks, renderTemplate, renderPaperclipWakePrompt, + selectPaperclipTaskMarkdown, isPaperclipRecoveryWakePayload, stringifyPaperclipWakePayload, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, + DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE, runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; import { shellQuote } from "@paperclipai/adapter-utils/ssh"; @@ -228,7 +230,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? renderTemplate(bootstrapPromptTemplate, templateData).trim() : ""; - const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: canResumeSession }); + const taskContextNote = context.conversationMode === true + ? selectPaperclipTaskMarkdown(context, { resumedSession: canResumeSession }) + : ""; + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { + conversationMode: context.conversationMode === true, + resumedSession: canResumeSession, + suppressIssueDescription: taskContextNote.length > 0, + }); const shouldUseResumeDeltaPrompt = canResumeSession && wakePrompt.length > 0; const renderedHeartbeatPrompt = shouldUseResumeDeltaPrompt || isPaperclipRecoveryWakePayload(context.paperclipWake) ? "" @@ -622,6 +635,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise