Deliver edited user direction without duplicating conversation briefs
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
f9a5f0c312
commit
f6e3764393
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue