Files
PaperClipAI/server/src/__tests__/onboarding-first-task-admission.test.ts
T
DottaandPaperclip ce1ee9e89f fix: stop prompts from encouraging subtasks (#20362)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents read runtime prompts, tool help, skills, and role
instructions.
> - Several of these texts tell agents to create child tasks as a normal
next step.
> - One accepted-plan prompt even forbids work on the current task.
> - Task parentage is useful, but it does not need a prompt that
encourages task creation.
> - This pull request removes those prompts and keeps the task APIs
available.

## Linked Issues or Issue Description

Refs #10642. This change adjusts prompt wording; it does not add
delegation-cycle guards.

**Current behavior**
Prompts tell agents to create children after plan approval, use children
for long work, and create a task just to pass context. Onboarding
requires a child even for a single request.

**Proposed behavior**
Continue work on the current task. Keep explicit assignments, optional
task creation, parentage, dependencies, and approval gates. Use the
separate-task recipes when the user requests that structure or when
continuing existing work.

**Reason and benefit**
Reduce needless task trees without removing subtask support. Keep the
patch limited to wording, existing test expectations, and generated
catalog data.

## What Changed

- Align accepted-plan prompts and remove direct child-creation advice
from runtime and error text.
- Make task tool descriptions neutral. Keep their fields, defaults, and
handlers.
- Update the main skill, onboarding, CEO instructions, company
templates, specialist workflows, and matching guides.
- Keep historical release snapshots intact. Refresh the explicitly
seeded schema fixture's catalog digest; the server still verifies
historical release snapshot hashes.
- Refresh the affected generated catalogs, capability inventory,
semantic contracts, and fixture manifest.

## Verification

- Final tested head: `c6e304d0c105cdbfbe0b2ab0a233307fcd91a04b`.
- All 66 check runs are complete: 60 pass, one is neutral, and five are
skipped by CI conditions. Snyk also passes. No checks fail or remain
pending.
- [PR
CI](https://github.com/paperclipai/paperclip/actions/runs/42846226554)
passes typecheck, build, server/general tests, all eight E2E shards,
Runner support, Runner Rust, and the aggregate verify gate.
[Windows](https://github.com/paperclipai/paperclip/actions/runs/42846226546)
and [Docker
Runner](https://github.com/paperclipai/paperclip/actions/runs/42846226108)
also pass.
-
[Greptile](https://github.com/paperclipai/paperclip/pull/20362#issuecomment-6751419835)
and [Paperclip
review](https://github.com/paperclipai/paperclip/pull/20362#issuecomment-6751454010)
give this exact head 5/5. All six inline threads are resolved. Neither
current summary has findings.
- Merged master `bf0220d34a156c81853755c3d1365ddc527037de`. Resolved
three generated-file conflicts with the canonical generators.
- Local verification passes: 473 focused prompt, continuation, UI,
catalog, runtime and native-execution tests; Runner TypeScript build;
generated metadata; UI token gates; and diff checks.
- The prior head `c22018cb0f` failed three SDK evidence tests because
two source hashes were stale. Reproduced all three failures locally. The
existing generator refreshed only the hashes for the denial-copy file
and its test. All 53 affected evidence/source-pin tests then passed,
including private-value rejection. No scanner logic, assertions, or
runtime dependency pins changed. Current CI confirms the support suite
passes.
- A prior commit failed the Rust process reaping test. Its cause remains
unproven. No Rust code or assertions changed. Later Rust runs pass; this
does not establish the cause of the earlier failure.
- Local preflight built shared/plugin SDK packages, then stopped because
`cargo` is absent. Current-head CI supplies Rust and full build
coverage. `pnpm verify --explain` selects broad required CI; full local
suites were not repeated.

## Risks

- Wording changes guide the model; they do not enforce a ban on
subtasks.
- Existing installed skill versions and saved custom instructions need
their normal refresh process. This PR does not migrate company
configuration.
- Existing approved task assignments and approval gates remain in
effect.

## Model Used

- OpenAI GPT-6, through Codex, with reasoning, tool use, and code
execution. The exact routed model ID and context-window size were not
exposed in this session.

## 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-10 09:58:54 -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("<task-title>", "Approved work")
.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);
});
});