From f6e37643937c9c44ec6aee232e648574a74a75c5 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 12 Sep 2026 20:29:32 -0500 Subject: [PATCH] Deliver edited user direction without duplicating conversation briefs Co-Authored-By: Paperclip --- .../src/server/execute-dispatch.test.ts | 16 +++++++-- .../openclaw-gateway/src/server/execute.ts | 12 ++++--- .../services/execution-continuation.test.ts | 34 +++++++++++++++++++ server/src/services/execution-continuation.ts | 23 +++++++------ 4 files changed, 69 insertions(+), 16 deletions(-) diff --git a/packages/adapters/openclaw-gateway/src/server/execute-dispatch.test.ts b/packages/adapters/openclaw-gateway/src/server/execute-dispatch.test.ts index 48a4858f3a..b53da5b9a4 100644 --- a/packages/adapters/openclaw-gateway/src/server/execute-dispatch.test.ts +++ b/packages/adapters/openclaw-gateway/src/server/execute-dispatch.test.ts @@ -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"); diff --git a/packages/adapters/openclaw-gateway/src/server/execute.ts b/packages/adapters/openclaw-gateway/src/server/execute.ts index 2e66896618..823edd680e 100644 --- a/packages/adapters/openclaw-gateway/src/server/execute.ts +++ b/packages/adapters/openclaw-gateway/src/server/execute.ts @@ -1102,13 +1102,19 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + 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)); diff --git a/server/src/services/execution-continuation.ts b/server/src/services/execution-continuation.ts index 38ac57d4eb..6fc9d0acf4 100644 --- a/server/src/services/execution-continuation.ts +++ b/server/src/services/execution-continuation.ts @@ -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;