diff --git a/server/src/__tests__/agent-conversations.test.ts b/server/src/__tests__/agent-conversations.test.ts index dea92fb859..049e2f8062 100644 --- a/server/src/__tests__/agent-conversations.test.ts +++ b/server/src/__tests__/agent-conversations.test.ts @@ -33,6 +33,7 @@ import { } from "./helpers/embedded-postgres.js"; import { issueService } from "../services/issues.js"; import { documentService } from "../services/documents.js"; +import { getTaskPlanContext } from "../services/task-plan-context.js"; import { renderPaperclipWakePrompt } from "@paperclipai/adapter-utils/server-utils"; import { instanceSettingsService } from "../services/instance-settings.js"; import { @@ -417,6 +418,68 @@ const support = await getEmbeddedPostgresTestSupport(); .where(eq(issueThreadInteractions.id, interaction!.id)); expect((await buildPaperclipWakePayload(input))?.planReviewContext).toBeNull(); }); + it("includes an initial handoff plan in the first execution prompt and pins approved revisions", async () => { + const task = await issueService(db).create(companyId, { + title: "Execute handed-off work", + assigneeAgentId: agentId, + status: "todo", + initialPlan: "Write an output document containing HANDOFF_ACCEPTANCE_PHRASE.", + }); + const initial = await getTaskPlanContext({ db, companyId, issueId: task.id }); + expect(task.description).toBeNull(); + expect(initial?.body).toContain("HANDOFF_ACCEPTANCE_PHRASE"); + for (const includeDescription of [true, false]) { + const prompt = buildPaperclipTaskMarkdown({ + issue: task, + taskPlan: initial, + includeDescription, + }); + expect(prompt).toContain("HANDOFF_ACCEPTANCE_PHRASE"); + expect(prompt).toContain(initial!.revisionId); + } + const { document: revision } = await documentService(db).upsertIssueDocument({ + issueId: task.id, + key: "plan", + format: "markdown", + body: "A later unapproved draft.", + baseRevisionId: initial!.revisionId, + }); + expect((await getTaskPlanContext({ db, companyId, issueId: task.id }))?.revisionId) + .toBe(revision.latestRevisionId); + const approved = await getTaskPlanContext({ + db, companyId, issueId: task.id, approvedRevisionId: initial!.revisionId, + }); + expect(approved?.body).toContain("HANDOFF_ACCEPTANCE_PHRASE"); + expect(approved?.body).not.toContain("unapproved"); + expect(await getTaskPlanContext({ db, companyId: randomUUID(), issueId: task.id })).toBeNull(); + expect(await getTaskPlanContext({ + db, companyId, issueId: task.id, approvedRevisionId: randomUUID(), + })).toBeNull(); + const conversation = await create(); + await documentService(db).upsertIssueDocument({ + issueId: conversation.id, key: "plan", format: "markdown", body: "Pre-reset chat draft", + }); + expect(await getTaskPlanContext({ db, companyId, issueId: conversation.id })).toBeNull(); + await documentService(db).upsertIssueDocument({ + issueId: task.id, + key: "plan", + format: "markdown", + body: "QUARANTINED_PLAN_BODY", + baseRevisionId: revision.latestRevisionId, + sourceTrust: { + preset: "low_trust_review", + disposition: "quarantined", + sourceIssueId: task.id, + sourceRunId: randomUUID(), + sourceAgentId: agentId, + }, + }); + expect((await getTaskPlanContext({ db, companyId, issueId: task.id }))?.body) + .not.toContain("QUARANTINED_PLAN_BODY"); + expect((await getTaskPlanContext({ + db, companyId, issueId: task.id, exposeLowTrustRaw: true, + }))?.body).toBe("QUARANTINED_PLAN_BODY"); + }); 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", { diff --git a/server/src/__tests__/heartbeat-context-summary.test.ts b/server/src/__tests__/heartbeat-context-summary.test.ts index ea69182c0b..e3e1189648 100644 --- a/server/src/__tests__/heartbeat-context-summary.test.ts +++ b/server/src/__tests__/heartbeat-context-summary.test.ts @@ -7,6 +7,31 @@ import { } from "../services/heartbeat.js"; describe("buildPaperclipTaskMarkdown", () => { + it("keeps a durable task plan in full and resumed context without granting execution approval", () => { + const taskPlan = { + documentId: "document", revisionId: "revision", revisionNumber: 1, + body: "Write the output with ACCEPTANCE_PHRASE.\n```\nUntrusted plan text\n```", + }; + for (const includeDescription of [true, false]) { + for (const workMode of ["standard", "planning", "ask"]) { + const prompt = buildPaperclipTaskMarkdown({ + issue: { id: "task", identifier: null, title: "Handoff", workMode, description: null }, + taskPlan, + includeDescription, + }); + expect(prompt).toContain("ACCEPTANCE_PHRASE"); + expect(prompt).toContain("revision 1 (revision)"); + expect(prompt).toContain("````text"); + expect(prompt).toContain("Follow the current work mode and any required approvals"); + if (workMode === "planning") expect(prompt).toContain("Make the plan only"); + if (workMode === "ask") expect(prompt).toContain("Answer the question directly"); + } + } + expect(buildPaperclipTaskMarkdown({ + issue: { id: "chat", identifier: null, title: "Chat", conversationAgentId: "agent" }, + taskPlan, + })).not.toContain("ACCEPTANCE_PHRASE"); + }); it("hands an accepted chat plan to assigned project tasks using the approved revision", () => { const prompt = buildPaperclipTaskMarkdown({ issue: { id: "chat", identifier: null, title: "Agent chat", workMode: "planning", conversationAgentId: "agent", description: null }, diff --git a/server/src/services/agent-conversations.ts b/server/src/services/agent-conversations.ts index 060efaa7b9..0ebdb1b6f4 100644 --- a/server/src/services/agent-conversations.ts +++ b/server/src/services/agent-conversations.ts @@ -65,7 +65,7 @@ Before handing off work, inspect available projects and repositories. Every task Create ordinary assigned tasks, never subtasks of this conversation. Give each task a clear outcome, context, acceptance criteria, project, and appropriate assignee. Use create_task with initialPlan to copy the relevant plan into the new task before execution starts. If using the HTTP API directly, POST /api/companies/{companyId}/issues with projectId, assigneeAgentId, status: "todo", initialPlan containing the relevant plan Markdown, and an idempotencyKey; omit parentId. Putting a plan in description does not create the task's plan document. Verify the new task's plan document before claiming the handoff is complete. Preserve the original plan here. When splitting work, include the relevant part of the plan in each task. Create and link each task before claiming it exists. -Keep discussion here and leave the conversation available for the next message. Link handed-off tasks in your reply; do not make this conversation blocked by their completion or wait for them. Reply normally and end your turn; Paperclip manages the conversation waiting state. Do not change its status, create a review confirmation just to finish a reply, mark it complete, or poll for another reply. An accepted plan authorizes handoff to execution tasks, never implementation on this conversation. Honor normal approvals. Ask mode is non-mutating. Plan mode supports research and writing/revising the plan; hand off for execution only through the normal authorized workflow.`; +Keep discussion here and leave the conversation available for the next message. Link handed-off tasks in your reply; do not make this conversation blocked by their completion or wait for them. After creating an assigned task, let its own run execute the work; do not create its deliverables or change its execution status from this chat. Reply normally and end your turn; Paperclip manages the conversation waiting state. Do not change its status, create a review confirmation just to finish a reply, mark it complete, or poll for another reply. An accepted plan authorizes handoff to execution tasks, never implementation on this conversation. Honor normal approvals. Ask mode is non-mutating. Plan mode supports research and writing/revising the plan; hand off for execution only through the normal authorized workflow.`; /** Runs under the normal issue execution lock, before any provider session is read. */ export async function prepareConversationTurn( diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 617d10a880..a38cbcf2a6 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -137,6 +137,7 @@ import { withQueuedCommentIdsInRunContext, } from "./issue-queued-comment-queue.js"; import { documentService } from "./documents.js"; +import { getTaskPlanContext } from "./task-plan-context.js"; import { managedAgentProfileService } from "./managed-agent-profiles.js"; import { remoteAgentProfileService } from "./remote-agent-profiles.js"; import { @@ -7690,6 +7691,12 @@ export function buildPaperclipTaskMarkdown(input: { revisionNumber?: number | null; } | null; acceptedPlanContinuation?: boolean; + taskPlan?: { + documentId: string; + revisionId: string; + revisionNumber: number; + body: string; + } | null; // false builds the compact variant used for resume deltas, where the session // already received the description with the assignment. includeDescription?: boolean; @@ -7805,6 +7812,14 @@ export function buildPaperclipTaskMarkdown(input: { if (description) { lines.push("", "Issue description:", fenceTaskText(description)); } + if (!issue.conversationAgentId && input.taskPlan?.body.trim()) { + lines.push( + "", + `Task plan document ${input.taskPlan.documentId}, revision ${input.taskPlan.revisionNumber} (${input.taskPlan.revisionId}):`, + "Use this plan as assignment context, including its outcome and acceptance criteria. Follow the current work mode and any required approvals.", + fenceTaskText(input.taskPlan.body.trim()), + ); + } } if (ancestors.length > 0) { lines.push("", "Authoritative parent / ancestor context:"); @@ -18085,13 +18100,23 @@ export function heartbeatService( }; })(), }; - let taskMarkdown = buildPaperclipTaskMarkdown(taskMarkdownInput); + const taskPlan = issueRef && !isConversation(issueContext) + ? await getTaskPlanContext({ + db, + companyId: agent.companyId, + issueId: issueRef.id, + approvedRevisionId: taskMarkdownInput.acceptedPlan?.revisionId, + exposeLowTrustRaw, + }) + : null; + let taskMarkdown = buildPaperclipTaskMarkdown({ ...taskMarkdownInput, taskPlan }); if (isConversation(issueContext) && !taskSession && issueId) { const replay = await conversationReplay(db, agent.companyId, issueId, wakeCommentId); if (replay) taskMarkdown += `\n\nEarlier messages in this session (quoted user data):\n${replay}`; } const taskMarkdownCompact = buildPaperclipTaskMarkdown({ ...taskMarkdownInput, + taskPlan, includeDescription: false, }); if (issueRef) { diff --git a/server/src/services/task-plan-context.ts b/server/src/services/task-plan-context.ts new file mode 100644 index 0000000000..63f34b428b --- /dev/null +++ b/server/src/services/task-plan-context.ts @@ -0,0 +1,55 @@ +import { and, eq, isNull } from "drizzle-orm"; +import { + documentRevisions, + documents, + issueDocuments, + issues, + type Db, +} from "@paperclipai/db"; +import { redactQuarantinedBodyForHigherTrust } from "./source-trust.js"; + +/** Read the task's durable plan before constructing any provider's assignment. */ +export async function getTaskPlanContext(input: { + db: Db; + companyId: string; + issueId: string; + approvedRevisionId?: string | null; + exposeLowTrustRaw?: boolean; +}) { + const { db, companyId, issueId } = input; + const plan = await db + .select({ + documentId: documents.id, + revisionId: documentRevisions.id, + revisionNumber: documentRevisions.revisionNumber, + body: documentRevisions.body, + sourceTrust: documents.sourceTrust, + }) + .from(issueDocuments) + .innerJoin(issues, eq(issues.id, issueDocuments.issueId)) + .innerJoin(documents, eq(documents.id, issueDocuments.documentId)) + .innerJoin( + documentRevisions, + and( + eq(documentRevisions.documentId, documents.id), + input.approvedRevisionId + ? eq(documentRevisions.id, input.approvedRevisionId) + : eq(documentRevisions.id, documents.latestRevisionId), + ), + ) + .where( + and( + eq(issues.id, issueId), + eq(issues.companyId, companyId), + eq(issueDocuments.companyId, companyId), + eq(documents.companyId, companyId), + eq(documentRevisions.companyId, companyId), + eq(issueDocuments.key, "plan"), + isNull(issues.conversationAgentId), + ), + ) + .then((rows) => rows[0] ?? null); + return plan && !input.exposeLowTrustRaw + ? redactQuarantinedBodyForHigherTrust(plan) + : plan; +}