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:
parent
64814d5a4b
commit
73105e3f7b
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in New Issue