Files
PaperClipAI/server/src/__tests__/onboarding-first-task-admission.test.ts
T
Dotta b25f3e2af8 fix(onboarding): show native proposal confirmation recipes (#18025)
## Thinking Path

> - Paperclip lets people manage agent work through tasks.
> - First-task onboarding asks a new agent to propose work before it
starts.
> - The user must accept a saved proposal before the agent can create
its child task.
> - A native agent can ask for approval in a question card, which does
not record proposal acceptance.
> - The skill names confirmation cards but does not show their native
tool arguments.
> - This change adds executable native proposal recipes and checks their
real approval path.
> - The task admission rules and Product E2E assertions stay in place.

## Linked Issues or Issue Description

**What happened?**

A fresh onboarding agent asked the user to approve a child task through
an Approve/Revise question. The question looked like a decision, but its
answer cannot authorize child creation. The first-task skill teaches
native question calls for interviews and names proposal confirmation
cards without showing the native mapping.

**Expected behavior**

The agent should create a confirmation card for a single-task proposal,
or a checkbox card bound to the saved plan revision. Only the saved
human acceptance should admit the child task.

**Steps to reproduce**

1. Start first-task onboarding with a native agent.
2. Choose one task and ask the agent to propose a child before it starts
work.
3. Have the agent use a question card with Approve/Revise options.
4. Answer Approve. Child creation remains blocked because no proposal
confirmation was accepted.

Related public context: #17940, #13517.

## What Changed

- Show the native `request_human_input` arguments for confirmation and
plan-bound checkbox proposals.
- Explain that question answers supply scope and cannot grant proposal
authority.
- Resume the agent after either acceptance or rejection, and use new
proposal keys for revisions.
- Parse the shipped recipes and execute them through the real native
tool authority and interaction service.
- Verify that pending proposals, agent-only acceptance, and an answered
Approve/Revise question cannot admit child work.
- Use the existing isolated PostgreSQL test helper when a local test
database is supplied.
- Fix the required clean Grok npm-install check to prefix synthetic
revision identifiers with `g`. The former all-numeric, leading-zero
identifier was invalid SemVer and caused a reproduced `ETARGET` failure.
Customer release versions are unchanged.

## Verification

- Red: both new executable recipe checks fail against the preceding
skill.
- Green: 44 focused tests pass with zero skips against a task-owned
PostgreSQL server. The actual tool authority and approval service are
exercised.
- Human acceptance of either recipe admits the child. Agent-only
acceptance does not. Answering the question leaves the issue set
unchanged.
- Red/green real HTTP checks prove rejection saves the assignee
continuation, starts no extra model run, and still denies child
creation.
- `git diff --check` passes.
- Full repository `pnpm -r typecheck` and `pnpm build` passed on the
final approval-guidance revision.
- The first full local test attempt used an unmigrated external fixture
and was stopped after the cause was confirmed. A new migrated fixture
resolved that setup error; the second full attempt reached its explicit
10-minute limit before finishing. Both attempts are preserved and are
not claimed as passing. The temporary owned PostgreSQL container was
removed. The complete Linux CI workflow passed on the final PR head.
- The exact synthetic-version failure was reproduced with real npm and
an empty local packed registry. The new maintained regression keeps the
invalid-version negative control and installs corrected numeric-hash and
normal-hash controls. All 27 existing Grok helper tests and three
offline registry-boundary checks pass.
- Final head `52d0b92a00dcb0653b6fb48415cfaea979b8f21f`: [CI
40706933035, attempt
1](https://github.com/paperclipai/paperclip/actions/runs/40706933035)
passed; 55 successful checks, two skipped and one neutral. Greptile is
5/5 for this exact head with no unresolved threads. The original failed
build remains preserved.
- Live fresh-workspace acceptance has not passed yet. It follows the
reviewed testing rollout; this source test does not prove model
compliance.

## Risks

The change guides model tool selection. An agent can still ignore the
skill. Product admission remains the authority, and the live journey
must still verify human approval, native child execution, downloaded
artifact bytes, context reuse, and cleanup. There is no schema or API
change. The CI helper changes only temporary verifier versions and
preserves the existing registry integrity, install, lifecycle, and
isolation checks.

## Model Used

OpenAI Codex `gpt-6.1-sol`, with reasoning, source inspection, tool use,
and code execution. The host does not expose its configured
context-window size.

## 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
2026-10-28 09:47:55 -05:00

349 lines
24 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
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 { recoveryPostgresSupport as support } from "./helpers/recovery-postgres.js";
import { startRunnerApiTestServer } from "./helpers/runner-api-server.js";
async function nativeProposalRecipe(kind: "confirmation" | "checkbox", planRevisionId = 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 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("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 { interaction } = await f.authority.execute({ tool: "request_human_input", callId: randomUUID(), arguments: args }) as { interaction: { id: string; kind: string } };
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 }, 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);
});
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);
});
});