Deliver edited user direction without duplicating conversation briefs

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-12 20:29:32 -05:00
parent f9a5f0c312
commit f6e3764393
4 changed files with 69 additions and 16 deletions

View File

@ -117,14 +117,15 @@ describe("openclaw_gateway execute dispatch boundary", () => {
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.";
const description = "Discuss the authentication rollout before implementing it.";
ctx.context = {
...ctx.context,
conversationMode: true,
paperclipTaskMarkdown: directive,
paperclipTaskMarkdown: `${directive}\n\n${description}`,
paperclipTaskMarkdownCompact: directive,
paperclipWake: {
reason: "issue_commented",
issue: { id: "issue-1", workMode: "planning", status: "in_progress" },
issue: { id: "issue-1", workMode: "planning", status: "in_progress", description },
interactionKind: "request_confirmation",
interactionStatus: "accepted",
},
@ -135,6 +136,7 @@ describe("openclaw_gateway execute dispatch boundary", () => {
expect(websocketState.messages).toHaveLength(1);
const prompt = websocketState.messages[0]!;
expect(prompt).toContain(directive);
expect(prompt.split(description)).toHaveLength(2);
expect(prompt).toContain("X-Paperclip-Run-Id");
expect(prompt).not.toContain("Execution contract:");
expect(prompt).not.toContain("Create child issues");
@ -142,6 +144,16 @@ describe("openclaw_gateway execute dispatch boundary", () => {
expect(prompt).not.toContain("GET /api/issues/{issueId}/comments");
});
it("retains the wake description when conversation task markdown is unavailable", async () => {
const ctx = createContext();
const description = "Keep this fallback brief available to the conversation.";
ctx.context = { ...ctx.context, conversationMode: true,
paperclipWake: { reason: "issue_commented", issue: { id: "issue-1", description } } };
const result = await execute(ctx);
expect(result.exitCode).toBe(0);
expect(websocketState.messages[0]).toContain(description);
});
it("reports dispatch after transport setup and before the remote agent request", async () => {
const onDispatch = vi.fn(() => {
websocketState.events.push("dispatch");

View File

@ -1102,13 +1102,19 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const wakePayload = buildWakePayload(ctx);
const paperclipEnv = buildPaperclipEnvForWake(ctx, wakePayload);
const conversationTaskMarkdown = ctx.context.conversationMode === true
? selectPaperclipTaskMarkdown(ctx.context, { resumedSession: Boolean(ctx.runtime?.sessionId) })
: undefined;
// No heartbeat prompt template is sent over the gateway, so the wake prompt
// must carry the execution contract itself.
const structuredWakePrompt = renderPaperclipWakePrompt(ctx.context.paperclipWake, {
includeExecutionContract: true,
conversationMode: ctx.context.conversationMode === true,
suppressIssueDescription: Boolean(conversationTaskMarkdown),
});
const structuredWakeJson = stringifyPaperclipWakePayload(ctx.context.paperclipWake, {
omitIssueDescription: Boolean(conversationTaskMarkdown),
});
const structuredWakeJson = stringifyPaperclipWakePayload(ctx.context.paperclipWake);
const wakeText = buildWakeText(
wakePayload,
paperclipEnv,
@ -1116,9 +1122,7 @@ 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,
conversationTaskMarkdown,
);
const sessionKeyStrategy = normalizeSessionKeyStrategy(ctx.config.sessionKeyStrategy);

View File

@ -226,6 +226,40 @@ const support = await getEmbeddedPostgresTestSupport();
});
it.each([true, false])("uses an edited older request ahead of delivered later history (explicit wake=%s)", async (explicitWake) => {
const taskId = randomUUID(), priorRunId = randomUUID(), olderId = randomUUID(), newerId = randomUUID();
const description = "Implement the approved project.";
const editedDirection = "Change of plan: implement only the authentication step.";
await db.insert(issues).values({ id: taskId, companyId, title: "Edited request",
description, status: "in_progress", assigneeAgentId: agentId });
await db.insert(issueComments).values([
{ id: olderId, companyId, issueId: taskId, authorType: "user", authorUserId: "local-board",
body: "Implement the whole plan.", createdAt: new Date("2026-09-08T10:00:00Z"),
updatedAt: new Date("2026-09-08T10:00:00Z") },
{ id: newerId, companyId, issueId: taskId, authorType: "user", authorUserId: "local-board",
body: "Include the reporting step too.", createdAt: new Date("2026-09-08T11:00:00Z"),
updatedAt: new Date("2026-09-08T11:00:00Z") },
]);
const input = { db, companyId, issueId: taskId, agentId,
context: { commentId: newerId }, summary: null, exposeLowTrustRaw: false };
const delivered = await buildExecutionContinuation(input);
await db.insert(heartbeatRuns).values({ id: priorRunId, companyId, agentId, status: "succeeded",
contextSnapshot: { issueId: taskId, paperclipIssue: { description }, executionContinuation: delivered } });
await db.update(issueComments).set({ body: editedDirection, updatedAt: new Date("2026-09-08T12:00:00Z") })
.where(eq(issueComments.id, olderId));
const resumed = await buildExecutionContinuation({ ...input, previousContextRunId: priorRunId,
context: explicitWake ? { commentId: olderId } : {} });
expect(resumed.objective).toBe(editedDirection);
expect(resumed.messages.map(message => message.id)).toEqual([olderId, newerId]);
expect(resumed.resumeDelta?.messages.map(message => message.id)).toEqual([olderId]);
expect(resumed.messages[1]?.body).toBe("Include the reporting step too.");
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: taskId,
paperclipIssue: { description }, executionContinuation: resumed } }).where(eq(heartbeatRuns.id, priorRunId));
const next = await buildExecutionContinuation({ ...input, previousContextRunId: priorRunId, context: {} });
expect(next.objective).toBe(editedDirection);
expect(next.resumeDelta?.messages).toEqual([]);
});
it("cancelled admission must not hide the interrupted execution", async () => {
const rejectedId = randomUUID();
await db.update(heartbeatRuns).set({ status: "interrupted", errorCode: "server_shutdown_interrupted", createdAt: new Date("2026-09-08T10:00:00Z") }).where(eq(heartbeatRuns.id, runId));

View File

@ -208,10 +208,15 @@ export async function buildExecutionContinuation(input: {
),
}
: undefined;
const latestRequest = messages.findLast(
(row) =>
row.authorType === "user" && !row.createdByRunId && !row.deleted && row.body.trim().length > 0,
const userRequests = messages.filter((row) =>
row.authorType === "user" && !row.createdByRunId && !row.deleted && row.body.trim().length > 0,
);
const latestRequest = userRequests.at(-1);
// Keep history in creation order, but edits are new direction. Among changed
// requests, the latest edit wins even when its comment was created earlier.
const changedRequests = userRequests.filter((message) => !wasDelivered(message))
.sort((a, b) => a.updatedAt.localeCompare(b.updatedAt) ||
a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
// Description edits create no comment. Do not promote an already delivered
// historical comment over the current brief, including on a second resume.
const wakeCommentIds = continuationOriginCommentIds({
@ -220,23 +225,21 @@ export async function buildExecutionContinuation(input: {
commentIds: input.context.commentIds,
wakeCommentIds: input.context.wakeCommentIds,
});
const hasUserWakeComment = messages.some((message) =>
wakeCommentIds.includes(message.id) && message.authorType === "user" &&
!message.createdByRunId && !message.deleted && message.body.trim().length > 0 &&
!wasDelivered(message),
const userWakeRequest = changedRequests.findLast((message) =>
wakeCommentIds.includes(message.id),
);
const previousIssue = object(previousRun?.context?.paperclipIssue);
const briefUnchanged = Object.hasOwn(previousIssue, "description") &&
previousIssue.description === issue.description;
const newUserDirection = deliveredMessages && latestRequest && !wasDelivered(latestRequest);
const newUserDirection = deliveredMessages ? changedRequests.at(-1) : undefined;
const previousObjective = string(priorEnvelope.objective);
// Never revive deleted, edited, or newly quarantined text from a prior run.
const priorObjectiveStillCurrent = previousObjective === issue.description ||
messages.some((message) => message.authorType === "user" && !message.createdByRunId &&
!message.deleted && message.body === previousObjective);
const objective = (hasUserWakeComment || triggerInteraction || issue.conversationAgentId ||
const objective = (userWakeRequest || triggerInteraction || issue.conversationAgentId ||
(briefUnchanged && newUserDirection))
? latestRequest?.body ?? issue.description ?? issue.title
? (userWakeRequest ?? newUserDirection ?? latestRequest)?.body ?? issue.description ?? issue.title
: briefUnchanged && previousObjective && priorObjectiveStillCurrent
? previousObjective
: issue.description ?? latestRequest?.body ?? issue.title;