Files
PaperClipAI/server/src/__tests__/onboarding-first-task-admission.test.ts
T
DottaandPaperclip 4740d251b7 fix(onboarding): retain plan approval across follow-up decisions (#19043)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Onboarding asks a person to approve a saved plan before execution
tasks can start.
> - A person can then approve a separate proposal for the first
execution step.
> - The creation gate checks only that latest card for the plan target.
It loses the earlier approval of the unchanged plan.
> - Decomposition and native context also omit checkbox plan
confirmations.
> - This pull request validates the latest execution decision and the
latest plan decision together.
> - The benefit is that approved work can start while human, revision,
and proposal gates remain effective.

## Linked Issues or Issue Description

Refs #17914, #18160, and #18283. The related-PR search found no
duplicate repair.

**What happened?**

After a person accepted a checkbox plan card and a later execution
confirmation, native task creation and REST child creation returned 403.
Accepted-plan decomposition returned 422 because its lookup recognized
only standard confirmations. Native task context returned no accepted
plan for the checkbox decision.

**Expected behavior**

The latest execution proposal can use an independently approved,
unchanged plan. A pending or rejected latest proposal must still stop
execution. The current plan must retain valid human approval of its
exact revision.

**Steps to reproduce**

1. Save a plan on a server-created onboarding task.
2. Create a revision-bound checkbox confirmation and accept it as a
human.
3. Create and accept a separate, untargeted confirmation for the first
execution task.
4. Create the child through the native tool or REST API.
5. Separately, try accepted-plan decomposition or read native task
context after step 2.

**Paperclip version or commit**

The workspace's registered hosted release is Core
`09c05c55050fede448196637cbaa543a154da76e`. The three regressions also
failed against local source `3cdb367369f29692b81cf19b514a2abdba90734f`.

**Deployment mode**

Hosted native Runner with ACPX Claude. Local regression proof uses
actual PostgreSQL, authenticated HTTP routes, native tool authority, and
synthetic task data on macOS arm64. No customer data or credentials are
in the fixtures.

## What Changed

- Validate the newest ordinary proposal and the newest plan proposal
separately. Keep the existing human-card and conversational provenance
checks for each.
- Require the accepted plan target to match the current document and
revision. A newer pending, rejected, invalid, or foreign plan decision
cannot fall back to an older approval.
- Share persisted confirmation parsing across admission, decomposition,
and native context. Validate checkbox selections against the saved
options and limits.
- Return `acceptedPlan.approvedOptions` with the user's saved selected
IDs and option text on later wakes, including revision-pinned context.
Keep unchecked defaults out of scope.
- Add behavioral and safety regressions. Document the approval rule.

## Verification

- Three behavioral regressions failed before the repair: child creation
returned 403, decomposition returned 422, and native context returned
`acceptedPlan: null`. All three now pass.
- Two scope-retention regressions failed before the review fix: native
context omitted the selected option after a later approval, with and
without a pinned plan revision. Both now pass. Unchecked defaults stay
excluded.
- Current admission, native-tool, and accepted-confirmation replay
suites: 131 tests passed. Existing first-task completion safety checks:
17 passed, including the atomic decision recheck.
- Safety cases cover missing and rejected plan approval, agent-only
decisions, foreign source runs, changed revisions, invalid checkbox
selections, newer pending or rejected proposals, and task-creation
replay.
- Full repository `pnpm -r typecheck` and `pnpm build` passed locally on
final head `7c0e8ab784`.
- [Full final-head
CI](https://github.com/paperclipai/paperclip/actions/runs/41911580531)
passed: all Vitest shards, Runner checks, eight browser E2E shards,
build, release registry, and canary dry run. The required `verify` and
`e2e` aggregates passed. Storybook checks were skipped for this backend
change; the file-selection job was skipped because full CI ran.
- Earlier local typecheck attempts exposed Copilot verification during
build preparation, stale declarations across the rebase, and two
test-fixture typing errors. The assets were verified, declarations
rebuilt, and fixtures corrected before the complete final-head pass.
- Full local Vitest attempts were interrupted for the rebase and review
repair. The duplicate final local run was stopped after full Linux CI
passed. No complete macOS Vitest-suite pass is claimed.
- A fresh Claude-provider acceptance run remains unverified: the local
Claude account is signed out and no Anthropic API credential is
available. The actual native tool and HTTP paths are verified with real
PostgreSQL persistence. This PR does not deploy or recover the reported
production task.
- Greptile: 5/5 on `7c0e8ab784`, with the review thread resolved.

## Risks

A later acceptance must not revive rejected or changed scope. Admission
validates both current decisions and retains source, company, human
provenance, and document locks. The extra plan-proposal lookup is
bounded and limited to accepted, untargeted first-task proposals.
Ordinary delegation adds no new proof lookup. The native context adds an
optional `approvedOptions` projection. Revision-pinned context now
performs the existing scoped approval read to recover that selection. No
database schema, request, provider, or background worker changes are
included.

## Model Used

OpenAI Codex, exact model `gpt-6.1-sol`, max reasoning effort, with
repository, shell, browser, and GitHub tools. The current session
metadata confirms the model and effort. The context window size is not
exposed.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-04 15:24:58 -06:00

793 lines
52 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { Ajv2020 } from "ajv/dist/2020.js";
import { eq } from "drizzle-orm";
import { agentWakeupRequests, authUsers, companyMemberships, heartbeatRuns, issueComments, issueThreadInteractions, issues } from "@paperclipai/db";
import { createIssueThreadInteractionSchema } from "@paperclipai/shared";
import { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
import { documentService } from "../services/documents.js";
import { resolveConfirmationFromComment } from "../services/confirmation-comment-resolution.js";
import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js";
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { initializeRunIdentity } from "../services/run-identity.js";
import { issueService } from "../services/issues.js";
import { recoveryPostgresSupport as support } from "./helpers/recovery-postgres.js";
import { startRunnerApiTestServer } from "./helpers/runner-api-server.js";
import { CAPABILITY_SEMANTIC_TOOL_CATALOG } from "../vendor/paperclip-runner/index.js";
async function nativeProposalRecipe(kind: "confirmation" | "checkbox", planRevisionId: string = randomUUID()) {
const skill = await readFile(new URL("../onboarding-assets/first-task/skills/first-task/SKILL.md", import.meta.url), "utf8");
const recipes = [...skill.matchAll(/^ {0,3}```json\n([\s\S]*?)^ {0,3}```/gm)].map(match => JSON.parse(match[1]!) as Record<string, unknown>);
const matches = recipes.filter(recipe => recipe.interactionKind === kind);
expect(matches, `one executable native ${kind} recipe must ship with the first-task skill`).toHaveLength(1);
return JSON.parse(JSON.stringify(matches[0]).replaceAll("<child-title>", "Approved child")
.replaceAll("<plan-revision-id>", planRevisionId)) as Record<string, unknown>;
}
it.each(["confirmation", "checkbox"] as const)("ships a native %s recipe accepted by the production interaction schema", async kind => {
const recipe = await nativeProposalRecipe(kind);
const schema = CAPABILITY_SEMANTIC_TOOL_CATALOG.find(tool => tool.operationId === "request_human_input")!.inputSchema;
const validator = new Ajv2020({ allErrors: true, strict: false });
expect(validator.validate(schema, recipe), JSON.stringify(validator.errors)).toBe(true);
expect(recipe).not.toHaveProperty("version");
expect(recipe).not.toHaveProperty("supersedeOnUserComment");
expect(recipe.payload).toMatchObject({ version: 1, supersedeOnUserComment: false });
const parsed = createIssueThreadInteractionSchema.parse({ kind: kind === "confirmation" ? "request_confirmation" : "request_checkbox_confirmation",
idempotencyKey: recipe.idempotencyKey, title: recipe.title, continuationPolicy: recipe.continuationPolicy,
payload: { ...(recipe.payload as Record<string, unknown>), version: 1, prompt: recipe.prompt } });
expect(parsed.kind).toBe(kind === "confirmation" ? "request_confirmation" : "request_checkbox_confirmation");
});
if (!support.supported) console.warn(`Skipping first-task admission database tests: ${support.reason}`);
describe.skipIf(!support.supported)("onboarding first-task execution admission", () => {
let server: Awaited<ReturnType<typeof startRunnerApiTestServer>>;
const originalSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
beforeAll(async () => {
process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID();
server = await startRunnerApiTestServer({ humanSessions: true });
}, 60_000);
afterAll(async () => {
await server?.close();
if (originalSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
else process.env.PAPERCLIP_AGENT_JWT_SECRET = originalSecret;
});
const fixture = async () => {
const f = await server.fixture({ disableWakeOnDemand: true, connectionScenario: "fresh" });
await server.db.update(issues).set({ originKind: "onboarding_first_task" }).where(eq(issues.id, f.issueId));
return f;
};
it("rejects native child creation before persisted human proposal acceptance", async () => {
const f = await fixture();
const before = await f.snapshot();
const [beforeRun] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
const wakes: unknown[] = [];
const authority = new PaperclipRunnerToolAuthority(server.db, { ...f, enqueueWakeup: async (...args) => { wakes.push(args); return null; } });
await expect(authority.execute({ tool: "create_task", callId: randomUUID(), arguments: {
title: "Execute proposed work", idempotencyKey: "before-acceptance", status: "todo",
} })).rejects.toThrow(/proposal.*accept/i);
const after = await f.snapshot();
expect(after.issues).toEqual(before.issues);
expect(after.activity).toEqual(before.activity);
expect(wakes).toEqual([]);
const [afterRun] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
expect(afterRun.resultJson).toEqual(beforeRun.resultJson);
});
it.each(["root", "child"])("rejects generic %s creation including explicit standalone parent before acceptance", async path => {
const f = await fixture();
const before = await f.snapshot();
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId)!;
const route = path === "root" ? `/api/companies/${f.companyId}/issues` : `/api/issues/${f.issueId}/children`;
const response = await fetch(`${server.apiUrl}${route}`, {
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ title: "Bypass proposal", parentId: null, assigneeAgentId: f.agentId, status: "todo" }),
});
const body = await response.json();
expect(response.status, JSON.stringify(body)).toBe(403);
expect(body.error).toMatch(/proposal.*accept/i);
const after = await f.snapshot();
expect(after.issues).toEqual(before.issues);
expect(after.activity).toEqual(before.activity);
});
const create = (f: Awaited<ReturnType<typeof fixture>>, key = randomUUID()) => f.authority.execute({
tool: "create_task", callId: randomUUID(), arguments: { title: "Approved execution", idempotencyKey: key, status: "backlog" },
});
const proposal = async (f: Awaited<ReturnType<typeof fixture>>, checkbox = false) => {
const [issue] = await server.db.select().from(issues).where(eq(issues.id, f.issueId));
const plan = checkbox ? (await documentService(server.db).upsertIssueDocument({
issueId: f.issueId, key: "plan", title: "First task plan", format: "markdown", body: "Create and verify the approved deliverable.",
createdByAgentId: f.agentId, createdByRunId: f.runId,
})).document : null;
const svc = issueThreadInteractionService(server.db);
const card = await svc.create(issue, checkbox ? {
kind: "request_checkbox_confirmation", payload: { version: 1, prompt: "Approve this plan?",
options: [{ id: "execute", label: "Execute the plan" }], defaultSelectedOptionIds: ["execute"], minSelected: 1,
target: { type: "issue_document", key: "plan", issueId: f.issueId, documentId: plan!.id, revisionId: plan!.latestRevisionId! } },
} : { kind: "request_confirmation", payload: { version: 1, prompt: "Create one task for the agreed deliverable?" } }, { agentId: f.agentId });
return { issue, card, svc, plan };
};
const accept = async (f: Awaited<ReturnType<typeof fixture>>, p: Awaited<ReturnType<typeof proposal>>) => {
await p.svc.acceptInteraction(p.issue, p.card.id, p.plan ? { selectedOptionIds: ["execute"] } : {}, { userId: f.responsibleUserId! });
};
it("rejects a first-task child blocked by its onboarding parent and permits a corrected retry", async () => {
const f = await fixture();
await accept(f, await proposal(f));
const wakes: unknown[] = [];
const authority = new PaperclipRunnerToolAuthority(server.db, { ...f, enqueueWakeup: async (...args) => { wakes.push(args); return null; } });
const before = await f.snapshot();
const [beforeRun] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
const input = { title: "Approved artifact child", idempotencyKey: "first-task-handoff", status: "todo",
blockedByTaskIds: [f.issueId] };
const rejected = authority.execute({ tool: "create_task", callId: randomUUID(), arguments: input });
await expect(rejected).rejects.toMatchObject({
status: 400, details: { code: "onboarding_first_task_parent_blocker" },
});
await expect(rejected).rejects.toThrow(/Remove.*blockedByTaskIds.*set_dependencies/);
const after = await f.snapshot();
expect(after.issues).toEqual(before.issues);
expect(after.issueRelations).toEqual(before.issueRelations);
expect(after.activity).toEqual(before.activity);
expect(after.issueDocuments).toEqual(before.issueDocuments);
expect(wakes).toEqual([]);
const [afterRun] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
expect(afterRun.resultJson).toEqual(beforeRun.resultJson);
// A definite input error must not consume the command key or approval.
const corrected = await authority.execute({ tool: "create_task", callId: randomUUID(),
arguments: { ...input, blockedByTaskIds: [] } }) as { task: { id: string }; scheduledWakeIds: string[] };
expect(corrected).toMatchObject({ task: { parentId: f.issueId, status: "todo", assigneeActorId: f.agentId } });
expect(corrected.scheduledWakeIds).toEqual([`created-child:${corrected.task.id}`]);
expect(wakes).toHaveLength(1);
});
it.each(["root", "child"])("rejects a backwards first-task dependency through generic %s creation", async path => {
const f = await fixture();
await accept(f, await proposal(f));
const before = await f.snapshot();
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
const route = path === "root" ? `/api/companies/${f.companyId}/issues` : `/api/issues/${f.issueId}/children`;
const response = await fetch(`${server.apiUrl}${route}`, {
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ title: "Approved execution", parentId: null, assigneeAgentId: f.agentId,
status: "todo", blockedByIssueIds: [f.issueId] }),
});
expect(response.status, JSON.stringify(await response.json())).toBe(400);
const after = await f.snapshot();
expect(after.issues).toEqual(before.issues);
expect(after.issueRelations).toEqual(before.issueRelations);
expect(after.activity).toEqual(before.activity);
});
it("preserves other first-task blockers and ordinary parent-blocked delegation", async () => {
for (const onboarding of [true, false]) {
const f = await fixture();
if (onboarding) await accept(f, await proposal(f));
else await server.db.update(issues).set({ originKind: "manual", description: "Ordinary task mentioning /first-task." }).where(eq(issues.id, f.issueId));
const blockerId = onboarding ? f.blockerId : f.issueId;
const wakes: unknown[] = [];
const authority = new PaperclipRunnerToolAuthority(server.db, { ...f, enqueueWakeup: async (...args) => { wakes.push(args); return null; } });
const created = await authority.execute({ tool: "create_task", callId: randomUUID(), arguments: {
title: "Work with a real prerequisite", idempotencyKey: "valid-blocker", status: "todo", blockedByTaskIds: [blockerId],
} }) as { task: { id: string } };
expect(created).toMatchObject({ task: { status: "blocked", parentId: f.issueId }, scheduledWakeIds: [] });
expect((await f.snapshot()).issueRelations).toContainEqual(expect.objectContaining({
issueId: blockerId, relatedIssueId: created.task.id, type: "blocks",
}));
expect(wakes).toEqual([]);
}
});
it("executes the shipped handoff recipe with exact inputs and the child blocking its parent", async () => {
const f = await fixture();
const inputBytes = Buffer.from('{"nonce":"handoff-snowman-☃","values":[2,5,-1]}\n');
const encodedInput = inputBytes.toString("base64");
const description = `Decode this exact base64 into handoff-input.json, preserving its final newline:\n${encodedInput}\n`
+ "Write handoff-proof.json with the nonce, value count, total, and SHA-256 of the exact input bytes. "
+ "Save the proof document and register the file on this task. Do not create other tasks or access credentials.";
const p = await proposal(f);
await server.db.update(issueThreadInteractions).set({ payload: { version: 1, prompt: description } })
.where(eq(issueThreadInteractions.id, p.card.id));
await accept(f, p);
const skill = await readFile(new URL("../onboarding-assets/first-task/skills/first-task/SKILL.md", import.meta.url), "utf8");
const recipe = [...skill.matchAll(/```json\s+([\s\S]*?)```/g)]
.map(match => JSON.parse(match[1]!) as Record<string, Record<string, unknown>>)
.find(value => value.createTask && value.setDependencies);
if (!recipe) throw new Error("Missing shipped first-task handoff recipe");
const wakes: unknown[] = [];
const authority = new PaperclipRunnerToolAuthority(server.db, { ...f, enqueueWakeup: async (...args) => { wakes.push(args); return null; } });
await authority.execute({ tool: "set_dependencies", callId: randomUUID(), arguments: {
idempotencyKey: "existing-prerequisite", blockedByTaskIds: [f.blockerId],
} });
const created = await authority.execute({ tool: "create_task", callId: randomUUID(), arguments: {
...recipe.createTask, idempotencyKey: `first-task-child:${p.card.id}`,
title: "Approved input proof", description, assigneeActorId: f.agentId,
} }) as { task: { id: string } };
expect(created).toMatchObject({ task: { status: "todo", parentId: f.issueId },
scheduledWakeIds: [`created-child:${created.task.id}`] });
expect(wakes).toHaveLength(1);
await authority.execute({ tool: "set_dependencies", callId: randomUUID(), arguments: {
...recipe.setDependencies, idempotencyKey: `first-task-wait:${p.card.id}`,
blockedByTaskIds: [f.blockerId, created.task.id],
} });
const relations = (await f.snapshot()).issueRelations;
expect(relations).toHaveLength(2);
for (const blocker of [f.blockerId, created.task.id]) expect(relations).toContainEqual(expect.objectContaining({
issueId: blocker, relatedIssueId: f.issueId, type: "blocks",
}));
// Read under the child's own native execution binding, without a model call.
const childRunId = randomUUID();
await server.db.insert(heartbeatRuns).values({ id: childRunId, companyId: f.companyId, agentId: f.agentId,
status: "running", runtimeMode: "native", nativeIssueId: created.task.id,
responsibleUserId: f.responsibleUserId, contextSnapshot: { issueId: created.task.id } });
await server.db.update(issues).set({ status: "in_progress", executionRunId: childRunId }).where(eq(issues.id, created.task.id));
const childAuthority = new PaperclipRunnerToolAuthority(server.db, { ...f, issueId: created.task.id, runId: childRunId });
const context = await childAuthority.execute({ tool: "get_task_context", callId: randomUUID(), arguments: {} }) as {
activeTask: { description: string };
};
expect(context.activeTask.description).toBe(description);
const recoveredInput = Buffer.from(context.activeTask.description.split("\n")[1]!, "base64");
expect(recoveredInput).toEqual(inputBytes);
});
it.each(["uppercase", "hyphenless", "braced"])("rejects a PostgreSQL-equivalent %s onboarding blocker UUID", async variant => {
const f = await fixture();
await accept(f, await proposal(f));
const sourceId = variant === "uppercase" ? f.issueId.toUpperCase()
: variant === "hyphenless" ? f.issueId.replaceAll("-", "") : `{${f.issueId}}`;
const before = await f.snapshot();
await expect(f.authority.execute({ tool: "create_task", callId: randomUUID(), arguments: {
title: "Approved child", idempotencyKey: "equivalent-parent", status: "todo", blockedByTaskIds: [sourceId],
} })).rejects.toMatchObject({ status: 400, details: { code: "onboarding_first_task_parent_blocker" } });
const after = await f.snapshot();
expect(after.issues).toEqual(before.issues);
expect(after.issueRelations).toEqual(before.issueRelations);
});
it("rejects adding the onboarding source blocker through a child PATCH", async () => {
const f = await fixture();
await accept(f, await proposal(f));
const created = await f.authority.execute({ tool: "create_task", callId: randomUUID(), arguments: {
title: "Approved ready child", idempotencyKey: "ready-child", status: "todo",
} }) as { task: { id: string } };
const before = await f.snapshot();
const beforeWakes = await server.db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId));
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
const response = await fetch(`${server.apiUrl}/api/issues/${created.task.id}`, {
method: "PATCH", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ status: "blocked", blockedByIssueIds: [f.issueId.toUpperCase()] }),
});
const body = await response.json();
expect(response.status, JSON.stringify(body)).toBe(400);
expect(body).toMatchObject({ details: { code: "onboarding_first_task_parent_blocker" } });
const after = await f.snapshot();
expect(after.issues).toEqual(before.issues);
expect(after.issueRelations).toEqual(before.issueRelations);
const priorActivityIds = new Set(before.activity.map(row => row.id));
expect(after.activity.filter(row => !priorActivityIds.has(row.id))).toEqual([expect.objectContaining({
action: "issue.cross_issue_influence_observed", entityId: created.task.id, runId: f.runId,
details: expect.objectContaining({ sourceIssueId: f.issueId, targetIssueId: created.task.id, kind: "update", allowed: true }),
})]);
expect(await server.db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))).toEqual(beforeWakes);
});
const childExecution = async (onboarding = true) => {
const f = await fixture();
if (onboarding) await accept(f, await proposal(f));
else await server.db.update(issues).set({ originKind: "manual" }).where(eq(issues.id, f.issueId));
const created = await f.authority.execute({ tool: "create_task", callId: randomUUID(), arguments: {
title: "Approved ready child", idempotencyKey: "own-execution-child", status: "todo",
} }) as { task: { id: string } };
// The parent is waiting, without a first-class dependency. This is not a
// graph cycle: the forbidden parent -> child edge would be the only edge.
await server.db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date() }).where(eq(heartbeatRuns.id, f.runId));
await server.db.update(issues).set({ status: "blocked", executionRunId: null }).where(eq(issues.id, f.issueId));
const childRunId = randomUUID();
await server.db.insert(heartbeatRuns).values({ id: childRunId, companyId: f.companyId, agentId: f.agentId,
status: "running", runtimeMode: "native", nativeIssueId: created.task.id,
responsibleUserId: f.responsibleUserId, contextSnapshot: { issueId: created.task.id } });
await server.db.update(issues).set({ status: "in_progress", executionRunId: childRunId }).where(eq(issues.id, created.task.id));
await initializeRunIdentity(server.db, { companyId: f.companyId, runId: childRunId, issueId: created.task.id,
responsibleUserId: f.responsibleUserId, cause: "instruction" });
const childAuthority = new PaperclipRunnerToolAuthority(server.db, { ...f, issueId: created.task.id, runId: childRunId });
await issueService(server.db).checkout(created.task.id, f.agentId, ["in_progress"], childRunId);
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", childRunId, f.responsibleUserId)!;
return { f, childId: created.task.id, childRunId, childAuthority, token };
};
it.each(["native", "REST"])("rejects the onboarding parent blocker from the child's own %s execution", async transport => {
const { f, childId, childRunId, childAuthority, token } = await childExecution();
const input = { idempotencyKey: "child-own-parent-blocker", blockedByTaskIds: [f.issueId.toUpperCase()] };
const before = await f.snapshot();
const [beforeRun] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, childRunId));
const beforeWakes = await server.db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId));
if (transport === "native") {
await expect(childAuthority.execute({ tool: "set_dependencies", callId: randomUUID(), arguments: input })).rejects.toMatchObject({
status: 400, details: { code: "onboarding_first_task_parent_blocker" },
});
} else {
const response = await fetch(`${server.apiUrl}/api/issues/${childId}`, {
method: "PATCH", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ status: "blocked", blockedByIssueIds: input.blockedByTaskIds }),
});
expect(response.status, JSON.stringify(await response.json())).toBe(400);
}
const after = await f.snapshot();
expect(after.issues).toEqual(before.issues);
expect(after.issueRelations).toEqual([]);
expect(after.activity).toEqual(before.activity);
const [afterRun] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, childRunId));
expect(afterRun.resultJson).toEqual(beforeRun.resultJson);
expect(await server.db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))).toEqual(beforeWakes);
// A definite input error leaves the key available and other blockers valid.
for (const blockedByTaskIds of [[f.blockerId], []]) await expect(childAuthority.execute({ tool: "set_dependencies",
callId: randomUUID(), arguments: { ...input, idempotencyKey: blockedByTaskIds.length ? input.idempotencyKey : randomUUID(), blockedByTaskIds },
})).resolves.toMatchObject({ disposition: "applied" });
});
it.each(["native", "REST", "board"])("preserves ordinary parent blockers and explicit board edits (%s)", async actor => {
const { f, childId, childAuthority, token } = await childExecution(actor === "board");
if (actor === "native") {
await expect(childAuthority.execute({ tool: "set_dependencies", callId: randomUUID(), arguments: {
idempotencyKey: "ordinary-parent-blocker", blockedByTaskIds: [f.issueId],
} })).resolves.toMatchObject({ disposition: "applied" });
} else {
const headers: Record<string, string> = actor === "board"
? { Cookie: await server.humanSession(f.responsibleUserId!), "Content-Type": "application/json", Origin: server.apiUrl }
: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
const response = await fetch(`${server.apiUrl}/api/issues/${childId}`, {
method: "PATCH", headers, body: JSON.stringify({ blockedByIssueIds: [f.issueId] }),
});
expect(response.status, JSON.stringify(await response.json())).toBe(200);
}
expect((await f.snapshot()).issueRelations).toContainEqual(expect.objectContaining({
issueId: f.issueId, relatedIssueId: childId, type: "blocks",
}));
});
it("guards native dependency edits without consuming the retry key", async () => {
const f = await fixture();
await accept(f, await proposal(f));
const before = await f.snapshot();
const [beforeRun] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
const input = { idempotencyKey: "native-parent-blocker", blockedByTaskIds: [f.issueId.toUpperCase()] };
await expect(f.authority.execute({ tool: "set_dependencies", callId: randomUUID(), arguments: input })).rejects.toMatchObject({
status: 400, details: { code: "onboarding_first_task_parent_blocker" },
});
const after = await f.snapshot();
expect(after.issues).toEqual(before.issues);
expect(after.issueRelations).toEqual(before.issueRelations);
const [afterRun] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
expect(afterRun.resultJson).toEqual(beforeRun.resultJson);
await expect(f.authority.execute({ tool: "set_dependencies", callId: randomUUID(), arguments: {
...input, blockedByTaskIds: [f.blockerId],
} })).resolves.toMatchObject({ disposition: "applied" });
});
it("keeps blocker edits and clears independent of a newer first-task proposal", async () => {
const f = await fixture();
await accept(f, await proposal(f));
const created = await f.authority.execute({ tool: "create_task", callId: randomUUID(), arguments: {
title: "Existing approved child", idempotencyKey: "approved-before-new-proposal", status: "todo",
} }) as { task: { id: string } };
await proposal(f);
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
for (const blockedByIssueIds of [[f.blockerId], []]) {
const response = await fetch(`${server.apiUrl}/api/issues/${created.task.id}`, {
method: "PATCH", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ blockedByIssueIds }),
});
expect(response.status, JSON.stringify(await response.json())).toBe(200);
}
for (const blockedByTaskIds of [[created.task.id], []]) await expect(f.authority.execute({
tool: "set_dependencies", callId: randomUUID(), arguments: { idempotencyKey: randomUUID(), blockedByTaskIds },
})).resolves.toMatchObject({ disposition: "applied" });
expect((await f.snapshot()).issueRelations).toEqual([]);
await expect(create(f)).rejects.toThrow(/proposal.*accept/i);
});
it("preserves an ordinary run's parent-blocked child update", async () => {
const f = await fixture();
await server.db.update(issues).set({ originKind: "manual" }).where(eq(issues.id, f.issueId));
const created = await f.authority.execute({ tool: "create_task", callId: randomUUID(), arguments: {
title: "Ordinary child", idempotencyKey: "ordinary-child", status: "todo",
} }) as { task: { id: string } };
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
const response = await fetch(`${server.apiUrl}/api/issues/${created.task.id}`, {
method: "PATCH", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ status: "blocked", blockedByIssueIds: [f.issueId] }),
});
expect(response.status, JSON.stringify(await response.json())).toBe(200);
expect((await f.snapshot()).issueRelations).toContainEqual(expect.objectContaining({
issueId: f.issueId, relatedIssueId: created.task.id, type: "blocks",
}));
});
it("an explicit Approve/Revise question remains input rather than proposal authority after the human answers", async () => {
const f = await fixture();
const issueIds = (await f.snapshot()).issues.map(issue => issue.id).sort();
const { interaction } = await f.authority.execute({ tool: "request_human_input", callId: randomUUID(), arguments: {
idempotencyKey: "first-task-proposal-1", interactionKind: "questions", title: "Approve child task",
prompt: "I propose exactly one child task and will wait for approval.", continuationPolicy: "wake_assignee",
payload: { questionSet: { schema: "paperclip.question_set.v1", questions: [{ id: "approve_child_task",
prompt: 'Approve exactly one child task named "Approved child"?', required: true, answerMode: "single_select",
options: [{ id: "approve", label: "Approve" }, { id: "revise", label: "Revise" }] }] } },
} }) as { interaction: { id: string; kind: string } };
expect(interaction.kind).toBe("ask_user_questions");
await expect(create(f)).rejects.toThrow(/proposal.*accept/i);
const answered = await issueThreadInteractionService(server.db).answerQuestions({ id: f.issueId, companyId: f.companyId }, interaction.id,
{ answers: [{ questionId: "approve_child_task", optionIds: ["approve"] }] }, { userId: f.responsibleUserId! });
expect(answered).toMatchObject({ status: "answered", resolvedByUserId: f.responsibleUserId,
result: { answers: [{ questionId: "approve_child_task", optionIds: ["approve"] }] } });
await expect(create(f)).rejects.toThrow(/proposal.*accept/i);
expect((await f.snapshot()).issues.map(issue => issue.id).sort()).toEqual(issueIds);
});
it.each(["confirmation", "checkbox"] as const)("the shipped native %s recipe requires a human decision before it admits child work", async kind => {
for (const actor of ["human", "agent"] as const) {
const f = await fixture();
const plan = kind === "checkbox" ? (await documentService(server.db).upsertIssueDocument({ issueId: f.issueId, key: "plan",
title: "Approved child plan", format: "markdown", body: "Create one child for the proposed deliverable.",
createdByAgentId: f.agentId, createdByRunId: f.runId })).document : null;
const args = await nativeProposalRecipe(kind, plan?.latestRevisionId ?? undefined);
const receipt = await f.authority.execute({ tool: "request_human_input", callId: randomUUID(), arguments: args }) as { interaction: { id: string; kind: string } };
const { interaction } = receipt;
await expect(f.authority.execute({ tool: "request_human_input", callId: randomUUID(), arguments: args })).resolves.toEqual(receipt);
expect(await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, f.issueId))).toHaveLength(1);
expect(interaction.kind).toBe(kind === "confirmation" ? "request_confirmation" : "request_checkbox_confirmation");
await expect(create(f)).rejects.toThrow(/proposal.*accept/i);
const selectedOptionIds = kind === "checkbox" ? ((args.payload as { options: { id: string }[] }).options.map(option => option.id)) : undefined;
await issueThreadInteractionService(server.db).acceptInteraction({ id: f.issueId, companyId: f.companyId, projectId: null, goalId: null }, interaction.id,
selectedOptionIds ? { selectedOptionIds } : {}, actor === "human" ? { userId: f.responsibleUserId! } : { agentId: f.agentId, runId: f.runId });
if (actor === "human") await expect(create(f)).resolves.toMatchObject({ task: { parentId: f.issueId } });
else await expect(create(f)).rejects.toThrow(/proposal.*accept/i);
}
});
it.each(["confirmation", "checkbox"] as const)("rejecting the shipped native %s recipe saves an assignee continuation without authorizing child work", async kind => {
const f = await fixture();
const issueIds = (await f.snapshot()).issues.map(issue => issue.id).sort();
const plan = kind === "checkbox" ? (await documentService(server.db).upsertIssueDocument({ issueId: f.issueId, key: "plan",
title: "Proposed child plan", format: "markdown", body: "Create one child for the proposed deliverable.",
createdByAgentId: f.agentId, createdByRunId: f.runId })).document : null;
const args = await nativeProposalRecipe(kind, plan?.latestRevisionId ?? undefined);
const { interaction } = await f.authority.execute({ tool: "request_human_input", callId: randomUUID(), arguments: args }) as { interaction: { id: string } };
const response = await fetch(`${server.apiUrl}/api/issues/${f.issueId}/interactions/${interaction.id}/reject`, {
method: "POST", headers: { cookie: await server.humanSession(f.responsibleUserId!), "Content-Type": "application/json", origin: server.apiUrl },
body: JSON.stringify({ reason: "Please revise the proposed scope." }),
});
const rejected = await response.json();
expect(rejected, JSON.stringify(rejected)).toMatchObject({ status: "rejected", continuationPolicy: "wake_assignee",
resolvedByUserId: f.responsibleUserId, result: { outcome: "rejected", reason: "Please revise the proposed scope." } });
expect(response.status).toBe(200);
const wakes = (await server.db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId)))
.filter(wake => wake.payload?.interactionId === interaction.id);
expect(wakes).toHaveLength(1);
expect(wakes[0]).toMatchObject({ agentId: f.agentId, requestedByActorType: "user", requestedByActorId: f.responsibleUserId,
payload: { issueId: f.issueId, interactionId: interaction.id, interactionStatus: "rejected",
paperclipAgentMessage: { text: "Please revise the proposed scope.", source: "interaction_rejection" } } });
// The fixture disables execution: observe the real durable wake, not a model call.
expect((await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).map(run => run.id)).toEqual([f.runId]);
await expect(create(f)).rejects.toThrow(/proposal.*accept/i);
expect((await f.snapshot()).issues.map(issue => issue.id).sort()).toEqual(issueIds);
});
it.each([false, true])("admits persisted human proposal acceptance (checkbox=%s)", async checkbox => {
const f = await fixture();
const p = await proposal(f, checkbox);
await accept(f, p);
await expect(create(f)).resolves.toMatchObject({ task: { parentId: f.issueId } });
});
it("admits a persisted conversational acceptance with actual same-task human comment provenance", async () => {
const f = await fixture();
const p = await proposal(f);
const [comment] = await server.db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId,
authorUserId: f.responsibleUserId!, authorType: "user", body: "Yes, create that task." }).returning();
await resolveConfirmationFromComment(server.db, { companyId: f.companyId, issueId: f.issueId,
interactionId: p.card.id, actor: { agentId: f.agentId, runId: f.runId }, input: { commentId: comment.id, decision: "accept" } });
const [saved] = await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, p.card.id));
expect(saved).toMatchObject({ status: "accepted", resolvedByAgentId: f.agentId, resolvedByUserId: null,
result: { outcome: "accepted", commentId: comment.id } });
await expect(create(f)).resolves.toMatchObject({ task: { parentId: f.issueId } });
});
it.each(["card", "conversation"])("preserves valid %s acceptance across clocks and a changed responding user", async kind => {
const f = await fixture();
const originalUserId = randomUUID();
await server.db.insert(authUsers).values({ id: originalUserId, name: "Original requester",
email: `${originalUserId}@fixture.invalid`, createdAt: new Date(), updatedAt: new Date() });
await server.db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user",
principalId: originalUserId, status: "active", membershipRole: "member" });
await server.db.update(issues).set({ responsibleUserId: originalUserId }).where(eq(issues.id, f.issueId));
const p = await proposal(f);
if (kind === "card") await accept(f, p);
else {
const [comment] = await server.db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId,
authorUserId: f.responsibleUserId!, authorType: "user", body: "Yes, create that task." }).returning();
await resolveConfirmationFromComment(server.db, { companyId: f.companyId, issueId: f.issueId,
interactionId: p.card.id, actor: { agentId: f.agentId, runId: f.runId }, input: { commentId: comment.id, decision: "accept" } });
}
// Database-created timestamps and application decision timestamps have
// independent clocks. Persisted authority must not depend on their order.
await server.db.update(issueThreadInteractions).set({ resolvedAt: new Date(new Date(p.card.createdAt).getTime() - 100) })
.where(eq(issueThreadInteractions.id, p.card.id));
await expect(create(f)).resolves.toMatchObject({ task: { parentId: f.issueId } });
});
it("preserves acceptance of a previously dismissed proposal", async () => {
const f = await fixture();
const p = await proposal(f);
await p.svc.dismissInteraction(p.issue, p.card.id, { userId: f.responsibleUserId! });
await expect(create(f)).rejects.toThrow(/proposal.*accept/i);
await accept(f, p);
const [saved] = await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, p.card.id));
expect(saved.dismissedAt).not.toBeNull();
await expect(create(f)).resolves.toMatchObject({ task: { parentId: f.issueId } });
});
it.each(["opening-answer", "agent-self-accept", "old-comment", "foreign-comment", "derived-comment", "governed-accept", "foreign-source-run"])(
"does not treat %s as persisted human proposal acceptance", async kind => {
const f = await fixture();
const p = await proposal(f);
if (kind === "opening-answer") {
await server.db.update(issueThreadInteractions).set({ kind: "ask_user_questions", status: "answered",
result: { version: 1, answers: [] }, resolvedByUserId: f.responsibleUserId!, resolvedAt: new Date() }).where(eq(issueThreadInteractions.id, p.card.id));
} else if (kind === "agent-self-accept") {
await p.svc.acceptInteraction(p.issue, p.card.id, {}, { agentId: f.agentId, runId: f.runId });
} else if (kind === "governed-accept") {
await accept(f, p);
await server.db.update(issueThreadInteractions).set({ payload: { ...p.card.payload, toolAction: {} } as never }).where(eq(issueThreadInteractions.id, p.card.id));
} else if (kind === "foreign-source-run") {
const other = await server.fixture({ disableWakeOnDemand: true });
await accept(f, p);
await server.db.update(issueThreadInteractions).set({ sourceRunId: other.runId }).where(eq(issueThreadInteractions.id, p.card.id));
} else {
const [comment] = await server.db.insert(issueComments).values({ companyId: f.companyId,
issueId: kind === "foreign-comment" ? f.blockerId : f.issueId,
authorUserId: f.responsibleUserId!, authorType: "user", body: "A reply",
...(kind === "old-comment" ? { createdAt: new Date(new Date(p.card.createdAt).getTime() - 1_000) } : {}),
...(kind === "derived-comment" ? { derivedAuthorAgentId: f.agentId, derivedCreatedByRunId: f.runId } : {}),
}).returning();
await server.db.update(issueThreadInteractions).set({ status: "accepted", resolvedAt: new Date(),
resolvedByAgentId: f.agentId, resolvedByRunId: f.runId,
result: { version: 1, outcome: "accepted", commentId: comment.id } }).where(eq(issueThreadInteractions.id, p.card.id));
}
await expect(create(f)).rejects.toThrow(/proposal.*accept/i);
});
it.each(["pending", "rejected"])("a newer cross-kind %s proposal supersedes old acceptance", async status => {
const f = await fixture();
const old = await proposal(f);
await accept(f, old);
const current = await proposal(f, true);
await server.db.update(issueThreadInteractions).set({ createdAt: new Date(new Date(old.card.createdAt).getTime() + 1_000),
...(status === "rejected" ? { status, resolvedByUserId: f.responsibleUserId!, resolvedAt: new Date(),
result: { version: 1, outcome: "rejected" } as const } : {}),
}).where(eq(issueThreadInteractions.id, current.card.id));
await expect(create(f)).rejects.toThrow(/proposal.*accept/i);
});
it("does not substitute untargeted acceptance for approval of a saved plan", async () => {
const f = await fixture();
const p = await proposal(f);
await documentService(server.db).upsertIssueDocument({ issueId: f.issueId, key: "plan", format: "markdown",
body: "This saved plan requires acceptance of its exact revision.", createdByAgentId: f.agentId, createdByRunId: f.runId });
await accept(f, p);
await expect(create(f)).rejects.toThrow(/proposal.*accept/i);
});
const acceptFollowUp = async (f: Awaited<ReturnType<typeof fixture>>, p: Awaited<ReturnType<typeof proposal>>) => {
const card = await p.svc.create(p.issue, { kind: "request_confirmation",
payload: { version: 1, prompt: "Create the first read-only discovery task from the approved plan?" },
sourceRunId: f.runId,
}, { agentId: f.agentId, runId: f.runId });
await p.svc.acceptInteraction(p.issue, card.id, {}, { userId: f.responsibleUserId! });
return card;
};
it("retains approval of the current plan through a later accepted execution proposal", async () => {
const f = await fixture();
const p = await proposal(f, true);
await accept(f, p);
await acceptFollowUp(f, p);
await expect(create(f)).resolves.toMatchObject({ task: { parentId: f.issueId } });
});
it.each(["pending", "rejected"])("keeps a later %s execution proposal behind its human gate", async status => {
const f = await fixture();
const p = await proposal(f, true);
await accept(f, p);
const card = await p.svc.create(p.issue, { kind: "request_confirmation",
payload: { version: 1, prompt: "Create the next execution task?" },
}, { agentId: f.agentId });
if (status === "rejected") await p.svc.rejectInteraction(p.issue, card.id, {}, { userId: f.responsibleUserId! });
await expect(create(f)).rejects.toThrow(/proposal.*accept/i);
});
it.each(["pending", "rejected", "agent-only", "invalid-selection", "revised-plan", "foreign-source"])(
"a later accepted proposal cannot replace %s plan approval", async scenario => {
const f = await fixture();
const p = await proposal(f, true);
if (scenario === "rejected") await p.svc.rejectInteraction(p.issue, p.card.id, {}, { userId: f.responsibleUserId! });
else if (scenario === "agent-only") await p.svc.acceptInteraction(p.issue, p.card.id, { selectedOptionIds: ["execute"] }, { agentId: f.agentId, runId: f.runId });
else if (scenario !== "pending") await accept(f, p);
if (scenario === "invalid-selection") await server.db.update(issueThreadInteractions)
.set({ result: { version: 1, outcome: "accepted", selectedOptionIds: ["unapproved"] } }).where(eq(issueThreadInteractions.id, p.card.id));
if (scenario === "foreign-source") {
const other = await fixture();
await server.db.update(issueThreadInteractions).set({ sourceRunId: other.runId }).where(eq(issueThreadInteractions.id, p.card.id));
}
if (scenario === "revised-plan") {
await documentService(server.db).unlockIssueDocument(f.issueId, "plan");
await documentService(server.db).upsertIssueDocument({ issueId: f.issueId, key: "plan", format: "markdown",
baseRevisionId: p.plan!.latestRevisionId, body: "Changed scope requires a new plan decision." });
}
await acceptFollowUp(f, p);
const before = await f.snapshot();
await expect(create(f)).rejects.toThrow(/proposal.*accept/i);
expect((await f.snapshot()).issues).toEqual(before.issues);
});
it("does not revive an older plan approval after a newer rejected plan proposal", async () => {
const f = await fixture();
const p = await proposal(f, true);
await accept(f, p);
const replacement = await p.svc.create(p.issue, { kind: "request_confirmation", payload: {
version: 1, prompt: "Approve this plan again?", target: {
type: "issue_document", key: "plan", issueId: f.issueId,
documentId: p.plan!.id, revisionId: p.plan!.latestRevisionId!,
},
} }, { agentId: f.agentId });
await p.svc.rejectInteraction(p.issue, replacement.id, {}, { userId: f.responsibleUserId! });
await acceptFollowUp(f, p);
await expect(create(f)).rejects.toThrow(/proposal.*accept/i);
});
it("decomposes a plan accepted through the onboarding checkbox card", async () => {
const f = await fixture();
const p = await proposal(f, true);
await accept(f, p);
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
const response = await fetch(`${server.apiUrl}/api/issues/${f.issueId}/accepted-plan-decompositions`, {
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ acceptedPlanRevisionId: p.plan!.latestRevisionId,
children: [{ title: "First discovery task", status: "backlog", workMode: "standard", priority: "medium", assigneeAgentId: f.agentId }] }),
});
const data = await response.json();
expect(response.status, JSON.stringify(data)).toBe(200);
expect(data.childIssueIds).toHaveLength(1);
});
it("includes a checkbox-approved plan in the native task context", async () => {
const f = await fixture();
const p = await proposal(f, true);
await accept(f, p);
await expect(f.authority.execute({ tool: "get_task_context", callId: randomUUID(), arguments: {} }))
.resolves.toMatchObject({ acceptedPlan: { revisionId: p.plan!.latestRevisionId } });
});
it.each([false, true])("retains only the selected plan scope after a later execution approval (anchored=%s)", async anchored => {
const f = await fixture();
const [issue] = await server.db.select().from(issues).where(eq(issues.id, f.issueId));
const plan = (await documentService(server.db).upsertIssueDocument({ issueId: f.issueId, key: "plan",
format: "markdown", body: "First: read-only discovery. Later: implementation, only if approved." })).document;
const svc = issueThreadInteractionService(server.db);
const selected = { id: "discovery", label: "Read-only discovery", description: "Inspect and write the architecture document." };
const card = await svc.create(issue, { kind: "request_checkbox_confirmation", payload: {
version: 1, prompt: "Which plan steps may start?", minSelected: 1, maxSelected: 2,
defaultSelectedOptionIds: ["discovery", "implementation"],
options: [selected, { id: "implementation", label: "Change the application" }],
target: { type: "issue_document", key: "plan", issueId: f.issueId, documentId: plan.id, revisionId: plan.latestRevisionId! },
} }, { agentId: f.agentId });
await svc.acceptInteraction(issue, card.id, { selectedOptionIds: [selected.id] }, { userId: f.responsibleUserId! });
await acceptFollowUp(f, { issue, plan, svc, card });
if (anchored) await server.db.update(heartbeatRuns).set({ contextSnapshot: {
issueId: f.issueId, planReviewInteraction: { acceptedTargetRevision: { revisionId: plan.latestRevisionId } },
} }).where(eq(heartbeatRuns.id, f.runId));
const context = await f.authority.execute({ tool: "get_task_context", callId: randomUUID(), arguments: {} }) as {
acceptedPlan: { revisionId: string; approvedOptions?: unknown[] };
};
expect(context.acceptedPlan.revisionId).toBe(plan.latestRevisionId);
expect(context.acceptedPlan.approvedOptions).toEqual([selected]);
const [saved] = await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, card.id));
expect(saved.result).toMatchObject({ selectedOptionIds: [selected.id] });
});
it.each([
{ selectedOptionIds: [] },
{ selectedOptionIds: ["unapproved"] },
{ selectedOptionIds: ["execute", "execute"] },
])("does not publish or decompose an invalid checkbox selection $selectedOptionIds", async ({ selectedOptionIds }) => {
const f = await fixture();
const p = await proposal(f, true);
await accept(f, p);
await server.db.update(issueThreadInteractions).set({ result: { version: 1, outcome: "accepted", selectedOptionIds } })
.where(eq(issueThreadInteractions.id, p.card.id));
await expect(f.authority.execute({ tool: "get_task_context", callId: randomUUID(), arguments: {} }))
.resolves.toMatchObject({ acceptedPlan: null });
const before = await f.snapshot();
await expect(issueService(server.db).decomposeAcceptedPlan(f.issueId, {
acceptedPlanRevisionId: p.plan!.latestRevisionId!, actorAgentId: f.agentId, actorRunId: f.runId,
children: [{ title: "Unapproved work", status: "backlog", workMode: "standard", priority: "medium" }],
})).rejects.toThrow(/accepted plan confirmation/);
expect((await f.snapshot()).issues).toEqual(before.issues);
});
it.each(["stale", "foreign-target", "empty-selection"])("does not execute a plan with %s approval", async kind => {
const f = await fixture();
const p = await proposal(f, true);
await accept(f, p);
if (kind === "stale") {
await documentService(server.db).unlockIssueDocument(f.issueId, "plan");
await documentService(server.db).upsertIssueDocument({ issueId: f.issueId, key: "plan", format: "markdown",
baseRevisionId: p.plan!.latestRevisionId, body: "A revised scope that has not been accepted." });
} else if (kind === "foreign-target") {
await server.db.update(issueThreadInteractions).set({ payload: { ...p.card.payload,
target: { type: "issue_document", key: "plan", issueId: f.blockerId, revisionId: p.plan!.latestRevisionId! } } as never }).where(eq(issueThreadInteractions.id, p.card.id));
} else {
await server.db.update(issueThreadInteractions).set({ result: { version: 1, outcome: "accepted", selectedOptionIds: [] } }).where(eq(issueThreadInteractions.id, p.card.id));
}
await expect(create(f)).rejects.toThrow(/proposal.*accept/i);
});
it("keeps ordinary delegation available even when its description invokes the skill", async () => {
const f = await fixture();
await server.db.update(issues).set({ originKind: "manual", description: "Please read /first-task, then delegate." }).where(eq(issues.id, f.issueId));
await expect(create(f)).resolves.toMatchObject({ task: { parentId: f.issueId } });
});
it("preserves native creation replay and dispatch recovery after a newer pending proposal", async () => {
const f = await fixture();
const p = await proposal(f);
await accept(f, p);
const wakes: unknown[] = [];
const authority = new PaperclipRunnerToolAuthority(server.db, { ...f, enqueueWakeup: async (...args) => { wakes.push(args); return null; } });
const input = { tool: "create_task", callId: randomUUID(), arguments: { title: "Approved child", idempotencyKey: "owned", status: "todo" } };
const first = await authority.execute(input);
expect(wakes).toHaveLength(1);
await proposal(f);
const before = await f.snapshot();
const [beforeRun] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
expect(await authority.execute({ ...input, callId: randomUUID() })).toEqual(first);
// Existing dispatch recovery remains bounded to this already-admitted child.
expect(wakes).toHaveLength(2);
const after = await f.snapshot();
expect(after.issues).toEqual(before.issues);
expect(after.activity).toEqual(before.activity);
const [afterRun] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
expect(afterRun.resultJson).toEqual(beforeRun.resultJson);
await expect(authority.execute({ ...input, arguments: { ...input.arguments, idempotencyKey: "new" } })).rejects.toThrow(/proposal.*accept/i);
});
it("replays an owned generic creation after a newer pending proposal", async () => {
const f = await fixture();
await accept(f, await proposal(f));
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
const route = `${server.apiUrl}/api/companies/${f.companyId}/issues`;
const body = { title: "Approved child", parentId: f.issueId, idempotencyKey: "owned-rest", assigneeAgentId: f.agentId, status: "backlog" };
const post = async (input = body) => {
const response = await fetch(route, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify(input) });
return { response, data: await response.json() };
};
const first = await post();
expect(first.response.status, JSON.stringify(first.data)).toBe(201);
await proposal(f);
const before = await f.snapshot();
const replay = await post();
expect(replay.response.status, JSON.stringify(replay.data)).toBe(200);
expect(replay.data).toMatchObject({ id: first.data.id, deduplicated: true });
const after = await f.snapshot();
expect(after.issues).toEqual(before.issues);
const fresh = await post({ ...body, idempotencyKey: "new-rest", title: "Another child" });
expect(fresh.response.status, JSON.stringify(fresh.data)).toBe(403);
});
});