fix(adapters): preserve conversation policy across provider paths

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-11 17:41:19 -05:00
parent badde6dd00
commit 380040082e
24 changed files with 443 additions and 36 deletions

View File

@ -161,6 +161,7 @@ async function runExecutor(
config: Record<string, unknown>,
options: {
context?: Record<string, unknown>;
runtime?: Record<string, unknown>;
executionTransport?: Record<string, unknown>;
authToken?: string;
executionTarget?: Record<string, unknown>;
@ -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" },

View File

@ -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,

View File

@ -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 ",

View File

@ -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(

View File

@ -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<AdapterExec
const promptTemplate = asString(
config.promptTemplate,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
context.conversationMode === true
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
);
const effort = asString(config.effort, "");
const chrome = asBoolean(config.chrome, false);
@ -845,6 +848,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const taskContextNote = selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId) });
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
resumedSession: Boolean(sessionId),
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,

View File

@ -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 } : {}),

View File

@ -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<AdapterExec
const promptTemplate = asString(
config.promptTemplate,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
context.conversationMode === true
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
);
const command = asString(config.command, "codex");
const model = asString(config.model, "");
@ -1119,7 +1123,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
!sessionId && bootstrapPromptTemplate.trim().length > 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<AdapterExec
wakePrompt,
codexFallbackHandoffNote,
sessionHandoffNote,
taskContextNote,
renderedPrompt,
]);
const promptMetrics = {
@ -1210,6 +1220,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
bootstrapPromptChars: renderedBootstrapPrompt.length,
wakePromptChars: wakePrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
taskContextChars: taskContextNote.length,
heartbeatPromptChars: renderedPrompt.length,
};

View File

@ -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<string, unknown>) => 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",

View File

@ -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<AdapterExec
}
: null);
const canReuseSession = sessionMatches(session, envType, envName, repos);
const promptTemplate = asString(config.promptTemplate, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE);
const promptTemplate = asString(config.promptTemplate, context.conversationMode === true
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE);
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
const templateData = {
agentId: agent.id,
@ -412,7 +416,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
context,
};
const instructions = await buildInstructionsPrefix(config, onLog);
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: canReuseSession });
const taskContextNote = context.conversationMode === true
? selectPaperclipTaskMarkdown(context, { resumedSession: canReuseSession })
: "";
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
conversationMode: context.conversationMode === true,
resumedSession: canReuseSession,
suppressIssueDescription: taskContextNote.length > 0,
});
const renderedBootstrapPrompt =
!canReuseSession && bootstrapPromptTemplate.trim().length > 0
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
@ -426,6 +437,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
instructions.prefix,
renderedBootstrapPrompt,
wakePrompt,
taskContextNote,
paperclipEnvNote,
renderedPrompt,
]);
@ -465,6 +477,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
instructionsChars: instructions.chars,
bootstrapPromptChars: renderedBootstrapPrompt.length,
wakePromptChars: wakePrompt.length,
taskContextChars: taskContextNote.length,
heartbeatPromptChars: renderedPrompt.length,
},
context: {

View File

@ -44,9 +44,11 @@ import {
removeMaintainerOnlySkillSymlinks,
renderTemplate,
renderPaperclipWakePrompt,
selectPaperclipTaskMarkdown,
isPaperclipRecoveryWakePayload,
stringifyPaperclipWakePayload,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
joinPromptSections,
} from "@paperclipai/adapter-utils/server-utils";
import { DEFAULT_CURSOR_LOCAL_MODEL, SANDBOX_INSTALL_COMMAND } from "../index.js";
@ -206,7 +208,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const promptTemplate = asString(
config.promptTemplate,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
context.conversationMode === true
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
);
let command = asString(config.command, "agent");
const model = asString(config.model, DEFAULT_CURSOR_LOCAL_MODEL).trim();
@ -566,7 +570,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
!sessionId && bootstrapPromptTemplate.trim().length > 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<AdapterExec
instructionsPrefix,
renderedBootstrapPrompt,
wakePrompt,
taskContextNote,
sessionHandoffNote,
paperclipEnvNote,
renderedPrompt,
@ -586,6 +598,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
instructionsChars,
bootstrapPromptChars: renderedBootstrapPrompt.length,
wakePromptChars: wakePrompt.length,
taskContextChars: taskContextNote.length,
sessionHandoffChars: sessionHandoffNote.length,
runtimeNoteChars: paperclipEnvNote.length,
heartbeatPromptChars: renderedPrompt.length,

View File

@ -47,9 +47,11 @@ import {
parseObject,
renderTemplate,
renderPaperclipWakePrompt,
selectPaperclipTaskMarkdown,
isPaperclipRecoveryWakePayload,
stringifyPaperclipWakePayload,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import { DEFAULT_GEMINI_LOCAL_MODEL, SANDBOX_INSTALL_COMMAND } from "../index.js";
@ -230,7 +232,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const promptTemplate = asString(
config.promptTemplate,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
context.conversationMode === true
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
);
const command = asString(config.command, "gemini");
const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim();
@ -555,7 +559,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
!sessionId && bootstrapPromptTemplate.trim().length > 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<AdapterExec
instructionsPrefix,
renderedBootstrapPrompt,
wakePrompt,
taskContextNote,
sessionHandoffNote,
paperclipEnvNote,
apiAccessNote,
@ -577,6 +589,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
instructionsChars: instructionsPrefix.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
wakePromptChars: wakePrompt.length,
taskContextChars: taskContextNote.length,
sessionHandoffChars: sessionHandoffNote.length,
runtimeNoteChars: paperclipEnvNote.length + apiAccessNote.length,
heartbeatPromptChars: renderedPrompt.length,

View File

@ -34,11 +34,13 @@ import {
readPaperclipRuntimeSkillEntries,
renderTemplate,
renderPaperclipWakePrompt,
selectPaperclipTaskMarkdown,
isPaperclipRecoveryWakePayload,
resolveLegacyPaperclipDesiredSkillNames,
stringifyPaperclipWakePayload,
refreshPaperclipWorkspaceEnvForExecution,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
} from "@paperclipai/adapter-utils/server-utils";
import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js";
import { copyBackGrokAuth } from "./grok-auth-copyback.js";
@ -202,7 +204,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const promptTemplate = asString(
config.promptTemplate,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
context.conversationMode === true
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
);
const command = asString(config.command, "grok");
const model = asString(config.model, DEFAULT_GROK_LOCAL_MODEL).trim();
@ -474,7 +478,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
run: { id: runId, source: "on_demand" },
context,
};
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)
? ""
@ -484,6 +495,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const apiAccessNote = renderApiAccessNote(env);
const prompt = joinPromptSections([
wakePrompt,
taskContextNote,
sessionHandoffNote,
paperclipEnvNote,
apiAccessNote,
@ -492,6 +504,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const promptMetrics = {
promptChars: prompt.length,
wakePromptChars: wakePrompt.length,
taskContextChars: taskContextNote.length,
sessionHandoffChars: sessionHandoffNote.length,
runtimeNoteChars: paperclipEnvNote.length + apiAccessNote.length,
heartbeatPromptChars: renderedPrompt.length,

View File

@ -174,6 +174,40 @@ describe("execute", () => {
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 = [

View File

@ -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<string, unknown> {
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) ??

View File

@ -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<string, unknown>,
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.

View File

@ -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");
}
});

View File

@ -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<AdapterExec
const promptTemplate = asString(
config.promptTemplate,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
context.conversationMode === true
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
);
const command = asString(config.command, "kimi");
const model = asString(config.model, "").trim();
@ -512,7 +516,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
!sessionId && bootstrapPromptTemplate.trim().length > 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<AdapterExec
instructionsPrefix,
renderedBootstrapPrompt,
wakePrompt,
taskContextNote,
sessionHandoffNote,
paperclipEnvNote,
apiAccessNote,
@ -534,6 +546,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
instructionsChars: instructionsPrefix.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
wakePromptChars: wakePrompt.length,
taskContextChars: taskContextNote.length,
sessionHandoffChars: sessionHandoffNote.length,
runtimeNoteChars: paperclipEnvNote.length + apiAccessNote.length,
heartbeatPromptChars: renderedPrompt.length,

View File

@ -6,6 +6,7 @@ const websocketState = vi.hoisted(() => ({
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");

View File

@ -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<string, string>,
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 ?? "<set 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<AdapterExec
// must carry the execution contract itself.
const structuredWakePrompt = renderPaperclipWakePrompt(ctx.context.paperclipWake, {
includeExecutionContract: true,
conversationMode: ctx.context.conversationMode === true,
});
const structuredWakeJson = stringifyPaperclipWakePayload(ctx.context.paperclipWake);
const wakeText = buildWakeText(
@ -1100,6 +1116,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
? joinWakePayloadSections(structuredWakePrompt, structuredWakeJson)
: structuredWakePrompt,
resolveClaimedApiKeyPath(ctx.config.claimedApiKeyPath),
ctx.context.conversationMode === true
? selectPaperclipTaskMarkdown(ctx.context, { resumedSession: Boolean(ctx.runtime?.sessionId) })
: undefined,
);
const sessionKeyStrategy = normalizeSessionKeyStrategy(ctx.config.sessionKeyStrategy);

View File

@ -104,12 +104,16 @@ describe("opencode remote execution", () => {
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 {

View File

@ -34,6 +34,53 @@ function probeResult(overrides: Record<string, unknown>) {
}
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");

View File

@ -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<AdapterExec
const promptTemplate = asString(
config.promptTemplate,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
context.conversationMode === true
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
);
const command = asString(config.command, "opencode");
const model = asString(config.model, "").trim();
@ -560,7 +564,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
!sessionId && bootstrapPromptTemplate.trim().length > 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<AdapterExec
instructionsPrefix,
renderedBootstrapPrompt,
wakePrompt,
taskContextNote,
sessionHandoffNote,
renderedPrompt,
]);
@ -578,6 +590,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
instructionsChars: instructionsPrefix.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
wakePromptChars: wakePrompt.length,
taskContextChars: taskContextNote.length,
sessionHandoffChars: sessionHandoffNote.length,
heartbeatPromptChars: renderedPrompt.length,
};

View File

@ -1,4 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
const {
@ -71,8 +74,17 @@ vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
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 () => {

View File

@ -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<AdapterExec
const promptTemplate = asString(
config.promptTemplate,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
context.conversationMode === true
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
);
const command = asString(config.command, "pi");
const model = asString(config.model, "").trim();
@ -583,7 +587,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`${instructionsContents}\n\n` +
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsFileDir}.\n\n` +
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE;
(context.conversationMode === true
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE);
} catch (err) {
instructionsReadFailed = true;
const reason = err instanceof Error ? err.message : String(err);
@ -613,7 +619,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
!canResumeSession && bootstrapPromptTemplate.trim().length > 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<AdapterExec
const userPrompt = joinPromptSections([
renderedBootstrapPrompt,
wakePrompt,
taskContextNote,
sessionHandoffNote,
renderedHeartbeatPrompt,
]);
@ -630,6 +644,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
promptChars: userPrompt.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
wakePromptChars: wakePrompt.length,
taskContextChars: taskContextNote.length,
sessionHandoffChars: sessionHandoffNote.length,
heartbeatPromptChars: renderedHeartbeatPrompt.length,
};