mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
## 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
349 lines
24 KiB
TypeScript
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);
|
|
});
|
|
|
|
});
|