fix: deliver chat plan rejection feedback on resumed turns

This commit is contained in:
Dotta 2026-09-11 14:12:54 -05:00
parent 3b115f8f06
commit 3f55e74a22
4 changed files with 86 additions and 8 deletions

View File

@ -1876,7 +1876,7 @@ export function renderPaperclipWakePrompt(
"",
"Open plan comments to incorporate:",
"These open plan annotations are user feedback. Resolved annotations were intentionally omitted.",
"Read this before revising the plan or creating child issues from an accepted plan.",
"Read this before revising the plan or acting on an accepted plan.",
);
if (context.latestRevisionNumber || context.latestRevisionId) {
lines.push(
@ -1885,6 +1885,9 @@ export function renderPaperclipWakePrompt(
}
if (context.interaction) {
lines.push(`- interaction: ${context.interaction.kind ?? "unknown"} ${context.interaction.status ?? "unknown"}`);
if (context.interaction.status === "rejected") {
lines.push("The user requested changes to this plan. Revise it using the feedback below; this is not approval to implement or hand off execution tasks. In Ask mode, discuss the requested changes without mutating documents or tasks.");
}
if (context.interaction.result) {
const result = context.interaction.result;
lines.push(`- result: ${result.outcome ?? "unknown"}${result.reason ? ` (${result.reason})` : ""}`);

View File

@ -32,6 +32,8 @@ import {
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { issueService } from "../services/issues.js";
import { documentService } from "../services/documents.js";
import { renderPaperclipWakePrompt } from "@paperclipai/adapter-utils/server-utils";
import { instanceSettingsService } from "../services/instance-settings.js";
import {
AGENT_CHAT_DIRECTIVE,
@ -381,6 +383,40 @@ const support = await getEmbeddedPostgresTestSupport();
await conversationReplay(db, companyId, issue.id, next.id),
).not.toContain("Following turn");
});
it("projects the resolved chat plan review into fresh and resumed prompts without replaying old reviews", async () => {
const issue = await create();
const { document } = await documentService(db).upsertIssueDocument({
issueId: issue.id, key: "plan", title: "Plan", format: "markdown", body: "Draft plan",
});
const [interaction] = await db.insert(issueThreadInteractions).values({
companyId, issueId: issue.id, kind: "request_confirmation", status: "rejected",
payload: { version: 1, target: { type: "issue_document", key: "plan", documentId: document.id,
revisionId: document.latestRevisionId!, revisionNumber: document.latestRevisionNumber } },
result: { outcome: "rejected", reason: "Include CHAT_REVIEW_MARKER in the revised plan." },
}).returning();
const input = { db, companyId, issueSummary: { ...issue, workMode: "planning" },
contextSnapshot: { issueId: issue.id, conversationMode: true, interactionId: interaction!.id,
interactionKind: "request_confirmation", interactionStatus: "rejected" } };
const payload = await buildPaperclipWakePayload(input);
expect(payload?.planReviewContext?.interaction).toMatchObject({
status: "rejected", acceptedTargetRevision: null,
result: { outcome: "rejected", reason: "Include CHAT_REVIEW_MARKER in the revised plan." },
});
for (const resumedSession of [false, true]) {
const prompt = renderPaperclipWakePrompt(payload, { resumedSession });
expect(prompt).toContain("request_confirmation rejected");
expect(prompt).toContain("Include CHAT_REVIEW_MARKER in the revised plan.");
expect(prompt).toContain("not approval to implement or hand off execution tasks");
expect(prompt).not.toContain("- accepted target:");
}
const later = await buildPaperclipWakePayload({ ...input,
contextSnapshot: { issueId: issue.id, conversationMode: true } });
expect(later?.planReviewContext).toBeNull();
// An unrelated confirmation must not cause old plan context to be replayed.
await db.update(issueThreadInteractions).set({ payload: { version: 1 } })
.where(eq(issueThreadInteractions.id, interaction!.id));
expect((await buildPaperclipWakePayload(input))?.planReviewContext).toBeNull();
});
it("keeps concurrent delivery and multiple resets in separate ordered queue entries", async () => {
const issue = await create();
const first = await issueService(db).addComment(issue.id, "First", {
@ -720,6 +756,21 @@ describe("conversation execution wake policy", () => {
});
describe("chat prompt policy", () => {
it.each([true, false])("preserves rejected-plan changes in task markdown (includeDescription=%s)", (includeDescription) => {
const prompt = buildPaperclipTaskMarkdown({
issue: { id: "chat", title: "Chat", workMode: "planning", conversationAgentId: "agent" },
interaction: { kind: "request_confirmation", status: "rejected" },
planReview: { status: "rejected", reason: "Add CHAT_REVIEW_MARKER and a validation step." },
acceptedPlanContinuation: true,
acceptedPlan: { revisionId: "stale-approved-plan" },
includeDescription,
});
expect(prompt).toContain("Rejected plan review directive:");
expect(prompt).toContain("Add CHAT_REVIEW_MARKER and a validation step.");
expect(prompt).toContain("not approval to implement or hand off execution tasks");
expect(prompt).not.toContain("Accepted chat plan directive:");
expect(prompt).not.toContain("stale-approved-plan");
});
it.each(["standard", "ask", "planning"])(
"keeps handoff instructions in %s, including accepted plans and resumes",
(workMode) => {

View File

@ -7149,14 +7149,18 @@ export async function buildPaperclipWakePayload(input: {
const checkboxSelection = parseObject(
input.contextSnapshot.checkboxSelection,
);
const planReviewContext = issueId && !conversationMode
// A resolved plan review is new user input, including in chat. Ordinary chat
// wakes must still exclude historical plan context across /new boundaries.
const resolvedPlanInteraction = interactionId && interactionKind === "request_confirmation" &&
(interactionStatus === "accepted" || interactionStatus === "rejected");
const planReviewContext = issueId && (!conversationMode || resolvedPlanInteraction)
? await buildPlanReviewContext({
db: input.db,
companyId: input.companyId,
issueId,
issueWorkMode: issueSummary?.workMode ?? null,
includeForIssueComment: commentIds.length > 0,
includeForAnnotationDelta: annotationDeltas.length > 0,
issueWorkMode: conversationMode ? null : issueSummary?.workMode ?? null,
includeForIssueComment: !conversationMode && commentIds.length > 0,
includeForAnnotationDelta: !conversationMode && annotationDeltas.length > 0,
interactionId,
})
: null;
@ -7676,6 +7680,10 @@ export function buildPaperclipTaskMarkdown(input: {
kind?: string | null;
status?: string | null;
} | null;
planReview?: {
status?: string | null;
reason?: string | null;
} | null;
acceptedPlan?: {
documentId?: string | null;
revisionId?: string | null;
@ -7698,14 +7706,15 @@ export function buildPaperclipTaskMarkdown(input: {
const issue = input.issue;
const ancestors = (input.ancestors ?? []).slice(0, 6);
const wakeComment = input.wakeComment ?? null;
const rejectedPlan = input.planReview?.status === "rejected";
const acceptedPlanContinuation =
!issue?.conversationAgentId && !wakeComment &&
!rejectedPlan && !issue?.conversationAgentId && !wakeComment &&
(input.acceptedPlanContinuation ||
(input.interaction?.kind === "request_confirmation" &&
input.interaction.status === "accepted" &&
issue?.workMode === "planning"));
const acceptedChatPlan = Boolean(
issue?.conversationAgentId &&
!rejectedPlan && issue?.conversationAgentId &&
issue.workMode !== "ask" &&
!wakeComment &&
input.interaction?.kind === "request_confirmation" &&
@ -7770,6 +7779,16 @@ export function buildPaperclipTaskMarkdown(input: {
"Implement the accepted plan on this issue when the work is small and cohesive. Use the paperclip-converting-plans-to-tasks skill to decide whether decomposition is justified. Create the minimum child issue graph only for qualifying ownership, parallelism, dependency, review, or lifecycle boundaries. Do not create a child merely because a plan was accepted.",
);
}
if (rejectedPlan) {
lines.push(
"",
"Rejected plan review directive:",
"The user rejected the plan and requested changes. Revise the plan to address their feedback through the existing plan document and review workflow. In Ask mode, discuss the requested changes without mutating documents or tasks. This is not approval to implement or hand off execution tasks. Do not treat the issue's in_progress status as plan approval.",
);
if (input.planReview?.reason?.trim()) {
lines.push("User's requested changes:", fenceTaskText(input.planReview.reason.trim()));
}
}
if ((acceptedPlanContinuation || acceptedChatPlan) && input.acceptedPlan?.revisionId) {
const revisionNumber = input.acceptedPlan.revisionNumber
? ` revision ${input.acceptedPlan.revisionNumber}`
@ -18039,6 +18058,12 @@ export function heartbeatService(
kind: readNonEmptyString(context.interactionKind),
status: readNonEmptyString(context.interactionStatus),
},
planReview: paperclipWakePayload?.planReviewContext?.interaction
? {
status: paperclipWakePayload.planReviewContext.interaction.status,
reason: paperclipWakePayload.planReviewContext.interaction.result?.reason,
}
: null,
acceptedPlanContinuation:
readNonEmptyString(context.workspaceRefreshReason) ===
"accepted_plan_confirmation" &&

View File

@ -417,7 +417,6 @@ export async function runChatFlow(input: {
).toBe(true);
const clarification = `It is the garden club; use the existing Garden ${nonce} project. Make one assigned task for yourself to write a two-sentence welcome note. Include ${marker} in that note, save it as the output document, and finish that execution task. Please get it started now.`;
if (pendingQuestions?.length) {
expect(pendingQuestions.length).toBeLessThanOrEqual(3);
for (const [index, question] of pendingQuestions.entries()) {
const textInput = page
.getByTestId("question-text-answer-composer")