fix: preserve chat composer submissions and session state

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-11 17:41:56 -05:00
commit d4390e5d95
17 changed files with 339 additions and 23 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

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

@ -46,6 +46,41 @@ describe("OpenCode local skill injection", () => {
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

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