fix: deliver current task descriptions on resumed runs

Keep updated task objectives across later resumes while preserving new human direction and explicit interactions.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-12 17:32:50 -05:00
parent 64814d5a4b
commit 73105e3f7b
5 changed files with 176 additions and 57 deletions

View File

@ -1247,6 +1247,14 @@ Behavior:
- `thin`: send IDs and pointers only; agent fetches context via API
- `fat`: include current assignments, goal summary, budget snapshot, and recent comments
When an adapter includes the task brief, it includes the current description on
resumed sessions as well as fresh sessions. A saved provider conversation does
not prove that it has received later description edits. Comment deltas may
remain compact; they do not replace the current brief. An edited brief must not
be replaced by an already delivered historical comment, including on subsequent
resumes. New human direction and explicit interaction outcomes remain available
alongside the brief. These context updates do not reset the provider session.
## 11.5 Recovery Work Classes
Status-only recovery coordination must include guard context that prevents deliverable work and document or plan updates (`allowDeliverableWork: false`, `allowDocumentUpdates: false`, `resumeRequiresNormalModel: true`). Recovery work classes do not select or change the agent model.

View File

@ -1332,7 +1332,28 @@ describe("renderPaperclipWakePrompt", () => {
});
});
it("omits the issue description from non-assignment resume deltas and leaves a fetch breadcrumb", () => {
it("delivers an edited task brief on an ordinary resumed status wake", () => {
const description = "Run sh /tmp/current-verification.sh once; do not run task/acceptance-step.sh.";
const wake = {
reason: "issue_status_changed",
issue: { id: "issue-1", title: "Existing task", status: "in_progress", description },
commentWindow: { requestedCount: 0, includedCount: 0, missingCount: 0 },
comments: [],
fallbackFetchNeeded: false,
};
const prompt = renderPaperclipWakePrompt(wake, { resumedSession: true });
expect(prompt).toContain(description);
expect(prompt).toContain("Paperclip Resume Delta");
expect(prompt).not.toContain("omitted from this resume delta");
const selected = selectPaperclipTaskMarkdown({
paperclipWake: wake,
paperclipTaskMarkdown: `Current task: ${description}`,
paperclipTaskMarkdownCompact: "Existing task",
}, { resumedSession: true });
expect(selected).toContain(description);
});
it("keeps the current issue description on unchanged and assignment-shaped resumes", () => {
const basePayload = {
issue: {
id: "issue-1",
@ -1352,10 +1373,9 @@ describe("renderPaperclipWakePrompt", () => {
{ ...basePayload, reason: "issue_commented" },
{ resumedSession: true },
);
expect(commentResume).not.toContain("Issue description:");
expect(commentResume).toContain(
"- issue description: omitted from this resume delta; fetch the issue if you need the latest brief",
);
expect(commentResume).toContain("Issue description:");
expect(commentResume).toContain(basePayload.issue.description);
expect(commentResume).not.toContain("omitted from this resume delta");
// Assignment-shaped resumes still deliver the brief: the resuming session
// may be picking this issue up for the first time.
@ -3040,7 +3060,7 @@ describe("selectPaperclipTaskMarkdown", () => {
).toBe(fullMarkdown);
});
it("returns the compact markdown for non-assignment resume deltas", () => {
it("retains the full current brief on non-assignment resume deltas", () => {
expect(
selectPaperclipTaskMarkdown(
{
@ -3050,7 +3070,7 @@ describe("selectPaperclipTaskMarkdown", () => {
},
{ resumedSession: true },
),
).toBe(compactMarkdown);
).toBe(fullMarkdown);
});
it("falls back to the full markdown when no compact variant exists", () => {

View File

@ -2150,28 +2150,14 @@ export function isAssignmentShapedPaperclipWakeReason(
);
}
// Picks the task-context markdown variant for adapters that inject it into the
// prompt. Fresh sessions, assignment-shaped wakes, and recovery wakes get the
// full brief; other resume deltas get the compact variant (description
// stripped) because the session already received the brief when it picked the
// issue up. Falls back to the full variant when no compact one was provided.
// A resumed provider session has no certified revision of the task description.
// Always deliver the current brief: an ordinary status/comment wake can follow
// an edit, even when its comment delta is empty. This does not reset the session.
export function selectPaperclipTaskMarkdown(
context: Record<string, unknown> | null | undefined,
options: { resumedSession?: boolean } = {},
_options: { resumedSession?: boolean } = {},
): string {
const full = asString(context?.paperclipTaskMarkdown, "").trim();
if (!full) return "";
if (options.resumedSession !== true) return full;
const wake = normalizePaperclipWakePayload(context?.paperclipWake);
if (!wake) return full;
if (
isAssignmentShapedPaperclipWakeReason(wake.reason) ||
isPaperclipRecoveryWakePayload(context?.paperclipWake)
) {
return full;
}
const compact = asString(context?.paperclipTaskMarkdownCompact, "").trim();
return compact || full;
return asString(context?.paperclipTaskMarkdown, "").trim();
}
// Runtime-only connector skills are supplied by the server after assignment resolution.
@ -2465,18 +2451,10 @@ function renderPaperclipWakePromptBody(
lines.push(`- issue priority: ${normalized.issue.priority}`);
}
const issueDescription = normalized.issue?.description ?? null;
// Resume deltas skip the description: the session already received the brief
// when it picked up the issue. Assignment-shaped and recovery wakes are the
// exceptions — there the resuming session may be seeing this issue fresh.
const resumeOmitsIssueDescription =
resumedSession &&
!recoveryScoped &&
!isAssignmentShapedPaperclipWakeReason(normalized.reason);
if (
issueDescription !== null &&
options.suppressIssueDescription !== true &&
!resumeOmitsIssueDescription
) {
// Comment coverage does not establish which description revision the provider
// saw. Keep the current brief on resumes; adapters carrying the full task
// markdown can still suppress this duplicate copy explicitly.
if (issueDescription !== null && options.suppressIssueDescription !== true) {
lines.push(
"",
"Issue description:",
@ -2488,10 +2466,6 @@ function renderPaperclipWakePromptBody(
"[issue description truncated; fetch the issue for the full brief]",
);
}
} else if (issueDescription !== null && resumeOmitsIssueDescription) {
lines.push(
"- issue description: omitted from this resume delta; fetch the issue if you need the latest brief",
);
}
if (normalized.checkboxSelection) {
if (normalized.checkboxSelection.prompt) {

View File

@ -133,6 +133,99 @@ const support = await getEmbeddedPostgresTestSupport();
summary: "Notion read completed.",
exposeLowTrustRaw: false,
});
it("retains an edited brief alongside historical direction on the same provider-session resume", async () => {
const taskId = randomUUID(), priorRunId = randomUUID(), commentId = randomUUID();
const oldBrief = 'Run bash "$HOME/task/acceptance-step.sh".';
const newBrief = "Run sh /tmp/current-verification.sh exactly once; do not run task/acceptance-step.sh.";
await db.insert(issues).values({ id: taskId, companyId, title: "Existing task", description: oldBrief,
status: "in_progress", assigneeAgentId: agentId });
await db.insert(issueComments).values({ id: commentId, companyId, issueId: taskId,
authorType: "user", authorUserId: "local-board", body: oldBrief });
const input = { db, companyId, issueId: taskId, agentId,
context: { wakeReason: "issue_status_changed" }, summary: null, exposeLowTrustRaw: false };
const before = await buildExecutionContinuation(input);
await db.insert(heartbeatRuns).values({ id: priorRunId, companyId, agentId, status: "succeeded",
sessionIdAfter: "original-provider-conversation", contextSnapshot: {
issueId: taskId, paperclipIssue: { description: oldBrief }, executionContinuation: before,
} });
await db.update(issues).set({ description: newBrief }).where(eq(issues.id, taskId));
const after = await buildExecutionContinuation({ ...input, previousContextRunId: priorRunId });
expect(after.objective).toBe(newBrief);
const stalePointer = await buildExecutionContinuation({ ...input, previousContextRunId: priorRunId,
context: { wakeReason: "issue_status_changed", latestCommentId: commentId } });
expect(stalePointer.objective).toBe(newBrief);
expect(after.messages).toEqual(before.messages);
expect(after.resumeDelta).toMatchObject({ baseRunId: priorRunId, messages: [] });
const prompt = renderPaperclipWakePrompt({ reason: "issue_status_changed",
issue: { id: taskId, title: "Existing task", description: newBrief },
executionContinuation: after, fallbackFetchNeeded: false }, { resumedSession: true });
expect(prompt).toContain(newBrief);
expect(prompt).toContain("Paperclip Resume Delta");
const [retained] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, priorRunId));
expect(retained.sessionIdAfter).toBe("original-provider-conversation");
// A second ordinary resume must not promote the older comment again now
// that the newly delivered description is equal to the stored description.
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: taskId,
paperclipIssue: { description: newBrief }, executionContinuation: after,
} }).where(eq(heartbeatRuns.id, priorRunId));
const secondResume = await buildExecutionContinuation({ ...input, previousContextRunId: priorRunId });
expect(secondResume.objective).toBe(newBrief);
expect(secondResume.resumeDelta?.messages).toEqual([]);
// A later comment may refine the brief or authorize the next planning step.
const direction = "The plan is approved; implement only its first step.";
const directionId = randomUUID();
await db.insert(issueComments).values({ id: directionId, companyId, issueId: taskId, authorType: "user",
authorUserId: "local-board", body: direction, createdAt: new Date(Date.now() + 1000) });
const coalesced = await buildExecutionContinuation({ ...input, previousContextRunId: priorRunId });
expect(coalesced.objective).toBe(direction);
const later = await buildExecutionContinuation({ ...input, previousContextRunId: priorRunId,
context: { wakeReason: "issue_commented", commentId: directionId } });
expect(later.objective).toBe(direction);
expect(later.resumeDelta?.messages.map(message => message.body)).toEqual([direction]);
// Unchanged task resumes keep the comment's refinement, with no new comment required.
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: taskId,
paperclipIssue: { description: newBrief }, executionContinuation: later,
} }).where(eq(heartbeatRuns.id, priorRunId));
const unchanged = await buildExecutionContinuation({ ...input, previousContextRunId: priorRunId });
expect(unchanged.objective).toBe(direction);
expect(unchanged.resumeDelta?.messages).toEqual([]);
// An accepted plan interaction retains the originating request and decision.
const approvalId = randomUUID();
await db.insert(issueThreadInteractions).values({ id: approvalId, companyId, issueId: taskId,
kind: "request_confirmation", status: "accepted", sourceRunId: priorRunId,
sourceCommentId: directionId, originCommentIds: [directionId],
payload: { version: 1, prompt: "Approve the plan?", target: {
type: "issue_document", key: "plan", revisionId: randomUUID(), revisionNumber: 1,
} }, result: { version: 1, outcome: "accepted" } });
const approved = await buildExecutionContinuation({ ...input,
context: { interactionId: approvalId, wakeReason: "issue_interaction_resolved" } });
expect(approved.objective).toBe(direction);
expect(approved.originCommentIds).toContain(directionId);
expect(approved.interactionOutcomes).toContainEqual(expect.objectContaining({
id: approvalId, kind: "request_confirmation", status: "accepted",
}));
// Conversations continue their current human message, not their persistent brief.
await db.update(issues).set({ conversationAgentId: agentId, conversationUserId: "local-board",
conversationState: "active" }).where(eq(issues.id, taskId));
const conversation = await buildExecutionContinuation(input);
expect(conversation.objective).toBe(direction);
// Removing the comment that supported the objective cannot replay its old body.
await db.update(issues).set({ conversationAgentId: null, conversationUserId: null,
conversationState: null }).where(eq(issues.id, taskId));
await db.update(issueComments).set({ deletedAt: new Date() }).where(eq(issueComments.id, directionId));
const deletedDirection = await buildExecutionContinuation({ ...input, previousContextRunId: priorRunId });
expect(deletedDirection.objective).toBe(newBrief);
expect(deletedDirection.messages.find(message => message.id === directionId)?.body).toBe("");
});
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

@ -186,24 +186,20 @@ export async function buildExecutionContinuation(input: {
const deliveredMessages = Array.isArray(priorEnvelope.messages)
? priorEnvelope.messages.map(object)
: null;
const wasDelivered = (message: (typeof messages)[number]) =>
deliveredMessages?.some((prior) =>
prior.id === message.id && prior.updatedAt === message.updatedAt &&
prior.body === message.body && prior.deleted === message.deleted &&
prior.authorId === message.authorId &&
(prior.createdByRunId ?? null) === message.createdByRunId &&
JSON.stringify(prior.sourceTrust) === JSON.stringify(message.sourceTrust),
) ?? false;
const resumeDelta =
deliveredMessages && input.previousContextRunId
? {
baseRunId: input.previousContextRunId,
messages: messages.filter(
(message) =>
originCommentIds.includes(message.id) ||
!deliveredMessages.some(
(prior) =>
prior.id === message.id &&
prior.updatedAt === message.updatedAt &&
prior.body === message.body &&
prior.deleted === message.deleted &&
prior.authorId === message.authorId &&
(prior.createdByRunId ?? null) === message.createdByRunId &&
JSON.stringify(prior.sourceTrust) ===
JSON.stringify(message.sourceTrust),
),
messages: messages.filter((message) =>
originCommentIds.includes(message.id) || !wasDelivered(message),
),
}
: undefined;
@ -211,6 +207,34 @@ export async function buildExecutionContinuation(input: {
(row) =>
row.authorType === "user" && !row.createdByRunId && !row.deleted && row.body.trim().length > 0,
);
// 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({
commentId: input.context.commentId,
latestCommentId: input.context.latestCommentId,
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 previousIssue = object(previousRun?.context?.paperclipIssue);
const briefUnchanged = Object.hasOwn(previousIssue, "description") &&
previousIssue.description === issue.description;
const newUserDirection = deliveredMessages && latestRequest && !wasDelivered(latestRequest);
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 ||
(briefUnchanged && newUserDirection))
? latestRequest?.body ?? issue.description ?? issue.title
: briefUnchanged && previousObjective && priorObjectiveStillCurrent
? previousObjective
: issue.description ?? latestRequest?.body ?? issue.title;
const priorRuns = await db
.select({ id: heartbeatRuns.id, result: heartbeatRuns.resultJson, status: heartbeatRuns.status, errorCode: heartbeatRuns.errorCode, runtimeMode: heartbeatRuns.runtimeMode, retryOfRunId: heartbeatRuns.retryOfRunId })
.from(heartbeatRuns)
@ -305,7 +329,7 @@ export async function buildExecutionContinuation(input: {
sourceRunId,
},
originCommentIds,
objective: latestRequest?.body ?? issue.description ?? issue.title,
objective,
messages,
interactionOutcomes: interactions
.filter((row) => row.status !== "pending")