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/adapters/cursor-cloud/src/server/execute.test.ts b/packages/adapters/cursor-cloud/src/server/execute.test.ts index 4d0ba57501..4798477b21 100644 --- a/packages/adapters/cursor-cloud/src/server/execute.test.ts +++ b/packages/adapters/cursor-cloud/src/server/execute.test.ts @@ -69,7 +69,7 @@ function createMockSdkAgent(options: MockAgentOptions = {}) { const sendRun = options.sendRun ?? createMockRun(); return { agentId: options.agentId ?? sendRun.agentId, - send: vi.fn(async () => 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 { 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 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